@standardagents/code-plugin-sdk 1.0.0-alpha.1 → 1.0.0-alpha.10-rows.0
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/README.md +97 -6
- package/REFERENCE.md +536 -0
- package/package.json +2 -2
- package/src/index.d.ts +204 -16
- package/src/index.mjs +3 -2
- package/src/internal.d.ts +2 -1
- package/src/manifest.mjs +22 -3
- package/src/protocol.mjs +8 -4
- package/src/row-layout.mjs +29 -0
- package/src/runtime.mjs +81 -6
- package/src/testing.d.ts +3 -1
- package/src/testing.mjs +17 -6
- package/src/view.mjs +257 -0
package/src/testing.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRuntime } from './runtime.mjs'
|
|
2
2
|
import { RpcPeer } from './protocol.mjs'
|
|
3
|
-
import { PluginError, ensure, LIMITS, validateManifest } from './manifest.mjs'
|
|
3
|
+
import { PluginError, ensure, jsonBytes, LIMITS, validateManifest } from './manifest.mjs'
|
|
4
4
|
|
|
5
5
|
/** Explicit time and fixture-owned responses; this harness starts no threads or subprocesses. */
|
|
6
6
|
export function createHarness({ manifest: input, machineId = 'test-machine', epoch = '1', handlers = {}, now = 0 } = {}) {
|
|
@@ -9,7 +9,7 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
|
|
|
9
9
|
const trace = []
|
|
10
10
|
const surfaces = new Map()
|
|
11
11
|
const subscriptions = new Map()
|
|
12
|
-
const
|
|
12
|
+
const accountState = new Map()
|
|
13
13
|
const timers = new Map()
|
|
14
14
|
let timerId = 0
|
|
15
15
|
let disposed = false
|
|
@@ -36,12 +36,16 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
|
|
|
36
36
|
switch (operation.op) {
|
|
37
37
|
case 'subscription.add': subscriptions.set(args.id, structuredClone(args)); return null
|
|
38
38
|
case 'subscription.remove': subscriptions.delete(args.id); return null
|
|
39
|
-
case 'state.get': return structuredClone(
|
|
39
|
+
case 'state.get': return structuredClone(accountState.get(args.key) ?? null)
|
|
40
40
|
case 'state.set':
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// The product stores these values in the account: 256 keys per
|
|
42
|
+
// plugin, 64 KiB per value.
|
|
43
|
+
ensure(accountState.has(args.key) || accountState.size < LIMITS.stateKeys, 'queue_full', 'Harness state holds 256 keys for this plugin')
|
|
44
|
+
jsonBytes(args.value, LIMITS.stateValueBytes)
|
|
45
|
+
accountState.set(args.key, structuredClone(args.value)); return null
|
|
46
|
+
case 'state.keys': return [...accountState.keys()].sort()
|
|
43
47
|
case 'config.get': return {}
|
|
44
|
-
case 'context.get': return { machineId }
|
|
48
|
+
case 'context.get': return { accountId: 'test-account', machineId, build: null, fleet: null, projects: [], panes: [], complete: true }
|
|
45
49
|
case 'health.set': return null
|
|
46
50
|
default: throw new PluginError('missing_fixture', `Provide a harness handler for ${operation.op}`)
|
|
47
51
|
}
|
|
@@ -88,6 +92,13 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
|
|
|
88
92
|
},
|
|
89
93
|
visibility: conditions => host.request({ op: 'runtime.visibility', args: { conditions } }),
|
|
90
94
|
receive: frame => runtime.receive(frame),
|
|
95
|
+
/** The current content of a published contribution, or undefined when it is clear. */
|
|
96
|
+
surface(id, entity) {
|
|
97
|
+
const declaration = manifest.contributions.find(item => item.id === id)
|
|
98
|
+
if (!declaration) return undefined
|
|
99
|
+
const key = { contributionId: id, anchor: declaration.anchor, ...(entity ? { entity } : {}) }
|
|
100
|
+
return structuredClone(surfaces.get(JSON.stringify(key))?.content)
|
|
101
|
+
},
|
|
91
102
|
drainTrace() { return trace.splice(0) },
|
|
92
103
|
get resources() { return { timers: timers.size, subscriptions: subscriptions.size, surfaces: surfaces.size, disposed } },
|
|
93
104
|
async dispose() {
|
package/src/view.mjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { LIMITS, ensure, identifier, jsonBytes, object } from './manifest.mjs'
|
|
2
|
+
|
|
3
|
+
// Mirrors crates/standard-protocol/src/plugin_view.rs. The host applies the
|
|
4
|
+
// same bounds; checking here reports a mistake at the replace() call.
|
|
5
|
+
// items and logLines bound the list-shaped nodes: the host lays every entry
|
|
6
|
+
// out again on each paint, so a long list costs a frame, not just memory.
|
|
7
|
+
export const VIEW_LIMITS = Object.freeze({ depth: 8, nodes: 4096, tableRows: 512, tableColumns: 12,
|
|
8
|
+
items: 512, logLines: 2048, cardLines: 6, actionValueBytes: 512 })
|
|
9
|
+
export const TONES = Object.freeze(['ok', 'info', 'warn', 'error', 'muted', 'accent', 'pending', 'bright'])
|
|
10
|
+
const WEIGHTS = ['normal', 'bold', 'dim']
|
|
11
|
+
const ALIGNS = ['start', 'center', 'end']
|
|
12
|
+
// C0, DEL, C1, and the bidirectional formatting characters that can reorder terminal output.
|
|
13
|
+
const FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
|
|
14
|
+
|
|
15
|
+
const invalid = message => ensure(false, 'invalid_payload', message)
|
|
16
|
+
function text(value, field) {
|
|
17
|
+
if (typeof value !== 'string') invalid(`Plugin view ${field} must be a string`)
|
|
18
|
+
ensure(!FORBIDDEN.test(value), 'invalid_payload', 'Plugin view text contains a control or bidirectional character')
|
|
19
|
+
}
|
|
20
|
+
function optionalText(value, field) { if (value !== undefined) text(value, field) }
|
|
21
|
+
function choice(value, choices, field) {
|
|
22
|
+
ensure(value === undefined || choices.includes(value), 'invalid_payload', `Invalid plugin view ${field}`)
|
|
23
|
+
}
|
|
24
|
+
function tone(value) { choice(value, TONES, 'tone') }
|
|
25
|
+
function integer(value, max, field) {
|
|
26
|
+
ensure(value === undefined || (Number.isInteger(value) && value >= 0 && value <= max), 'invalid_payload', `Invalid plugin view ${field}`)
|
|
27
|
+
}
|
|
28
|
+
function flag(value, field) { ensure(value === undefined || typeof value === 'boolean', 'invalid_payload', `Invalid plugin view ${field}`) }
|
|
29
|
+
function number(value) {
|
|
30
|
+
ensure(typeof value === 'number' && Number.isFinite(value), 'invalid_payload', 'Plugin view number is not finite')
|
|
31
|
+
}
|
|
32
|
+
function list(value, field, optional = false) {
|
|
33
|
+
if (optional && value === undefined) return []
|
|
34
|
+
ensure(Array.isArray(value), 'invalid_payload', `Plugin view ${field} must be an array`)
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
function entries(value, field, optional, check) {
|
|
38
|
+
for (const item of list(value, field, optional)) {
|
|
39
|
+
ensure(object(item), 'invalid_payload', `Plugin view ${field} must contain objects`)
|
|
40
|
+
check(item)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function bounded(value, field, limit, optional = false) {
|
|
44
|
+
const values = list(value, field, optional)
|
|
45
|
+
ensure(values.length <= limit, 'invalid_payload', `Plugin view ${field} has more than ${limit} entries`)
|
|
46
|
+
return values
|
|
47
|
+
}
|
|
48
|
+
// A list-shaped node's entries, held to the bound the host applies.
|
|
49
|
+
function boundedEntries(value, field, check) {
|
|
50
|
+
entries(bounded(value, field, VIEW_LIMITS.items), field, false, check)
|
|
51
|
+
}
|
|
52
|
+
function spans(value, optional) {
|
|
53
|
+
entries(value, 'spans', optional, span => {
|
|
54
|
+
text(span.text, 'span text')
|
|
55
|
+
tone(span.tone)
|
|
56
|
+
choice(span.weight, WEIGHTS, 'weight')
|
|
57
|
+
flag(span.mono, 'mono')
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
function action(value, optional = true) {
|
|
61
|
+
if (optional && value === undefined) return
|
|
62
|
+
ensure(object(value), 'invalid_payload', 'Plugin view action must be an object')
|
|
63
|
+
text(value.actionId, 'action id')
|
|
64
|
+
optionalText(value.value, 'action value')
|
|
65
|
+
optionalText(value.opens, 'action opens')
|
|
66
|
+
}
|
|
67
|
+
function meter(item) {
|
|
68
|
+
number(item.value)
|
|
69
|
+
number(item.max)
|
|
70
|
+
tone(item.tone)
|
|
71
|
+
optionalText(item.label, 'label')
|
|
72
|
+
}
|
|
73
|
+
function children(node, depth, count) {
|
|
74
|
+
for (const child of list(node.children, 'children', true)) walk(child, depth + 1, count)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NODES = {
|
|
78
|
+
stack(node, depth, count) { integer(node.gap, 255, 'gap'); children(node, depth, count) },
|
|
79
|
+
row(node, depth, count) { choice(node.align, ALIGNS, 'align'); children(node, depth, count) },
|
|
80
|
+
divider(node) { optionalText(node.label, 'label') },
|
|
81
|
+
text(node) {
|
|
82
|
+
optionalText(node.text, 'text')
|
|
83
|
+
spans(node.spans, true)
|
|
84
|
+
tone(node.tone)
|
|
85
|
+
choice(node.weight, WEIGHTS, 'weight')
|
|
86
|
+
flag(node.mono, 'mono')
|
|
87
|
+
},
|
|
88
|
+
badge(node) { text(node.label, 'label'); tone(node.tone) },
|
|
89
|
+
dot(node) { tone(node.tone) },
|
|
90
|
+
progress: meter,
|
|
91
|
+
segments(node) { boundedEntries(node.items, 'items', meter) },
|
|
92
|
+
card(node, depth, count) { optionalText(node.title, 'title'); tone(node.tone); children(node, depth, count) },
|
|
93
|
+
stat(node) {
|
|
94
|
+
text(node.label, 'label')
|
|
95
|
+
text(node.value, 'value')
|
|
96
|
+
tone(node.tone)
|
|
97
|
+
optionalText(node.hint, 'hint')
|
|
98
|
+
},
|
|
99
|
+
kv(node) {
|
|
100
|
+
boundedEntries(node.items, 'items', item => {
|
|
101
|
+
text(item.label, 'label')
|
|
102
|
+
text(item.value, 'value')
|
|
103
|
+
flag(item.mono, 'mono')
|
|
104
|
+
flag(item.copy, 'copy')
|
|
105
|
+
})
|
|
106
|
+
},
|
|
107
|
+
tabs(node) {
|
|
108
|
+
text(node.id, 'id')
|
|
109
|
+
optionalText(node.filters, 'filters')
|
|
110
|
+
action(node.action)
|
|
111
|
+
boundedEntries(node.items, 'items', item => {
|
|
112
|
+
text(item.id, 'id')
|
|
113
|
+
text(item.label, 'label')
|
|
114
|
+
optionalText(item.sublabel, 'sublabel')
|
|
115
|
+
tone(item.tone)
|
|
116
|
+
integer(item.count, 0xffffffff, 'count')
|
|
117
|
+
optionalText(item.tag, 'tag')
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
select(node) {
|
|
121
|
+
text(node.id, 'id')
|
|
122
|
+
text(node.label, 'label')
|
|
123
|
+
optionalText(node.filters, 'filters')
|
|
124
|
+
action(node.action)
|
|
125
|
+
boundedEntries(node.options, 'options', option => {
|
|
126
|
+
text(option.id, 'id')
|
|
127
|
+
text(option.label, 'label')
|
|
128
|
+
optionalText(option.group, 'group')
|
|
129
|
+
integer(option.count, 0xffffffff, 'count')
|
|
130
|
+
optionalText(option.tag, 'tag')
|
|
131
|
+
})
|
|
132
|
+
},
|
|
133
|
+
table(node, depth, count) {
|
|
134
|
+
const columns = list(node.columns, 'columns')
|
|
135
|
+
const rows = list(node.rows, 'rows', true)
|
|
136
|
+
ensure(columns.length <= VIEW_LIMITS.tableColumns, 'invalid_payload', 'Plugin table has more than 12 columns')
|
|
137
|
+
ensure(rows.length <= VIEW_LIMITS.tableRows, 'invalid_payload', 'Plugin table has more than 512 rows')
|
|
138
|
+
text(node.id, 'id')
|
|
139
|
+
entries(columns, 'columns', false, column => {
|
|
140
|
+
text(column.id, 'column id')
|
|
141
|
+
optionalText(column.label, 'column label')
|
|
142
|
+
if (column.width !== 'fill') integer(column.width, 0xffff, 'column width')
|
|
143
|
+
integer(column.maxWidth, 0xffff, 'column maxWidth')
|
|
144
|
+
choice(column.align, ALIGNS, 'align')
|
|
145
|
+
integer(column.priority, 255, 'column priority')
|
|
146
|
+
})
|
|
147
|
+
entries(rows, 'rows', false, row => {
|
|
148
|
+
text(row.id, 'row id')
|
|
149
|
+
tone(row.tone)
|
|
150
|
+
spans(row.note, true)
|
|
151
|
+
for (const tag of bounded(row.tags, 'tags', VIEW_LIMITS.items, true)) text(tag, 'tag')
|
|
152
|
+
action(row.action)
|
|
153
|
+
ensure(row.cells === undefined || object(row.cells), 'invalid_payload', 'Plugin table cells must be an object')
|
|
154
|
+
for (const [column, cell] of Object.entries(row.cells ?? {})) {
|
|
155
|
+
text(column, 'cell column')
|
|
156
|
+
walk(cell, depth + 1, count)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
},
|
|
160
|
+
log(node) { for (const line of bounded(node.lines, 'lines', VIEW_LIMITS.logLines)) text(line, 'log line') },
|
|
161
|
+
button(node) { text(node.label, 'label'); action(node.action, false) },
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function walk(node, depth, count) {
|
|
165
|
+
ensure(depth <= VIEW_LIMITS.depth, 'invalid_payload', 'Plugin view nests deeper than 8 nodes')
|
|
166
|
+
ensure(++count.nodes <= VIEW_LIMITS.nodes, 'invalid_payload', 'Plugin view has more than 4096 nodes')
|
|
167
|
+
ensure(object(node) && typeof node.type === 'string', 'invalid_payload', 'Plugin view node requires a type')
|
|
168
|
+
// A node type this SDK does not know draws nothing on hosts that do not know it either.
|
|
169
|
+
if (Object.hasOwn(NODES, node.type)) NODES[node.type](node, depth, count)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The daemon checks below mirror crates/standardd/src/plugin_view_content.rs.
|
|
173
|
+
|
|
174
|
+
/** Visits the actions a viewer can activate, in the nodes the daemon searches. */
|
|
175
|
+
function visitActions(node, visit) {
|
|
176
|
+
switch (node.type) {
|
|
177
|
+
case 'stack': case 'row': case 'card':
|
|
178
|
+
for (const child of node.children ?? []) visitActions(child, visit)
|
|
179
|
+
break
|
|
180
|
+
case 'button': visit(node.action); break
|
|
181
|
+
case 'tabs': case 'select': if (node.action !== undefined) visit(node.action); break
|
|
182
|
+
case 'table':
|
|
183
|
+
for (const row of node.rows ?? []) {
|
|
184
|
+
if (row.action !== undefined) visit(row.action)
|
|
185
|
+
for (const cell of Object.values(row.cells ?? {})) visitActions(cell, visit)
|
|
186
|
+
}
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const ONE_LINE = ['text', 'badge', 'dot', 'progress', 'segments', 'stat', 'divider']
|
|
192
|
+
const PANEL_ONLY = ['card', 'kv', 'tabs', 'select', 'table', 'log', 'button']
|
|
193
|
+
/** Cards hold one-line summaries arranged by stacks and rows; unknown nodes draw nothing. */
|
|
194
|
+
function cardLines(node) {
|
|
195
|
+
if (node.type === 'stack') {
|
|
196
|
+
const children = node.children ?? []
|
|
197
|
+
return children.reduce((total, child) => total + cardLines(child), (node.gap ?? 0) * Math.max(children.length - 1, 0))
|
|
198
|
+
}
|
|
199
|
+
if (node.type === 'row') return (node.children ?? []).reduce((tallest, child) => Math.max(tallest, cardLines(child)), 0)
|
|
200
|
+
if (ONE_LINE.includes(node.type)) return 1
|
|
201
|
+
ensure(!PANEL_ONLY.includes(node.type), 'invalid_payload', `A card view cannot contain a ${node.type} node`)
|
|
202
|
+
return 0
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Throws a PluginError when a view tree breaks a protocol bound or a rule the
|
|
207
|
+
* serving daemon applies. `kind: 'card'` adds the card rules, and `manifest`
|
|
208
|
+
* requires every action `opens` to name one of its panels.
|
|
209
|
+
*/
|
|
210
|
+
export function validateView(root, { kind, manifest } = {}) {
|
|
211
|
+
walk(root, 1, { nodes: 0 })
|
|
212
|
+
jsonBytes(root, LIMITS.frameBytes)
|
|
213
|
+
visitActions(root, action => {
|
|
214
|
+
ensure(identifier(action.actionId), 'invalid_payload', 'Plugin view action id must be a plugin identifier')
|
|
215
|
+
ensure(action.value === undefined || Buffer.byteLength(action.value) <= VIEW_LIMITS.actionValueBytes,
|
|
216
|
+
'payload_too_large', 'Plugin view action value exceeds 512 bytes')
|
|
217
|
+
ensure(action.opens === undefined || identifier(action.opens), 'invalid_payload', 'Plugin view action opens must be a plugin identifier')
|
|
218
|
+
ensure(action.opens === undefined || !manifest ||
|
|
219
|
+
manifest.contributions.some(item => item.id === action.opens && item.kind === 'panel'),
|
|
220
|
+
'invalid_payload', 'Plugin view action opens must name a declared panel')
|
|
221
|
+
})
|
|
222
|
+
if (kind === 'card') {
|
|
223
|
+
ensure(cardLines(root) <= VIEW_LIMITS.cardLines, 'payload_too_large', 'A card view is taller than 6 lines')
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function compact(value) {
|
|
228
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined))
|
|
229
|
+
}
|
|
230
|
+
const node = (type, fields) => compact({ type, ...fields })
|
|
231
|
+
|
|
232
|
+
/** Optional builders. Each returns the plain protocol JSON for one node. */
|
|
233
|
+
export const ui = Object.freeze({
|
|
234
|
+
view: (root, { title } = {}) => compact({ kind: 'view', root, title }),
|
|
235
|
+
action: (actionId, { value, opens } = {}) => compact({ actionId, value, opens }),
|
|
236
|
+
span: (value, { tone, weight, mono } = {}) => compact({ text: value, tone, weight, mono }),
|
|
237
|
+
stack: (items, { gap } = {}) => node('stack', { gap, children: items }),
|
|
238
|
+
row: (items, { align } = {}) => node('row', { children: items, align }),
|
|
239
|
+
divider: label => node('divider', { label }),
|
|
240
|
+
/** A string sets `text`; an array of spans sets `spans`. */
|
|
241
|
+
text: (content, { tone, weight, mono } = {}) =>
|
|
242
|
+
node('text', { ...(Array.isArray(content) ? { spans: content.map(compact) } : { text: content }), tone, weight, mono }),
|
|
243
|
+
badge: (label, tone) => node('badge', { label, tone }),
|
|
244
|
+
dot: tone => node('dot', { tone }),
|
|
245
|
+
progress: (value, max, { tone, label } = {}) => node('progress', { value, max, tone, label }),
|
|
246
|
+
segments: items => node('segments', { items: items.map(compact) }),
|
|
247
|
+
card: (items, { title, tone } = {}) => node('card', { title, tone, children: items }),
|
|
248
|
+
stat: (label, value, { tone, hint } = {}) => node('stat', { label, value, tone, hint }),
|
|
249
|
+
kv: items => node('kv', { items: items.map(compact) }),
|
|
250
|
+
tabs: (id, items, { filters, action } = {}) => node('tabs', { id, items: items.map(compact), filters, action }),
|
|
251
|
+
select: (id, label, options, { filters, action } = {}) =>
|
|
252
|
+
node('select', { id, label, options: options.map(compact), filters, action }),
|
|
253
|
+
table: (id, columns, rows = []) => node('table', { id, columns: columns.map(compact), rows: rows.map(compact) }),
|
|
254
|
+
log: lines => node('log', { lines }),
|
|
255
|
+
/** `action` is an action object or an action id. */
|
|
256
|
+
button: (label, action) => node('button', { label, action: typeof action === 'string' ? { actionId: action } : action }),
|
|
257
|
+
})
|