@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.
@@ -0,0 +1,216 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import {
4
+ type ResolvedMicroDotSpecV2,
5
+ decodeCatalogLockSync,
6
+ decodeResolvedMicroDotSpecSync,
7
+ } from '@bespokeagentics/microdots-authoring'
8
+
9
+ import {
10
+ type CompositionSpecV1,
11
+ type CompositionSpecV2,
12
+ decodeCompositionSpecSync,
13
+ decodeCompositionSpecVersionedSync,
14
+ encodeCompositionSpecV2Sync,
15
+ migrateCompositionSpecV1,
16
+ validateExistingSurfaceComposition,
17
+ } from './compositionSpec.ts'
18
+ import type { HostTopology } from './wire.ts'
19
+
20
+ const topology: HostTopology = {
21
+ host: { id: 'host', label: 'Host', ownedInputs: [] },
22
+ routes: [
23
+ {
24
+ path: '/',
25
+ label: 'Home',
26
+ title: 'Home',
27
+ sectionIds: ['main'],
28
+ mounts: [],
29
+ },
30
+ ],
31
+ wires: [],
32
+ watch: [],
33
+ }
34
+
35
+ const lock = decodeCatalogLockSync({
36
+ schemaVersion: 1,
37
+ snapshotSha256: 'a'.repeat(64),
38
+ compilerVersion: '0.1.0',
39
+ shapeDigests: { 'shape:view@1': 'e'.repeat(64) },
40
+ capabilityDigests: {},
41
+ backendCatalogDigest: 'b'.repeat(64),
42
+ targetMatrixDigest: 'c'.repeat(64),
43
+ allocationBaselineDigest: 'd'.repeat(64),
44
+ })
45
+
46
+ const resolved = (catalogLock = lock): ResolvedMicroDotSpecV2 =>
47
+ decodeResolvedMicroDotSpecSync({
48
+ schemaVersion: 2,
49
+ slug: 'quote-board',
50
+ domain: {
51
+ schemaVersion: 1,
52
+ entity: {
53
+ name: 'Quote',
54
+ pluralName: 'Quotes',
55
+ identityField: 'symbol',
56
+ },
57
+ fields: [
58
+ {
59
+ id: 'symbol',
60
+ key: 'symbol',
61
+ label: 'Symbol',
62
+ kind: 'text',
63
+ required: true,
64
+ constraints: {},
65
+ },
66
+ ],
67
+ operations: [],
68
+ },
69
+ surfaces: [
70
+ {
71
+ shape: 'shape:view@1',
72
+ subject: 'quote',
73
+ parameters: {},
74
+ tag: 'quote-board-view',
75
+ },
76
+ ],
77
+ backend: {
78
+ tag: 'quote-board-backend',
79
+ layout: {
80
+ rootId: 'records',
81
+ nodes: [
82
+ {
83
+ id: 'records',
84
+ primitive: 'records-table@1',
85
+ parameters: {},
86
+ },
87
+ ],
88
+ },
89
+ },
90
+ capabilityClosure: [],
91
+ target: {
92
+ id: 'cloudflare-worker@1',
93
+ provider: 'cloudflare',
94
+ runtime: 'worker',
95
+ support: 'supported',
96
+ proof: 'preflight',
97
+ requiredArtifacts: ['service/worker.ts'],
98
+ requiredBindings: [],
99
+ requiredSecrets: [],
100
+ acknowledgedConsequences: [],
101
+ },
102
+ identity: {
103
+ packageName: '@microdots/quote-board',
104
+ bundleName: 'quote-board.js',
105
+ port: 3116,
106
+ frontTags: ['quote-board-view'],
107
+ backendTag: 'quote-board-backend',
108
+ },
109
+ catalogLock,
110
+ provenance: {
111
+ selectedBy: 'human',
112
+ client: 'workbench',
113
+ },
114
+ })
115
+
116
+ const v1 = (
117
+ generate: CompositionSpecV1['generate'] = [],
118
+ ): CompositionSpecV1 => ({
119
+ schemaVersion: 1,
120
+ generate,
121
+ topology,
122
+ })
123
+
124
+ describe('CompositionSpec v1/v2 boundary', () => {
125
+ test('preserves the existing v1 decoder', () => {
126
+ expect(decodeCompositionSpecSync(v1())).toEqual(v1())
127
+ })
128
+
129
+ test('migrates topology-only v1 exactly under an explicit catalog lock', () => {
130
+ const result = migrateCompositionSpecV1(v1(), lock)
131
+ expect(result._tag).toBe('migrated')
132
+ if (result._tag !== 'migrated') {
133
+ throw new Error('expected topology-only migration to succeed')
134
+ }
135
+ expect(result.value).toEqual({
136
+ schemaVersion: 2,
137
+ generate: [],
138
+ topology,
139
+ catalogLock: lock,
140
+ })
141
+ expect(decodeCompositionSpecVersionedSync(result.value)).toEqual(
142
+ result.value,
143
+ )
144
+ })
145
+
146
+ test('does not invent missing v1 generation semantics', () => {
147
+ const result = migrateCompositionSpecV1(
148
+ v1([{ slug: 'quote', tag: 'quote-view', port: 3990 }]),
149
+ lock,
150
+ )
151
+ expect(result._tag).toBe('needs-input')
152
+ if (result._tag !== 'needs-input') {
153
+ throw new Error('expected migration input to be required')
154
+ }
155
+ expect(result.diagnostic.code).toBe('migration-input-required')
156
+ expect(result.diagnostic.destinationMutated).toBe(false)
157
+ })
158
+
159
+ test('decodes and encodes v2 without cutting over the v1 API', () => {
160
+ const generated: ReadonlyArray<ResolvedMicroDotSpecV2> = [resolved()]
161
+ const spec: CompositionSpecV2 = {
162
+ schemaVersion: 2,
163
+ generate: generated,
164
+ topology,
165
+ catalogLock: lock,
166
+ }
167
+ expect(decodeCompositionSpecVersionedSync(spec)).toEqual(spec)
168
+ expect(encodeCompositionSpecV2Sync(spec)).toEqual(spec)
169
+ })
170
+
171
+ test('accepts only topology-only v2 in the existing-surface lane', () => {
172
+ expect(
173
+ validateExistingSurfaceComposition(
174
+ {
175
+ schemaVersion: 2,
176
+ generate: [],
177
+ topology,
178
+ catalogLock: lock,
179
+ },
180
+ [],
181
+ ),
182
+ ).toEqual([])
183
+
184
+ expect(
185
+ validateExistingSurfaceComposition(
186
+ {
187
+ schemaVersion: 2,
188
+ generate: [resolved()],
189
+ topology,
190
+ catalogLock: lock,
191
+ },
192
+ [],
193
+ ),
194
+ ).toContainEqual({
195
+ path: 'generate',
196
+ message:
197
+ 'existing-surface composition requires generate[] to be empty; use the application-authoring compiler for new MicroDots',
198
+ })
199
+ })
200
+
201
+ test('rejects generated specs locked to a different catalog snapshot', () => {
202
+ const otherLock = decodeCatalogLockSync({
203
+ ...lock,
204
+ snapshotSha256: 'f'.repeat(64),
205
+ })
206
+
207
+ expect(() =>
208
+ decodeCompositionSpecVersionedSync({
209
+ schemaVersion: 2,
210
+ generate: [resolved()],
211
+ topology,
212
+ catalogLock: otherLock,
213
+ }),
214
+ ).toThrow()
215
+ })
216
+ })
@@ -0,0 +1,240 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import type {
4
+ ManifestAttribute,
5
+ ManifestEvent,
6
+ ManifestTag,
7
+ } from '@bespokeagentics/microdots-element'
8
+
9
+ import type { CompositionSpec } from './compositionSpec.ts'
10
+ import { schemaEventsFor } from './compositionSpec.ts'
11
+ import {
12
+ FillCompositionError,
13
+ compositionPrompt,
14
+ fillComposition,
15
+ liveFillPrompt,
16
+ } from './fillComposition.ts'
17
+ import type { HostTopology, Wire } from './wire.ts'
18
+
19
+ const attribute = (name: string): ManifestAttribute => ({
20
+ name,
21
+ type: 'string',
22
+ required: false,
23
+ live: true,
24
+ ownership: 'dot',
25
+ })
26
+
27
+ const manifestEvent = (name: string, field: string): ManifestEvent => ({
28
+ name,
29
+ payload: {
30
+ ref: `@microdots/test/contract#${name}`,
31
+ jsonSchema: {
32
+ dialect: 'draft-2020-12',
33
+ schema: { type: 'object', properties: { [field]: { type: 'string' } } },
34
+ definitions: {},
35
+ },
36
+ },
37
+ })
38
+
39
+ const SURFACES: ReadonlyArray<ManifestTag> = [
40
+ {
41
+ tag: 'readout-view',
42
+ attributes: [attribute('symbol')],
43
+ events: [manifestEvent('quote-changed', 'symbol')],
44
+ },
45
+ {
46
+ tag: 'fleet-health',
47
+ attributes: [attribute('region')],
48
+ events: [],
49
+ },
50
+ ]
51
+
52
+ const wire: Wire = {
53
+ id: 'w1',
54
+ from: 'readout-view',
55
+ event: 'quote-changed',
56
+ field: 'symbol',
57
+ fieldType: 'string',
58
+ to: 'fleet-health',
59
+ input: 'region',
60
+ inputType: 'string',
61
+ transform: {
62
+ _tag: 'lookup',
63
+ rows: { FOLD: 'us-east' },
64
+ fallback: 'us-east',
65
+ },
66
+ envs: ['dev'],
67
+ plain: 'When the quote changes, set the region.',
68
+ }
69
+
70
+ const topology: HostTopology = {
71
+ host: { id: 'demo-host', label: 'Demo', ownedInputs: [] },
72
+ routes: [
73
+ {
74
+ path: '/price',
75
+ label: 'Price',
76
+ title: 'Price',
77
+ sectionIds: ['section-price'],
78
+ mounts: [{ tag: 'readout-view', slotId: 'price-slot' }],
79
+ },
80
+ {
81
+ path: '/fleet',
82
+ label: 'Fleet',
83
+ title: 'Fleet',
84
+ sectionIds: ['section-fleet'],
85
+ mounts: [{ tag: 'fleet-health', slotId: 'fleet-slot' }],
86
+ },
87
+ ],
88
+ slotManifest: {
89
+ theme: { name: '@bespokeagentics/microdots-theme', version: '0.1.0' },
90
+ slots: [
91
+ { id: 'price-slot', kind: 'band', row: 1, capacity: 1 },
92
+ { id: 'fleet-slot', kind: 'band', row: 1, capacity: 1 },
93
+ ],
94
+ },
95
+ wires: [wire],
96
+ watch: [],
97
+ }
98
+
99
+ const gold: CompositionSpec = {
100
+ schemaVersion: 1,
101
+ brief: 'Wire the ticker into the fleet region.',
102
+ generate: [],
103
+ topology,
104
+ }
105
+
106
+ describe('compositionPrompt', () => {
107
+ test('is the brief plus the schema instruction — not the wiki', () => {
108
+ const prompt = compositionPrompt('compose price and fleet')
109
+ expect(prompt).toContain('Brief: compose price and fleet')
110
+ expect(prompt).toContain('JSON Schema')
111
+ expect(prompt).toContain('generate must be []')
112
+ expect(prompt).not.toContain('AGENTS.md')
113
+ })
114
+
115
+ test('offers the coordinated current theme version in the live JSON shape', () => {
116
+ expect(liveFillPrompt('compose price and fleet', SURFACES)).toContain(
117
+ '"version":"0.1.1"',
118
+ )
119
+ })
120
+ })
121
+
122
+ describe('fillComposition', () => {
123
+ test('decodes and validates a stub that honours the schema', () => {
124
+ const spec = fillComposition({
125
+ brief: gold.brief ?? '',
126
+ surfaces: SURFACES,
127
+ complete: (prompt, schema) => {
128
+ expect(prompt).toContain('Wire the ticker')
129
+ expect(schemaEventsFor(schema, 'readout-view')).toEqual([
130
+ 'quote-changed',
131
+ ])
132
+ expect(schemaEventsFor(schema, 'readout-view')).not.toContain(
133
+ 'bid-placed',
134
+ )
135
+ return JSON.stringify(gold)
136
+ },
137
+ })
138
+ expect(spec).toEqual(gold)
139
+ })
140
+
141
+ test('rejects non-JSON', () => {
142
+ expect(() =>
143
+ fillComposition({
144
+ brief: 'x',
145
+ surfaces: SURFACES,
146
+ complete: () => 'not json',
147
+ }),
148
+ ).toThrow(FillCompositionError)
149
+ try {
150
+ fillComposition({
151
+ brief: 'x',
152
+ surfaces: SURFACES,
153
+ complete: () => 'not json',
154
+ })
155
+ } catch (error) {
156
+ expect(error).toBeInstanceOf(FillCompositionError)
157
+ if (error instanceof FillCompositionError) {
158
+ expect(error.failure._tag).toBe('invalid-json')
159
+ }
160
+ }
161
+ })
162
+
163
+ test('rejects a value the Effect schema will not decode', () => {
164
+ try {
165
+ fillComposition({
166
+ brief: 'x',
167
+ surfaces: SURFACES,
168
+ complete: () => JSON.stringify({ schemaVersion: 2 }),
169
+ })
170
+ expect.unreachable('decode should fail')
171
+ } catch (error) {
172
+ expect(error).toBeInstanceOf(FillCompositionError)
173
+ if (error instanceof FillCompositionError) {
174
+ expect(error.failure._tag).toBe('decode-failed')
175
+ }
176
+ }
177
+ })
178
+
179
+ test('rejects a draft wire even when JSON Schema was bypassed', () => {
180
+ const bogus: CompositionSpec = {
181
+ ...gold,
182
+ topology: {
183
+ ...topology,
184
+ wires: [{ ...wire, event: 'bid-placed' }],
185
+ },
186
+ }
187
+ try {
188
+ fillComposition({
189
+ brief: 'x',
190
+ surfaces: SURFACES,
191
+ complete: () => JSON.stringify(bogus),
192
+ })
193
+ expect.unreachable('validate should fail')
194
+ } catch (error) {
195
+ expect(error).toBeInstanceOf(FillCompositionError)
196
+ if (error instanceof FillCompositionError) {
197
+ expect(error.failure._tag).toBe('invalid')
198
+ if (error.failure._tag === 'invalid') {
199
+ expect(error.failure.issues[0]?.path).toBe('topology.wires[0]')
200
+ }
201
+ }
202
+ }
203
+ })
204
+
205
+ test('refuses an invented mount tag even when JSON Schema was bypassed', () => {
206
+ const bogus: CompositionSpec = {
207
+ ...gold,
208
+ topology: {
209
+ ...topology,
210
+ routes: [
211
+ ...topology.routes,
212
+ {
213
+ path: '/weather',
214
+ label: 'Weather',
215
+ title: 'Weather',
216
+ sectionIds: ['section-weather'],
217
+ mounts: [{ tag: 'weather-5day-forecast', slotId: 'price-slot' }],
218
+ },
219
+ ],
220
+ },
221
+ }
222
+ try {
223
+ fillComposition({
224
+ brief: 'rainy weather',
225
+ surfaces: SURFACES,
226
+ complete: () => JSON.stringify(bogus),
227
+ })
228
+ expect.unreachable('invented tags must fail')
229
+ } catch (error) {
230
+ expect(error).toBeInstanceOf(FillCompositionError)
231
+ if (
232
+ error instanceof FillCompositionError &&
233
+ error.failure._tag === 'invalid'
234
+ ) {
235
+ expect(error.message).toContain('weather-5day-forecast')
236
+ expect(error.message).not.toContain('topology.routes')
237
+ }
238
+ }
239
+ })
240
+ })
@@ -0,0 +1,159 @@
1
+ import type { ManifestTag } from '@bespokeagentics/microdots-element'
2
+
3
+ import {
4
+ type CompositionIssue,
5
+ type CompositionSpec,
6
+ type JsonSchema,
7
+ compositionJsonSchema,
8
+ decodeCompositionSpecSync,
9
+ validateComposition,
10
+ } from './compositionSpec.ts'
11
+
12
+ /**
13
+ * The entire generate-time prompt. Legal names live in the JSON Schema
14
+ * (`compositionJsonSchema`); this text is the brief plus that instruction.
15
+ * See `wiki/plans/active/microdots-specialist-spec-filler.md`.
16
+ */
17
+ export const compositionPrompt = (brief: string): string =>
18
+ [
19
+ 'Emit a CompositionSpec JSON object and nothing else.',
20
+ 'Legal tags, events, attributes and transforms are in the JSON Schema.',
21
+ 'Do not invent names. Use only catalogued tags. generate must be [].',
22
+ `Brief: ${brief}`,
23
+ ].join('\n')
24
+
25
+ /** Compact catalog for a live model that cannot take a JSON Schema grammar. */
26
+ export const compositionVocabulary = (
27
+ surfaces: ReadonlyArray<ManifestTag>,
28
+ ): string =>
29
+ surfaces
30
+ .map(surface => {
31
+ const events =
32
+ surface.events.map(event => event.name).join(', ') || '(none)'
33
+ const attributes =
34
+ surface.attributes.map(attribute => attribute.name).join(', ') ||
35
+ '(none)'
36
+ return `- ${surface.tag}: events [${events}]; attributes [${attributes}]`
37
+ })
38
+ .join('\n')
39
+
40
+ export const liveFillPrompt = (
41
+ brief: string,
42
+ surfaces: ReadonlyArray<ManifestTag>,
43
+ ): string =>
44
+ [
45
+ compositionPrompt(brief),
46
+ '',
47
+ 'Catalogued tags (use only these; do not invent tags, events or attributes):',
48
+ compositionVocabulary(surfaces),
49
+ '',
50
+ 'generate must be []. Every mount and wire tag must be a catalogued tag.',
51
+ 'JSON shape:',
52
+ '{"schemaVersion":1,"brief":"...","generate":[],"topology":{"host":{"id":"composed","label":"Composed","ownedInputs":[]},"routes":[{"path":"/readout","label":"Readout","title":"Readout","sectionIds":["section-readout"],"mounts":[{"tag":"readout-view","slotId":"readout-slot"}]}],"slotManifest":{"theme":{"name":"@bespokeagentics/microdots-theme","version":"0.1.1"},"slots":[{"id":"readout-slot","kind":"band","row":1,"capacity":1}]},"wires":[{"id":"w1","from":"readout-view","event":"quote-changed","field":"symbol","fieldType":"string","to":"roster-backend","input":"refresh-token","inputType":"string","transform":{"_tag":"direct"},"envs":["dev","preview","prod"],"plain":"When the quote changes, refresh the roster backend."}],"watch":[]}}',
53
+ 'transform is {"_tag":"direct"} or {"_tag":"lookup","rows":{...},"fallback":"..."} or {"_tag":"condition","field":"...","op":"is","value":"..."}.',
54
+ 'envs is ["dev","preview","prod"]. Slot kind is band. fieldType and inputType are string, number, boolean, json, or enum.',
55
+ ].join('\n')
56
+
57
+ /** Strip fences and surrounding prose so a small model can narrate. */
58
+ export const extractCompositionJson = (answer: string): string => {
59
+ const trimmed = answer
60
+ .trim()
61
+ .replace(/^```(?:json)?\s*/u, '')
62
+ .replace(/\s*```$/u, '')
63
+ const start = trimmed.indexOf('{')
64
+ const end = trimmed.lastIndexOf('}')
65
+ return start !== -1 && end > start ? trimmed.slice(start, end + 1) : trimmed
66
+ }
67
+
68
+ /**
69
+ * Test seam for a constrained decoder. Production will pass a model call
70
+ * that honours `schema`; tests pass a stub. The adapter never writes files.
71
+ */
72
+ export type CompleteCompositionJson = (
73
+ prompt: string,
74
+ schema: JsonSchema,
75
+ ) => string
76
+
77
+ export type FillCompositionFailure =
78
+ | { readonly _tag: 'invalid-json'; readonly detail: string }
79
+ | { readonly _tag: 'decode-failed'; readonly detail: string }
80
+ | {
81
+ readonly _tag: 'invalid'
82
+ readonly issues: ReadonlyArray<CompositionIssue>
83
+ }
84
+
85
+ export class FillCompositionError extends Error {
86
+ readonly name = 'FillCompositionError'
87
+ /**
88
+ * Declared and assigned, NOT a `constructor(readonly failure: …)` parameter
89
+ * property. A parameter property is TS syntax that needs code GENERATED for
90
+ * it, and node's type stripping is strip-only — so the moment this module
91
+ * joined a vite config's import chain it killed `bun run build` with
92
+ * `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` while every other gate stayed green.
93
+ * Third instance of the same hazard class; see
94
+ * `wiki/patterns-and-traps/a-workspace-package-import-kills-a-vite-config.md`.
95
+ */
96
+ readonly failure: FillCompositionFailure
97
+
98
+ constructor(failure: FillCompositionFailure) {
99
+ super(fillFailureMessage(failure))
100
+ this.failure = failure
101
+ }
102
+ }
103
+
104
+ const fillFailureMessage = (failure: FillCompositionFailure): string => {
105
+ switch (failure._tag) {
106
+ case 'invalid-json':
107
+ case 'decode-failed':
108
+ return failure.detail
109
+ case 'invalid':
110
+ return failure.issues.map(issue => issue.message).join(' ')
111
+ }
112
+ }
113
+
114
+ const detailOf = (cause: unknown, fallback: string): string =>
115
+ cause instanceof Error ? cause.message : fallback
116
+
117
+ export type FillCompositionOptions = {
118
+ readonly brief: string
119
+ readonly surfaces: ReadonlyArray<ManifestTag>
120
+ readonly complete: CompleteCompositionJson
121
+ /** Closed over the catalog when false (the Workbench picker). Default false. */
122
+ readonly allowNewTags?: boolean
123
+ }
124
+
125
+ /**
126
+ * Constrained-decode a CompositionSpec. Fail closed: malformed JSON, a
127
+ * value the Effect schema rejects, or a draft wire never reach the compiler.
128
+ */
129
+ export const fillComposition = (
130
+ options: FillCompositionOptions,
131
+ ): CompositionSpec => {
132
+ const schema = compositionJsonSchema(options.surfaces, {
133
+ allowNewTags: options.allowNewTags ?? false,
134
+ })
135
+ const raw = options.complete(compositionPrompt(options.brief), schema)
136
+ let parsed: unknown
137
+ try {
138
+ parsed = JSON.parse(raw)
139
+ } catch (cause) {
140
+ throw new FillCompositionError({
141
+ _tag: 'invalid-json',
142
+ detail: detailOf(cause, 'JSON.parse failed'),
143
+ })
144
+ }
145
+ let spec: CompositionSpec
146
+ try {
147
+ spec = decodeCompositionSpecSync(parsed)
148
+ } catch (cause) {
149
+ throw new FillCompositionError({
150
+ _tag: 'decode-failed',
151
+ detail: detailOf(cause, 'CompositionSpec decode failed'),
152
+ })
153
+ }
154
+ const issues = validateComposition(spec, options.surfaces)
155
+ if (issues.length > 0) {
156
+ throw new FillCompositionError({ _tag: 'invalid', issues })
157
+ }
158
+ return spec
159
+ }