@standardagents/code-plugin-sdk 1.0.0-alpha.1 → 1.0.0-alpha.11-headers.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/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 localState = new Map()
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(localState.get(args.key) ?? null)
39
+ case 'state.get': return structuredClone(accountState.get(args.key) ?? null)
40
40
  case 'state.set':
41
- ensure(localState.has(args.key) || localState.size < LIMITS.contributions, 'queue_full', 'Harness state is full')
42
- localState.set(args.key, structuredClone(args.value)); return null
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
+ })