@r0hitsharma/dashboard-kit 0.12.0-rohit-fork-ci.1

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/src/schema.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `@r0hitsharma/dashboard-kit` — the generic, manifest-driven dashboard
3
+ * engine.
4
+ *
5
+ * Three concerns stay SEPARABLE rather than conflated the way Grafana (flat,
6
+ * absolute `gridPos` + string-interpolated query blobs) or Superset (three
7
+ * disconnected config blobs) do:
8
+ *
9
+ * 1. `layout` — a recursive composition tree (Vega-Lite `hconcat`/
10
+ * `vconcat` style), independent of what each leaf renders.
11
+ * 2. `widgets` — a flat, id-keyed registry of widget nodes (`{component,
12
+ * props, dataBinding}`), each resolved through a string -> component
13
+ * registry key (see `registry.tsx`).
14
+ * 3. `dataBinding` — a typed `{source, fields}` pointer at a data source,
15
+ * uniform across whatever transport a consuming app resolves `source`
16
+ * ids against (live tail, replay cursor, a REST poll, ...).
17
+ *
18
+ * The one addition none of Grafana/Superset/Perspective/Vega-Lite model as
19
+ * part of the *widget* node itself is `interaction.reads`/`writes`: the
20
+ * shared-selection keys a widget consumes or produces. Declaring it here
21
+ * makes cross-filtering a declared, auditable property of the manifest
22
+ * instead of implicit wiring buried in component code — see `interaction.ts`
23
+ * for how those keys reach a real interaction store.
24
+ */
25
+
26
+ export type LayoutDirection = 'row' | 'column';
27
+
28
+ /** A recursive split node — maps onto the design-system's `SplitLayout` orientation. */
29
+ export type SplitLayoutNode = {
30
+ type: 'split';
31
+ direction: LayoutDirection;
32
+ /** Relative share of the parent split's main axis; children need not sum to 1. */
33
+ size?: number;
34
+ /**
35
+ * Render this split as a DRAG-RESIZABLE panel group (design-system's
36
+ * `SplitLayout`, itself an Ark `Splitter`) instead of a plain flex box,
37
+ * with one panel per child and a resize handle between each pair. N-way by
38
+ * construction: three children give two handles.
39
+ *
40
+ * Opt-in rather than the default, because a `SplitLayout` needs hard panel
41
+ * sizes and therefore cannot flex-wrap: the non-resizable path keeps the
42
+ * responsive wrapping some dashboards rely on at narrow widths. A
43
+ * `direction: 'column'` split additionally needs {@link height}, since a
44
+ * vertical splitter has no content-driven size to divide.
45
+ */
46
+ resizable?: boolean;
47
+ /** Required for a resizable `column` split: the CSS height to divide. */
48
+ height?: string;
49
+ children: LayoutNode[];
50
+ };
51
+
52
+ /** A leaf that resolves to one entry in the flat `widgets` registry. */
53
+ export type WidgetLayoutNode = {
54
+ type: 'widget';
55
+ /** Key into `DashboardSpec['widgets']`. */
56
+ ref: string;
57
+ /** Relative share of the parent split's main axis. */
58
+ size?: number;
59
+ };
60
+
61
+ export type LayoutNode = SplitLayoutNode | WidgetLayoutNode;
62
+
63
+ /**
64
+ * What data source feeds a widget, and how its fields map onto the shape the
65
+ * component needs — a typed channel-to-field mapping (Vega-Lite style)
66
+ * rather than an opaque query string. `source` ids are resolved through
67
+ * whatever {@link DashboardDataSources} table the host app supplies;
68
+ * `dashboard-kit` itself is agnostic to what backs a source (a mock array, a
69
+ * live tail, a replay cursor).
70
+ */
71
+ export type DataBinding = {
72
+ /** Data-source id, e.g. `"stream:metrics.throughput"`. */
73
+ source: string;
74
+ /** Field name mapping: which field of a source record feeds which slot. */
75
+ fields: Record<string, string>;
76
+ };
77
+
78
+ /**
79
+ * Shared-selection keys a widget consumes (`reads`) or produces (`writes`).
80
+ * Symmetric by design — any widget may read or write any key, no
81
+ * master/detail hardcoding.
82
+ */
83
+ export type WidgetInteraction = {
84
+ reads?: string[];
85
+ writes?: string[];
86
+ /**
87
+ * Per-field AGENT EXPOSURE: which of this widget's interaction keys an
88
+ * agent (a drive tool, a command bar) may write. **Default-deny**: a key a
89
+ * human can write through the UI is NOT agent-writable unless it is listed
90
+ * here.
91
+ *
92
+ * This is the manifest's half of the contract; a host app's agent-facing
93
+ * tools consult {@link collectAgentWritableKeys} and refuse anything
94
+ * outside it, so the agent surface is a declared, auditable property of
95
+ * the dashboard rather than "whatever setters happen to be in scope".
96
+ */
97
+ agentWritable?: string[];
98
+ };
99
+
100
+ export type ThresholdSeverity = 'success' | 'warning' | 'critical';
101
+
102
+ export type ThresholdRule = {
103
+ op: 'gte' | 'lte' | 'gt' | 'lt' | 'eq';
104
+ value: number;
105
+ severity: ThresholdSeverity;
106
+ };
107
+
108
+ /** One declarative column for a table-rendering widget. */
109
+ export type WidgetTableColumn = {
110
+ accessorKey: string;
111
+ header: string;
112
+ /** How the registry's table adapter should render the cell. */
113
+ render?: 'text' | 'number' | 'currency' | 'percent' | 'badge' | 'sparkline';
114
+ };
115
+
116
+ export type WidgetNode = {
117
+ id: string;
118
+ /** Component-registry key, e.g. `"panel.stat"` or `"chart.line"`. */
119
+ component: string;
120
+ title?: string;
121
+ /**
122
+ * Component-specific configuration, passed through to the resolved
123
+ * component-registry entry. Kept to JSON-serializable shapes (no
124
+ * functions) so a manifest stays a genuine, inspectable data value —
125
+ * `columns` for table widgets is the one structured exception.
126
+ */
127
+ props?: Record<string, unknown> & { columns?: WidgetTableColumn[] };
128
+ dataBinding?: DataBinding;
129
+ interaction?: WidgetInteraction;
130
+ thresholds?: ThresholdRule[];
131
+ };
132
+
133
+ export type DashboardSpec = {
134
+ version: 1;
135
+ title?: string;
136
+ layout: LayoutNode;
137
+ /** Flat registry of widget nodes, referenced by id from `layout`. */
138
+ widgets: Record<string, WidgetNode>;
139
+ };
140
+
141
+ /**
142
+ * Data-source table keyed by `DataBinding['source']` — every source resolves
143
+ * to an array of plain records; scalar widgets (e.g. a stat tile reading a
144
+ * gauge) read the last record. A host app owns populating this (from a mock
145
+ * fixture, a live subscription snapshot, a query result, ...); the engine
146
+ * only ever reads it.
147
+ */
148
+ export type DashboardDataSources = Record<string, Record<string, unknown>[]>;
@@ -0,0 +1,257 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { DashboardSpec, WidgetNode } from './schema.js';
4
+ import { collectAgentWritableKeys, validateDashboardSpec } from './validate.js';
5
+
6
+ /**
7
+ * A minimal, structurally valid spec: one widget placed by a single-widget
8
+ * layout. Individual tests clone and mutate it to isolate one failure at a
9
+ * time. Pure logic only — nothing here renders a component (DOM-free, matching
10
+ * the repo's node-environment test style).
11
+ */
12
+ // The `widgets.a` intersection records what the fixture guarantees: `widgets`
13
+ // stays an open `Record` (tests add keys like `orphan` to it), while `a` is a
14
+ // known `WidgetNode` rather than the `| undefined` that
15
+ // `noUncheckedIndexedAccess` gives every other lookup.
16
+ function baseSpec(): DashboardSpec & { widgets: { a: WidgetNode } } {
17
+ return {
18
+ version: 1,
19
+ title: 'Sample',
20
+ layout: { type: 'widget', ref: 'a' },
21
+ widgets: {
22
+ a: { id: 'a', component: 'note', props: { text: 'hello' } },
23
+ },
24
+ };
25
+ }
26
+
27
+ describe('validateDashboardSpec — structural', () => {
28
+ it('accepts a minimal valid spec', () => {
29
+ const result = validateDashboardSpec(baseSpec());
30
+ expect(result.ok).toBe(true);
31
+ if (result.ok) expect(result.issues).toEqual([]);
32
+ });
33
+
34
+ it('accepts a nested split layout', () => {
35
+ const spec: DashboardSpec = {
36
+ version: 1,
37
+ layout: {
38
+ type: 'split',
39
+ direction: 'row',
40
+ children: [
41
+ { type: 'widget', ref: 'a' },
42
+ {
43
+ type: 'split',
44
+ direction: 'column',
45
+ children: [{ type: 'widget', ref: 'b' }],
46
+ },
47
+ ],
48
+ },
49
+ widgets: {
50
+ a: { id: 'a', component: 'note' },
51
+ b: { id: 'b', component: 'stat' },
52
+ },
53
+ };
54
+ expect(validateDashboardSpec(spec).ok).toBe(true);
55
+ });
56
+
57
+ it('rejects a wrong version literal', () => {
58
+ const spec = { ...baseSpec(), version: 2 } as unknown;
59
+ const result = validateDashboardSpec(spec);
60
+ expect(result.ok).toBe(false);
61
+ });
62
+
63
+ it('rejects a malformed layout node (missing type)', () => {
64
+ const spec = {
65
+ version: 1,
66
+ layout: { ref: 'a' },
67
+ widgets: { a: { id: 'a', component: 'note' } },
68
+ } as unknown;
69
+ expect(validateDashboardSpec(spec).ok).toBe(false);
70
+ });
71
+
72
+ it('rejects a bad threshold enum', () => {
73
+ const spec = baseSpec();
74
+ spec.widgets.a.thresholds = [
75
+ // @ts-expect-error — intentionally invalid op for the test
76
+ { op: 'between', value: 1, severity: 'warning' },
77
+ ];
78
+ expect(validateDashboardSpec(spec).ok).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('validateDashboardSpec — referential', () => {
83
+ it('flags a layout ref with no matching widget', () => {
84
+ const spec = baseSpec();
85
+ spec.layout = { type: 'widget', ref: 'missing' };
86
+ const result = validateDashboardSpec(spec);
87
+ expect(result.ok).toBe(false);
88
+ if (!result.ok) {
89
+ expect(result.issues.some((i) => i.path === 'layout.ref')).toBe(true);
90
+ expect(result.issues[0]?.message).toContain('unknown widget');
91
+ }
92
+ });
93
+
94
+ it('flags an orphaned widget never placed by the layout', () => {
95
+ const spec = baseSpec();
96
+ spec.widgets.orphan = { id: 'orphan', component: 'note' };
97
+ const result = validateDashboardSpec(spec);
98
+ expect(result.ok).toBe(false);
99
+ if (!result.ok) {
100
+ expect(result.issues.some((i) => i.path === 'widgets.orphan')).toBe(true);
101
+ }
102
+ });
103
+
104
+ it('flags a widget whose id disagrees with its registry key', () => {
105
+ const spec = baseSpec();
106
+ spec.widgets.a.id = 'mismatch';
107
+ const result = validateDashboardSpec(spec);
108
+ expect(result.ok).toBe(false);
109
+ if (!result.ok) {
110
+ expect(result.issues.some((i) => i.path === 'widgets.a.id')).toBe(true);
111
+ }
112
+ });
113
+
114
+ it('flags a resizable column split with no height', () => {
115
+ const spec: DashboardSpec = {
116
+ version: 1,
117
+ layout: {
118
+ type: 'split',
119
+ direction: 'column',
120
+ resizable: true,
121
+ children: [{ type: 'widget', ref: 'a' }],
122
+ },
123
+ widgets: { a: { id: 'a', component: 'note' } },
124
+ };
125
+ const result = validateDashboardSpec(spec);
126
+ expect(result.ok).toBe(false);
127
+ if (!result.ok) {
128
+ expect(result.issues.some((i) => i.path.endsWith('.height'))).toBe(true);
129
+ }
130
+ });
131
+
132
+ it('accepts a resizable column split that declares a height', () => {
133
+ const spec: DashboardSpec = {
134
+ version: 1,
135
+ layout: {
136
+ type: 'split',
137
+ direction: 'column',
138
+ resizable: true,
139
+ height: '400px',
140
+ children: [{ type: 'widget', ref: 'a' }],
141
+ },
142
+ widgets: { a: { id: 'a', component: 'note' } },
143
+ };
144
+ expect(validateDashboardSpec(spec).ok).toBe(true);
145
+ });
146
+
147
+ it('accepts a resizable ROW split without a height (row divides width)', () => {
148
+ const spec: DashboardSpec = {
149
+ version: 1,
150
+ layout: {
151
+ type: 'split',
152
+ direction: 'row',
153
+ resizable: true,
154
+ children: [
155
+ { type: 'widget', ref: 'a' },
156
+ { type: 'widget', ref: 'b' },
157
+ ],
158
+ },
159
+ widgets: {
160
+ a: { id: 'a', component: 'note' },
161
+ b: { id: 'b', component: 'stat' },
162
+ },
163
+ };
164
+ expect(validateDashboardSpec(spec).ok).toBe(true);
165
+ });
166
+ });
167
+
168
+ describe('validateDashboardSpec — agentWritable', () => {
169
+ it('flags an agentWritable key not present in writes', () => {
170
+ const spec = baseSpec();
171
+ spec.widgets.a.interaction = {
172
+ writes: ['highlightedKey'],
173
+ agentWritable: ['selectedTimeRange'],
174
+ };
175
+ const result = validateDashboardSpec(spec);
176
+ expect(result.ok).toBe(false);
177
+ if (!result.ok) {
178
+ expect(
179
+ result.issues.some(
180
+ (i) => i.path === 'widgets.a.interaction.agentWritable',
181
+ ),
182
+ ).toBe(true);
183
+ }
184
+ });
185
+
186
+ it('accepts an agentWritable key that is also written', () => {
187
+ const spec = baseSpec();
188
+ spec.widgets.a.interaction = {
189
+ writes: ['highlightedKey'],
190
+ agentWritable: ['highlightedKey'],
191
+ };
192
+ expect(validateDashboardSpec(spec).ok).toBe(true);
193
+ });
194
+ });
195
+
196
+ describe('validateDashboardSpec — knownComponents', () => {
197
+ it('flags a component key outside the provided allow-list', () => {
198
+ const spec = baseSpec();
199
+ const result = validateDashboardSpec(spec, {
200
+ knownComponents: ['stat', 'table'],
201
+ });
202
+ expect(result.ok).toBe(false);
203
+ if (!result.ok) {
204
+ expect(result.issues.some((i) => i.path === 'widgets.a.component')).toBe(
205
+ true,
206
+ );
207
+ }
208
+ });
209
+
210
+ it('passes when every component key is known', () => {
211
+ const spec = baseSpec();
212
+ expect(validateDashboardSpec(spec, { knownComponents: ['note'] }).ok).toBe(
213
+ true,
214
+ );
215
+ });
216
+ });
217
+
218
+ describe('collectAgentWritableKeys', () => {
219
+ it('returns an empty set by default (default-deny)', () => {
220
+ expect(collectAgentWritableKeys(baseSpec()).size).toBe(0);
221
+ });
222
+
223
+ it('unions agentWritable keys across every widget', () => {
224
+ const spec: DashboardSpec = {
225
+ version: 1,
226
+ layout: {
227
+ type: 'split',
228
+ direction: 'row',
229
+ children: [
230
+ { type: 'widget', ref: 'a' },
231
+ { type: 'widget', ref: 'b' },
232
+ ],
233
+ },
234
+ widgets: {
235
+ a: {
236
+ id: 'a',
237
+ component: 'table',
238
+ interaction: {
239
+ writes: ['highlightedKey'],
240
+ agentWritable: ['highlightedKey'],
241
+ },
242
+ },
243
+ b: {
244
+ id: 'b',
245
+ component: 'note',
246
+ interaction: {
247
+ writes: ['selectedTimeRange'],
248
+ agentWritable: ['selectedTimeRange'],
249
+ },
250
+ },
251
+ },
252
+ };
253
+ expect(collectAgentWritableKeys(spec)).toEqual(
254
+ new Set(['highlightedKey', 'selectedTimeRange']),
255
+ );
256
+ });
257
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Manifest validation — the gate that must land before any "an agent or
3
+ * command bar submits a manifest patch" feature can trust a `DashboardSpec`.
4
+ *
5
+ * Two layers, because zod alone can't catch the interesting failures:
6
+ *
7
+ * 1. STRUCTURAL — `dashboardSpecSchema` below. Shape, enums, required
8
+ * fields, no malformed widget nodes.
9
+ * 2. REFERENTIAL — {@link validateDashboardSpec}'s extra passes. Every
10
+ * `layout` leaf must name a widget that exists; every widget must be
11
+ * reachable from the layout; a resizable `column` split must carry a
12
+ * `height`; an `agentWritable` key must actually be writable. These are
13
+ * the mistakes an agent-authored patch actually makes, and none of them
14
+ * are expressible as a type.
15
+ *
16
+ * `zod` deliberately does NOT validate `component` against a registry here:
17
+ * `schema.ts` is the data contract and a registry is one possible resolution
18
+ * of it. The renderer degrades gracefully on an unknown component key (it
19
+ * renders an `UnknownWidget` marker), and coupling the two would make the
20
+ * schema un-shareable. {@link validateDashboardSpec} takes an optional set of
21
+ * known component keys for callers who DO want that checked.
22
+ */
23
+
24
+ import { z } from 'zod';
25
+
26
+ import type { DashboardSpec } from './schema.js';
27
+
28
+ const layoutDirection = z.enum(['row', 'column']);
29
+
30
+ const widgetLayoutNode = z.object({
31
+ type: z.literal('widget'),
32
+ ref: z.string().min(1),
33
+ size: z.number().positive().optional(),
34
+ });
35
+
36
+ /**
37
+ * Recursive by `z.lazy` — a split's children are layout nodes, which may be
38
+ * splits.
39
+ */
40
+ const layoutNode: z.ZodType<unknown> = z.lazy(() =>
41
+ z.union([widgetLayoutNode, splitLayoutNode]),
42
+ );
43
+
44
+ const splitLayoutNode = z.object({
45
+ type: z.literal('split'),
46
+ direction: layoutDirection,
47
+ size: z.number().positive().optional(),
48
+ resizable: z.boolean().optional(),
49
+ height: z.string().min(1).optional(),
50
+ children: z.array(layoutNode).min(1),
51
+ });
52
+
53
+ const dataBinding = z.object({
54
+ source: z.string().min(1),
55
+ fields: z.record(z.string(), z.string()),
56
+ });
57
+
58
+ const widgetInteraction = z.object({
59
+ reads: z.array(z.string().min(1)).optional(),
60
+ writes: z.array(z.string().min(1)).optional(),
61
+ agentWritable: z.array(z.string().min(1)).optional(),
62
+ });
63
+
64
+ const thresholdRule = z.object({
65
+ op: z.enum(['gte', 'lte', 'gt', 'lt', 'eq']),
66
+ value: z.number(),
67
+ severity: z.enum(['success', 'warning', 'critical']),
68
+ });
69
+
70
+ const widgetTableColumn = z.object({
71
+ accessorKey: z.string().min(1),
72
+ header: z.string(),
73
+ render: z
74
+ .enum(['text', 'number', 'currency', 'percent', 'badge', 'sparkline'])
75
+ .optional(),
76
+ });
77
+
78
+ const widgetNode = z.object({
79
+ id: z.string().min(1),
80
+ component: z.string().min(1),
81
+ title: z.string().optional(),
82
+ // `props` stays open (it's per-component config) EXCEPT `columns`, which is
83
+ // structured enough — and mistyped often enough — to be worth checking.
84
+ props: z
85
+ .looseObject({ columns: z.array(widgetTableColumn).optional() })
86
+ .optional(),
87
+ dataBinding: dataBinding.optional(),
88
+ interaction: widgetInteraction.optional(),
89
+ thresholds: z.array(thresholdRule).optional(),
90
+ });
91
+
92
+ export const dashboardSpecSchema = z.object({
93
+ version: z.literal(1),
94
+ title: z.string().optional(),
95
+ layout: layoutNode,
96
+ widgets: z.record(z.string(), widgetNode),
97
+ });
98
+
99
+ export type ManifestIssue = {
100
+ /** Dotted path into the spec, e.g. `widgets.exposure.component`. */
101
+ path: string;
102
+ message: string;
103
+ };
104
+
105
+ export type ManifestValidation =
106
+ | { ok: true; spec: DashboardSpec; issues: [] }
107
+ | { ok: false; issues: ManifestIssue[] };
108
+
109
+ type RawLayoutNode = {
110
+ type?: string;
111
+ ref?: string;
112
+ direction?: string;
113
+ resizable?: boolean;
114
+ height?: string;
115
+ children?: RawLayoutNode[];
116
+ };
117
+
118
+ function walkLayout(
119
+ node: RawLayoutNode,
120
+ path: string,
121
+ visit: (node: RawLayoutNode, path: string) => void,
122
+ ): void {
123
+ visit(node, path);
124
+ node.children?.forEach((child, index) =>
125
+ walkLayout(child, `${path}.children[${index}]`, visit),
126
+ );
127
+ }
128
+
129
+ export type ValidateOptions = {
130
+ /**
131
+ * When supplied, every widget's `component` must be one of these keys.
132
+ * Pass `Object.keys(registry)` to catch a typo'd or hallucinated component
133
+ * name before it renders as an `UnknownWidget` placeholder.
134
+ */
135
+ knownComponents?: Iterable<string>;
136
+ };
137
+
138
+ /**
139
+ * Validates a candidate manifest. Never throws — an agent-submitted patch is
140
+ * untrusted input, and the caller needs the reasons, not a stack trace.
141
+ */
142
+ export function validateDashboardSpec(
143
+ candidate: unknown,
144
+ { knownComponents }: ValidateOptions = {},
145
+ ): ManifestValidation {
146
+ const parsed = dashboardSpecSchema.safeParse(candidate);
147
+ if (!parsed.success) {
148
+ return {
149
+ ok: false,
150
+ issues: parsed.error.issues.map((issue) => ({
151
+ path: issue.path.join('.') || '(root)',
152
+ message: issue.message,
153
+ })),
154
+ };
155
+ }
156
+
157
+ const spec = parsed.data as unknown as DashboardSpec;
158
+ const issues: ManifestIssue[] = [];
159
+ const known = knownComponents ? new Set(knownComponents) : null;
160
+
161
+ // ── Referential pass ────────────────────────────────────────────────────
162
+ const referenced = new Set<string>();
163
+ walkLayout(spec.layout as RawLayoutNode, 'layout', (node, path) => {
164
+ if (node.type === 'widget') {
165
+ const ref = node.ref as string;
166
+ referenced.add(ref);
167
+ if (!(ref in spec.widgets)) {
168
+ issues.push({
169
+ path: `${path}.ref`,
170
+ message: `layout references unknown widget "${ref}" — add it to \`widgets\` or fix the ref`,
171
+ });
172
+ }
173
+ return;
174
+ }
175
+ if (node.resizable && node.direction === 'column' && !node.height) {
176
+ issues.push({
177
+ path: `${path}.height`,
178
+ message:
179
+ 'a resizable `column` split needs an explicit `height` — a vertical splitter has no content-driven size to divide',
180
+ });
181
+ }
182
+ });
183
+
184
+ for (const [key, widget] of Object.entries(spec.widgets)) {
185
+ if (widget.id !== key) {
186
+ issues.push({
187
+ path: `widgets.${key}.id`,
188
+ message: `widget id "${widget.id}" does not match its registry key "${key}"`,
189
+ });
190
+ }
191
+ if (!referenced.has(key)) {
192
+ issues.push({
193
+ path: `widgets.${key}`,
194
+ message: `widget "${key}" is never placed by \`layout\` — it will not render`,
195
+ });
196
+ }
197
+ if (known && !known.has(widget.component)) {
198
+ issues.push({
199
+ path: `widgets.${key}.component`,
200
+ message: `unknown component "${widget.component}"`,
201
+ });
202
+ }
203
+ // An agent-writable key that the widget cannot actually write is a policy
204
+ // that silently does nothing — worth catching at the gate. `writes` is the
205
+ // set of keys a widget produces; an `agentWritable` key must be one of them.
206
+ const writes = widget.interaction?.writes ?? [];
207
+ for (const exposed of widget.interaction?.agentWritable ?? []) {
208
+ if (!writes.includes(exposed)) {
209
+ issues.push({
210
+ path: `widgets.${key}.interaction.agentWritable`,
211
+ message: `"${exposed}" is marked agent-writable but is not in this widget's \`writes\``,
212
+ });
213
+ }
214
+ }
215
+ }
216
+
217
+ return issues.length > 0
218
+ ? { ok: false, issues }
219
+ : { ok: true, spec, issues: [] };
220
+ }
221
+
222
+ /**
223
+ * The set of interaction keys an agent is allowed to write, collected across
224
+ * every widget in the manifest. Default-deny: an empty set means an agent may
225
+ * read the dashboard but drive nothing.
226
+ */
227
+ export function collectAgentWritableKeys(spec: DashboardSpec): Set<string> {
228
+ const keys = new Set<string>();
229
+ for (const widget of Object.values(spec.widgets)) {
230
+ for (const key of widget.interaction?.agentWritable ?? []) keys.add(key);
231
+ }
232
+ return keys;
233
+ }