@standardagents/code-plugin-sdk 0.0.0-stub.0 → 1.0.0-alpha.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/README.md +174 -3
- package/bin/standard-plugin.mjs +149 -0
- package/package.json +17 -4
- package/src/collection.mjs +0 -0
- package/src/index.d.ts +310 -0
- package/src/index.mjs +13 -0
- package/src/internal.d.ts +39 -0
- package/src/internal.mjs +5 -0
- package/src/manifest.mjs +86 -0
- package/src/protocol.mjs +166 -0
- package/src/publish.mjs +62 -0
- package/src/runtime.mjs +354 -0
- package/src/testing.d.ts +24 -0
- package/src/testing.mjs +104 -0
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { LIMITS, PluginError, ensure, jsonBytes, validateManifest, ANCHORS, identifier, object } from './manifest.mjs'
|
|
2
|
+
import { RpcPeer, authorize, realClock } from './protocol.mjs'
|
|
3
|
+
|
|
4
|
+
const always = Object.freeze({ kind: 'always' })
|
|
5
|
+
const menuPositions = ['top', 'after-open', 'before-danger', 'bottom']
|
|
6
|
+
const entityKinds = ['account', 'machine', 'project', 'pane', 'section']
|
|
7
|
+
function boundedText(value, label, limit = 512) {
|
|
8
|
+
ensure(typeof value === 'string' && value.trim().length > 0 && value.length <= limit &&
|
|
9
|
+
!/[\u0000-\u001f\u007f]/.test(value), 'invalid_payload', `Invalid ${label}`)
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
function validateEntity(entity) {
|
|
13
|
+
ensure(object(entity) && entityKinds.includes(entity.kind), 'invalid_payload', 'Invalid registration entity')
|
|
14
|
+
boundedText(entity.id, 'entity id', 128)
|
|
15
|
+
if (entity.machineId !== undefined) boundedText(entity.machineId, 'machine id', 128)
|
|
16
|
+
if (entity.generation !== undefined) {
|
|
17
|
+
ensure(typeof entity.generation === 'string' && /^(0|[1-9]\d{0,19})$/.test(entity.generation) &&
|
|
18
|
+
BigInt(entity.generation) <= 18446744073709551615n, 'invalid_payload', 'Invalid entity generation')
|
|
19
|
+
}
|
|
20
|
+
return { kind: entity.kind, id: entity.id,
|
|
21
|
+
...(entity.machineId !== undefined ? { machineId: entity.machineId } : {}),
|
|
22
|
+
...(entity.generation !== undefined ? { generation: entity.generation } : {}) }
|
|
23
|
+
}
|
|
24
|
+
function validateChord(chord) {
|
|
25
|
+
boundedText(chord, 'key chord', 128)
|
|
26
|
+
const presses = chord.split(' ')
|
|
27
|
+
const namedKeys = ['space', 'enter', 'esc', 'tab', 'backspace', 'left', 'right', 'up', 'down', 'pageup', 'pagedown', 'home', 'end']
|
|
28
|
+
ensure(presses.length <= 2 && presses.every(press => {
|
|
29
|
+
const parts = press.split('+')
|
|
30
|
+
const modifiers = parts.filter(part => ['shift', 'alt', 'ctrl'].includes(part))
|
|
31
|
+
const keys = parts.filter(part => !['shift', 'alt', 'ctrl'].includes(part))
|
|
32
|
+
return new Set(modifiers).size === modifiers.length && keys.length === 1 &&
|
|
33
|
+
(namedKeys.includes(keys[0]) || Array.from(keys[0]).length === 1)
|
|
34
|
+
}), 'invalid_payload', 'Invalid native key chord')
|
|
35
|
+
return chord
|
|
36
|
+
}
|
|
37
|
+
function conditionKey(condition) { return JSON.stringify([condition.kind, condition.contributionId ?? null, condition.entity ?? null]) }
|
|
38
|
+
function validateCondition(condition) {
|
|
39
|
+
ensure(condition && ['always', 'section-visible', 'slot-visible', 'panel-open'].includes(condition.kind) &&
|
|
40
|
+
(condition.kind === 'always' || identifier(condition.contributionId)), 'invalid_payload', 'Invalid schedule condition')
|
|
41
|
+
return structuredClone(condition)
|
|
42
|
+
}
|
|
43
|
+
function validateContent(content) {
|
|
44
|
+
jsonBytes(content)
|
|
45
|
+
ensure(content && ['rows', 'text', 'badge', 'canvas'].includes(content.kind), 'invalid_payload', 'Invalid surface content')
|
|
46
|
+
if (content.kind === 'canvas') {
|
|
47
|
+
const { columns, rows, shade = 0 } = content.canvas ?? {}
|
|
48
|
+
ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
|
|
49
|
+
Number.isSafeInteger(rows) && rows > 0 && rows <= LIMITS.canvasRows &&
|
|
50
|
+
Number.isFinite(shade) && shade >= 0 && shade <= 1, 'invalid_payload', 'Invalid canvas dimensions or shade')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createRuntime({ manifest: input, producer, send, clock = realClock, onError = () => {}, instanceId = '0' }) {
|
|
55
|
+
const manifest = validateManifest(input)
|
|
56
|
+
ensure(producer.pluginId === manifest.id, 'identity_mismatch', 'Manifest and producer ids differ')
|
|
57
|
+
const lifetime = new AbortController()
|
|
58
|
+
const subscriptions = new Map()
|
|
59
|
+
const schedules = new Set()
|
|
60
|
+
const cleanups = new Set()
|
|
61
|
+
const publishers = new Map()
|
|
62
|
+
let visibility = new Set()
|
|
63
|
+
let counter = 0
|
|
64
|
+
let sequence = 0n
|
|
65
|
+
let disposal
|
|
66
|
+
ensure(identifier(instanceId), 'invalid_payload', 'Invalid runtime instance id')
|
|
67
|
+
const peer = new RpcPeer({ producer, send, clock, onError, idPrefix: 'sdk', onRequest: async (operation, options) => {
|
|
68
|
+
if (operation.op === 'runtime.visibility') {
|
|
69
|
+
ensure(Array.isArray(operation.args.conditions) && operation.args.conditions.length <= LIMITS.contributions,
|
|
70
|
+
'invalid_payload', 'Invalid visibility snapshot')
|
|
71
|
+
visibility = new Set(operation.args.conditions.map(condition => conditionKey(validateCondition(condition))))
|
|
72
|
+
for (const schedule of schedules) schedule.visibilityChanged(visibility)
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
ensure(operation.op === 'runtime.invoke', 'unsupported_operation', 'Unsupported worker operation')
|
|
76
|
+
const subscription = subscriptions.get(operation.args.subscriptionId)
|
|
77
|
+
ensure(subscription, 'subscription_missing', 'Subscription was disposed')
|
|
78
|
+
if (subscription.condition.kind !== 'always' && !visibility.has(conditionKey(subscription.condition))) return null
|
|
79
|
+
const result = await subscription.handler(operation.args.event, { signal: options.signal })
|
|
80
|
+
if (subscription.kind === 'hook') {
|
|
81
|
+
ensure(result && ['proceed', 'cancel'].includes(result.decision), 'invalid_payload', 'Hook must return proceed or cancel')
|
|
82
|
+
}
|
|
83
|
+
return result ?? null
|
|
84
|
+
} })
|
|
85
|
+
function active() { ensure(!lifetime.signal.aborted, 'disposed', 'Plugin runtime is disposed') }
|
|
86
|
+
function request(op, args, options) {
|
|
87
|
+
try { active(); authorize({ op, args }, manifest.capabilities) }
|
|
88
|
+
catch (error) { onError(error); return Promise.reject(error) }
|
|
89
|
+
return peer.request({ op, args }, options)
|
|
90
|
+
}
|
|
91
|
+
function publish(kind, id, entity) {
|
|
92
|
+
active()
|
|
93
|
+
ensure(manifest.capabilities.includes('surfaces'), 'capability_denied', 'Publishing requires surfaces capability')
|
|
94
|
+
const declaration = manifest.contributions.find(item => item.id === id)
|
|
95
|
+
ensure(declaration && (!kind || declaration.kind === kind) && ANCHORS.includes(declaration.anchor),
|
|
96
|
+
'contribution_missing', 'Surface must match its static contribution declaration')
|
|
97
|
+
const key = { contributionId: id, anchor: declaration.anchor, ...(entity ? { entity: structuredClone(entity) } : {}) }
|
|
98
|
+
const identity = JSON.stringify(key)
|
|
99
|
+
ensure(!publishers.has(identity), 'duplicate_contribution', 'Contribution publisher exists')
|
|
100
|
+
ensure(publishers.size < LIMITS.contributions, 'queue_full', 'Too many contribution publishers')
|
|
101
|
+
let disposed = false
|
|
102
|
+
const replace = content => {
|
|
103
|
+
active()
|
|
104
|
+
ensure(!disposed, 'disposed', 'Contribution publisher is disposed')
|
|
105
|
+
if (content !== null) validateContent(content)
|
|
106
|
+
peer.transmit(peer.frame('surface', { key, sequence: String(++sequence), content }))
|
|
107
|
+
}
|
|
108
|
+
const publisher = { replace, clear: () => replace(null), dispose() {
|
|
109
|
+
if (disposed) return
|
|
110
|
+
try { if (!lifetime.signal.aborted) replace(null) }
|
|
111
|
+
finally { disposed = true; publishers.delete(identity) }
|
|
112
|
+
} }
|
|
113
|
+
publishers.set(identity, publisher)
|
|
114
|
+
return { publisher, key }
|
|
115
|
+
}
|
|
116
|
+
function subscribe(kind, name, handler, condition = always, registration) {
|
|
117
|
+
active()
|
|
118
|
+
ensure(typeof handler === 'function', 'invalid_payload', 'Subscription requires a handler')
|
|
119
|
+
ensure(subscriptions.size < LIMITS.subscriptions, 'queue_full', 'Too many subscriptions')
|
|
120
|
+
condition = validateCondition(condition)
|
|
121
|
+
const id = `subscription-${instanceId}-${++counter}`
|
|
122
|
+
const args = { id, kind, name, condition, ...(registration ? { registration } : {}) }
|
|
123
|
+
authorize({ op: 'subscription.add', args }, manifest.capabilities)
|
|
124
|
+
const entry = { kind, handler, condition, registration }
|
|
125
|
+
subscriptions.set(id, entry)
|
|
126
|
+
let disposed = false
|
|
127
|
+
let updating = false
|
|
128
|
+
const remove = () => { if (!lifetime.signal.aborted) request('subscription.remove', { id }).catch(onError) }
|
|
129
|
+
const ready = request('subscription.add', args)
|
|
130
|
+
ready.then(() => { if (disposed) remove() }, error => {
|
|
131
|
+
disposed = true
|
|
132
|
+
subscriptions.delete(id)
|
|
133
|
+
remove()
|
|
134
|
+
onError(error)
|
|
135
|
+
})
|
|
136
|
+
const subscription = { ready, dispose() {
|
|
137
|
+
if (disposed) return
|
|
138
|
+
disposed = true
|
|
139
|
+
subscriptions.delete(id)
|
|
140
|
+
remove()
|
|
141
|
+
} }
|
|
142
|
+
if (registration?.kind === 'key') subscription.update = async (chord, options) => {
|
|
143
|
+
active()
|
|
144
|
+
ensure(!disposed, 'disposed', 'Key registration is disposed')
|
|
145
|
+
validateChord(chord)
|
|
146
|
+
ensure(!updating, 'request_in_flight', 'A key registration update is pending')
|
|
147
|
+
updating = true
|
|
148
|
+
try {
|
|
149
|
+
await ready
|
|
150
|
+
active()
|
|
151
|
+
ensure(!disposed, 'disposed', 'Key registration is disposed')
|
|
152
|
+
const next = { ...entry.registration, chord }
|
|
153
|
+
await request('subscription.add', { ...args, registration: next }, options)
|
|
154
|
+
if (!disposed) entry.registration = next
|
|
155
|
+
return null
|
|
156
|
+
} finally {
|
|
157
|
+
updating = false
|
|
158
|
+
if (disposed) remove()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return subscription
|
|
162
|
+
}
|
|
163
|
+
function prepareRegistration(kind, id, options, handler) {
|
|
164
|
+
active()
|
|
165
|
+
if (typeof options === 'function' && handler === undefined) { handler = options; options = {} }
|
|
166
|
+
ensure(identifier(id) && object(options) && typeof handler === 'function', 'invalid_payload', 'Invalid contribution registration')
|
|
167
|
+
jsonBytes(options, LIMITS.manifestBytes)
|
|
168
|
+
const declaration = manifest.contributions.find(item => item.id === id && item.kind === kind)
|
|
169
|
+
ensure(declaration, 'contribution_missing', 'Registration must match its static contribution declaration')
|
|
170
|
+
const actionId = options.actionId ?? declaration.actionId ?? id
|
|
171
|
+
ensure(identifier(actionId), 'invalid_payload', 'Invalid registration action id')
|
|
172
|
+
const registration = { contributionId: id, kind, anchor: declaration.anchor, actionId,
|
|
173
|
+
title: boundedText(options.title ?? declaration.title ?? id, 'registration title'),
|
|
174
|
+
...(options.entity !== undefined ? { entity: validateEntity(options.entity) } : {}) }
|
|
175
|
+
if (kind === 'menu') {
|
|
176
|
+
registration.position = options.position ?? declaration.position ?? 'bottom'
|
|
177
|
+
ensure(menuPositions.includes(registration.position), 'invalid_payload', 'Invalid menu position')
|
|
178
|
+
} else if (kind === 'command') {
|
|
179
|
+
const group = options.group ?? declaration.group
|
|
180
|
+
if (group !== undefined) registration.group = boundedText(group, 'command group')
|
|
181
|
+
} else if (kind === 'key') {
|
|
182
|
+
registration.chord = validateChord(options.chord ?? declaration.chord)
|
|
183
|
+
} else if (kind === 'link') {
|
|
184
|
+
registration.pattern = boundedText(options.pattern ?? declaration.pattern, 'link pattern')
|
|
185
|
+
}
|
|
186
|
+
const condition = validateCondition(options.condition ?? always)
|
|
187
|
+
if (condition.entity !== undefined) condition.entity = validateEntity(condition.entity)
|
|
188
|
+
jsonBytes({ registration, condition }, LIMITS.manifestBytes)
|
|
189
|
+
const invoke = async (event, context) => {
|
|
190
|
+
ensure(object(event) && event.actionId === actionId, 'invalid_payload', 'Invalid registered action selection')
|
|
191
|
+
if (event.entity !== undefined) validateEntity(event.entity)
|
|
192
|
+
if (kind === 'link') boundedText(event.url, 'link URL', 8192)
|
|
193
|
+
const result = await handler(event, context)
|
|
194
|
+
if (kind === 'link') ensure(typeof result === 'boolean', 'invalid_payload', 'Link handlers must return a boolean')
|
|
195
|
+
return result
|
|
196
|
+
}
|
|
197
|
+
return { registration, handler: invoke, condition }
|
|
198
|
+
}
|
|
199
|
+
const registrationKey = registration => JSON.stringify([registration.contributionId, registration.entity ?? null])
|
|
200
|
+
function registerPrepared({ registration, handler, condition }) {
|
|
201
|
+
const identity = registrationKey(registration)
|
|
202
|
+
ensure(![...subscriptions.values()].some(entry => entry.registration && registrationKey(entry.registration) === identity),
|
|
203
|
+
'duplicate_contribution', 'Contribution registration exists')
|
|
204
|
+
return subscribe('action', registration.actionId, handler, condition, registration)
|
|
205
|
+
}
|
|
206
|
+
function commandGroup(group, commands) {
|
|
207
|
+
active()
|
|
208
|
+
boundedText(group, 'command group')
|
|
209
|
+
ensure(Array.isArray(commands) && commands.length > 0 && commands.length <= LIMITS.contributions &&
|
|
210
|
+
subscriptions.size + commands.length <= LIMITS.subscriptions, 'queue_full', 'Invalid or excessive group commands')
|
|
211
|
+
const prepared = commands.map(command => {
|
|
212
|
+
ensure(object(command), 'invalid_payload', 'Invalid group command')
|
|
213
|
+
const { id, handler, ...options } = command
|
|
214
|
+
return prepareRegistration('command', id, { ...options, group }, handler)
|
|
215
|
+
})
|
|
216
|
+
const identities = new Set([...subscriptions.values()].filter(entry => entry.registration).map(entry => registrationKey(entry.registration)))
|
|
217
|
+
for (const { registration } of prepared) {
|
|
218
|
+
const identity = registrationKey(registration)
|
|
219
|
+
ensure(!identities.has(identity), 'duplicate_contribution', 'Contribution registration exists')
|
|
220
|
+
identities.add(identity)
|
|
221
|
+
}
|
|
222
|
+
const members = []
|
|
223
|
+
const dispose = () => { for (const member of members) member.dispose() }
|
|
224
|
+
try { for (const item of prepared) members.push(registerPrepared(item)) }
|
|
225
|
+
catch (error) { dispose(); throw error }
|
|
226
|
+
const ready = Promise.all(members.map(member => member.ready)).then(() => null)
|
|
227
|
+
ready.catch(dispose)
|
|
228
|
+
return { ready, dispose }
|
|
229
|
+
}
|
|
230
|
+
function schedule(intervalMs, handler, { condition = always, immediate = false } = {}) {
|
|
231
|
+
active()
|
|
232
|
+
ensure(Number.isFinite(intervalMs) && intervalMs >= 1000 / 30 && intervalMs <= 2147483647,
|
|
233
|
+
'invalid_payload', 'Schedule interval must be between 1000/30 and 2147483647 ms')
|
|
234
|
+
ensure(typeof handler === 'function' && schedules.size < LIMITS.schedules, 'queue_full', 'Invalid or excessive schedules')
|
|
235
|
+
condition = validateCondition(condition)
|
|
236
|
+
const interest = condition.kind === 'always' ? null : subscribe('visibility', condition.contributionId, () => {}, condition)
|
|
237
|
+
const conditionIdentity = conditionKey(condition)
|
|
238
|
+
const controller = new AbortController()
|
|
239
|
+
let activeRun = null
|
|
240
|
+
let timer
|
|
241
|
+
const beginRun = () => {
|
|
242
|
+
const runController = new AbortController()
|
|
243
|
+
const cancelFromSchedule = () => runController.abort()
|
|
244
|
+
const cancelFromLifetime = () => runController.abort()
|
|
245
|
+
controller.signal.addEventListener('abort', cancelFromSchedule, { once: true })
|
|
246
|
+
lifetime.signal.addEventListener('abort', cancelFromLifetime, { once: true })
|
|
247
|
+
const run = {
|
|
248
|
+
controller: runController,
|
|
249
|
+
finish() {
|
|
250
|
+
controller.signal.removeEventListener('abort', cancelFromSchedule)
|
|
251
|
+
lifetime.signal.removeEventListener('abort', cancelFromLifetime)
|
|
252
|
+
if (activeRun?.controller === runController) activeRun = null
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
activeRun = run
|
|
256
|
+
return run
|
|
257
|
+
}
|
|
258
|
+
const tick = async () => {
|
|
259
|
+
if (controller.signal.aborted || lifetime.signal.aborted) return
|
|
260
|
+
const visible = condition.kind === 'always' || visibility.has(conditionIdentity)
|
|
261
|
+
if (!visible) {
|
|
262
|
+
timer = clock.setTimeout(tick, intervalMs)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
const run = beginRun()
|
|
266
|
+
try {
|
|
267
|
+
await handler({ signal: run.controller.signal })
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (!run.controller.signal.aborted || condition.kind === 'always') onError(error)
|
|
270
|
+
} finally {
|
|
271
|
+
run.finish()
|
|
272
|
+
}
|
|
273
|
+
// Each schedule has at most one invocation in flight.
|
|
274
|
+
if (!controller.signal.aborted && !lifetime.signal.aborted) timer = clock.setTimeout(tick, intervalMs)
|
|
275
|
+
}
|
|
276
|
+
const disposable = {
|
|
277
|
+
visibilityChanged(nextVisibility) {
|
|
278
|
+
if (condition.kind !== 'always' && !nextVisibility.has(conditionIdentity)) activeRun?.controller.abort()
|
|
279
|
+
},
|
|
280
|
+
dispose() {
|
|
281
|
+
controller.abort()
|
|
282
|
+
activeRun?.controller.abort()
|
|
283
|
+
clock.clearTimeout(timer)
|
|
284
|
+
interest?.dispose()
|
|
285
|
+
schedules.delete(disposable)
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
schedules.add(disposable)
|
|
289
|
+
timer = clock.setTimeout(tick, immediate ? 0 : intervalMs)
|
|
290
|
+
return disposable
|
|
291
|
+
}
|
|
292
|
+
const method = op => (args, options) => request(op, args, options)
|
|
293
|
+
const context = Object.freeze({
|
|
294
|
+
manifest, producer: peer.producer, signal: lifetime.signal, request,
|
|
295
|
+
...Object.fromEntries(['section', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
|
|
296
|
+
(id, entity) => publish(kind, id, entity).publisher])),
|
|
297
|
+
canvas(id, spec, entity) {
|
|
298
|
+
const { publisher, key } = publish(null, id, entity)
|
|
299
|
+
try { publisher.replace({ kind: 'canvas', canvas: spec }) }
|
|
300
|
+
catch (error) { publisher.dispose(); throw error }
|
|
301
|
+
return { ...publisher, write: (ansi, options) => request('canvas.write', { key, ansi }, options),
|
|
302
|
+
focus: (capture, options) => request('canvas.focus', { key, capture }, options) }
|
|
303
|
+
},
|
|
304
|
+
...Object.fromEntries(['Event', 'Hook', 'Action', 'Input', 'Select', 'Resize', 'Activate', 'Deactivate'].map(name =>
|
|
305
|
+
[`on${name}`, (event, handler, condition) => subscribe(name.toLowerCase(), event, handler, condition)])),
|
|
306
|
+
...Object.fromEntries(['menu', 'command', 'key', 'link'].map(kind => [kind,
|
|
307
|
+
(id, options, handler) => registerPrepared(prepareRegistration(kind, id, options, handler))])),
|
|
308
|
+
commandGroup,
|
|
309
|
+
schedule,
|
|
310
|
+
onDispose(cleanup) {
|
|
311
|
+
active()
|
|
312
|
+
ensure(typeof cleanup === 'function' && cleanups.size < LIMITS.subscriptions, 'queue_full', 'Invalid or excessive cleanup callbacks')
|
|
313
|
+
cleanups.add(cleanup)
|
|
314
|
+
return { dispose: () => { cleanups.delete(cleanup) } }
|
|
315
|
+
},
|
|
316
|
+
panes: Object.freeze(Object.fromEntries(['create', 'close', 'restart', 'input', 'focus', 'wait'].map(name => [name, method(`pane.${name}`)]))),
|
|
317
|
+
projects: Object.freeze({ create: method('project.create'), remove: method('project.remove') }),
|
|
318
|
+
notifications: Object.freeze({ show: method('notification.show') }),
|
|
319
|
+
url: Object.freeze({ open: (url, options) => request('url.open', { url }, options) }),
|
|
320
|
+
fetch: method('fetch'),
|
|
321
|
+
secrets: Object.freeze({ get: (name, options) => request('secret.get', { name }, options) }),
|
|
322
|
+
config: Object.freeze({ get: options => request('config.get', {}, options) }),
|
|
323
|
+
state: Object.freeze({ get: (key, options) => request('state.get', { key }, options),
|
|
324
|
+
set: (key, value, options) => request('state.set', { key, value }, options) }),
|
|
325
|
+
context: Object.freeze({ get: options => request('context.get', {}, options) }),
|
|
326
|
+
popover: Object.freeze({ open: method('popover.open') }),
|
|
327
|
+
health: Object.freeze({ set: method('health.set') }),
|
|
328
|
+
webhook: Object.freeze({ ack: (deliveryId, options) => request('webhook.ack', { deliveryId }, options) }),
|
|
329
|
+
})
|
|
330
|
+
return {
|
|
331
|
+
context, receive: frame => peer.receive(frame),
|
|
332
|
+
async activate(definition) {
|
|
333
|
+
active()
|
|
334
|
+
ensure(definition?.id === manifest.id && typeof definition.activate === 'function', 'identity_mismatch', 'Runtime plugin must match static manifest id')
|
|
335
|
+
const cleanup = await definition.activate(context)
|
|
336
|
+
if (typeof cleanup === 'function') {
|
|
337
|
+
if (lifetime.signal.aborted) await cleanup()
|
|
338
|
+
else context.onDispose(cleanup)
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
dispose() {
|
|
342
|
+
if (disposal) return disposal
|
|
343
|
+
// Remove owned surfaces before the lifetime signal prevents new work.
|
|
344
|
+
for (const publisher of publishers.values()) { try { publisher.dispose() } catch (error) { onError(error) } }
|
|
345
|
+
lifetime.abort()
|
|
346
|
+
for (const disposable of schedules) disposable.dispose()
|
|
347
|
+
peer.dispose()
|
|
348
|
+
subscriptions.clear()
|
|
349
|
+
disposal = Promise.allSettled([...cleanups].reverse().map(cleanup => Promise.resolve().then(cleanup)))
|
|
350
|
+
.then(results => { for (const result of results) if (result.status === 'rejected') onError(result.reason); cleanups.clear() })
|
|
351
|
+
return disposal
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
}
|
package/src/testing.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions } from './index.js';
|
|
2
|
+
import type { Envelope } from './internal.js';
|
|
3
|
+
export interface Harness {
|
|
4
|
+
context: PluginContext;
|
|
5
|
+
trace: unknown[];
|
|
6
|
+
surfaces: Map<string, Envelope>;
|
|
7
|
+
subscriptions: Map<string, Json>;
|
|
8
|
+
activate(definition: PluginDefinition): Promise<void>;
|
|
9
|
+
flush(): Promise<void>;
|
|
10
|
+
advance(milliseconds: number): Promise<void>;
|
|
11
|
+
emit(kind: string, name: string, event: Json, options?: RequestOptions): Promise<Json[]>;
|
|
12
|
+
visibility(conditions: Condition[]): Promise<Json>;
|
|
13
|
+
receive(frame: Envelope): void;
|
|
14
|
+
drainTrace(): unknown[];
|
|
15
|
+
readonly resources: { timers: number; subscriptions: number; surfaces: number; disposed: boolean };
|
|
16
|
+
dispose(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export function createHarness(options: {
|
|
19
|
+
manifest: ManifestInput;
|
|
20
|
+
machineId?: string;
|
|
21
|
+
epoch?: string;
|
|
22
|
+
now?: number;
|
|
23
|
+
handlers?: { [K in keyof OperationMap]?: (args: OperationMap[K]['input'], context: { signal: AbortSignal; timeoutMs: number }) => OperationMap[K]['output'] | Promise<OperationMap[K]['output']> };
|
|
24
|
+
}): Harness;
|
package/src/testing.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createRuntime } from './runtime.mjs'
|
|
2
|
+
import { RpcPeer } from './protocol.mjs'
|
|
3
|
+
import { PluginError, ensure, LIMITS, validateManifest } from './manifest.mjs'
|
|
4
|
+
|
|
5
|
+
/** Explicit time and fixture-owned responses; this harness starts no threads or subprocesses. */
|
|
6
|
+
export function createHarness({ manifest: input, machineId = 'test-machine', epoch = '1', handlers = {}, now = 0 } = {}) {
|
|
7
|
+
const manifest = validateManifest(input)
|
|
8
|
+
const producer = { pluginId: manifest.id, machineId, epoch }
|
|
9
|
+
const trace = []
|
|
10
|
+
const surfaces = new Map()
|
|
11
|
+
const subscriptions = new Map()
|
|
12
|
+
const localState = new Map()
|
|
13
|
+
const timers = new Map()
|
|
14
|
+
let timerId = 0
|
|
15
|
+
let disposed = false
|
|
16
|
+
const clock = {
|
|
17
|
+
now: () => now,
|
|
18
|
+
setTimeout(callback, delay) { const id = ++timerId; timers.set(id, { due: now + delay, callback }); return id },
|
|
19
|
+
clearTimeout(id) { timers.delete(id) },
|
|
20
|
+
}
|
|
21
|
+
const record = event => {
|
|
22
|
+
ensure(trace.length < 10000, 'queue_full', 'Harness trace reached 10000 entries; drain it before continuing')
|
|
23
|
+
trace.push(event)
|
|
24
|
+
}
|
|
25
|
+
let runtime
|
|
26
|
+
const host = new RpcPeer({ producer, clock,
|
|
27
|
+
send(frame) { record({ type: 'host', frame: structuredClone(frame) }); runtime.receive(frame) },
|
|
28
|
+
onSurface(frame) {
|
|
29
|
+
const key = JSON.stringify(frame.key)
|
|
30
|
+
frame.content === null ? surfaces.delete(key) : surfaces.set(key, structuredClone(frame))
|
|
31
|
+
},
|
|
32
|
+
onRequest(operation, options) {
|
|
33
|
+
record({ type: 'command', operation: structuredClone(operation) })
|
|
34
|
+
if (handlers[operation.op]) return handlers[operation.op](operation.args, options)
|
|
35
|
+
const args = operation.args
|
|
36
|
+
switch (operation.op) {
|
|
37
|
+
case 'subscription.add': subscriptions.set(args.id, structuredClone(args)); return null
|
|
38
|
+
case 'subscription.remove': subscriptions.delete(args.id); return null
|
|
39
|
+
case 'state.get': return structuredClone(localState.get(args.key) ?? null)
|
|
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
|
|
43
|
+
case 'config.get': return {}
|
|
44
|
+
case 'context.get': return { machineId }
|
|
45
|
+
case 'health.set': return null
|
|
46
|
+
default: throw new PluginError('missing_fixture', `Provide a harness handler for ${operation.op}`)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
onError: error => record({ type: 'error', code: error.code, message: error.message }),
|
|
50
|
+
})
|
|
51
|
+
runtime = createRuntime({ manifest, producer, clock,
|
|
52
|
+
send(frame) { record({ type: 'plugin', frame: structuredClone(frame) }); host.receive(frame) },
|
|
53
|
+
onError: error => record({ type: 'error', code: error.code, message: error.message }),
|
|
54
|
+
})
|
|
55
|
+
async function flush() { for (let turn = 0; turn < 64; turn++) await Promise.resolve() }
|
|
56
|
+
return {
|
|
57
|
+
context: runtime.context, trace, surfaces, subscriptions,
|
|
58
|
+
activate: definition => runtime.activate(definition),
|
|
59
|
+
flush,
|
|
60
|
+
async advance(milliseconds) {
|
|
61
|
+
ensure(Number.isFinite(milliseconds) && milliseconds >= 0, 'invalid_payload', 'Advance requires a nonnegative duration')
|
|
62
|
+
const end = now + milliseconds
|
|
63
|
+
await flush()
|
|
64
|
+
let ticks = 0
|
|
65
|
+
while (true) {
|
|
66
|
+
const next = [...timers].filter(([, timer]) => timer.due <= end)
|
|
67
|
+
.sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0]
|
|
68
|
+
if (!next) break
|
|
69
|
+
ensure(++ticks <= 10000, 'queue_full', 'Advance exceeds the harness timer budget')
|
|
70
|
+
now = next[1].due
|
|
71
|
+
timers.delete(next[0])
|
|
72
|
+
const result = next[1].callback()
|
|
73
|
+
Promise.resolve(result).catch(error => record({ type: 'error', code: error.code, message: error.message }))
|
|
74
|
+
await flush()
|
|
75
|
+
}
|
|
76
|
+
now = end
|
|
77
|
+
await flush()
|
|
78
|
+
},
|
|
79
|
+
async emit(kind, name, event, options) {
|
|
80
|
+
await flush()
|
|
81
|
+
const results = []
|
|
82
|
+
for (const [subscriptionId, subscription] of subscriptions) {
|
|
83
|
+
if (subscription.kind === kind && subscription.name === name) {
|
|
84
|
+
results.push(await host.request({ op: 'runtime.invoke', args: { subscriptionId, event } }, options))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return results
|
|
88
|
+
},
|
|
89
|
+
visibility: conditions => host.request({ op: 'runtime.visibility', args: { conditions } }),
|
|
90
|
+
receive: frame => runtime.receive(frame),
|
|
91
|
+
drainTrace() { return trace.splice(0) },
|
|
92
|
+
get resources() { return { timers: timers.size, subscriptions: subscriptions.size, surfaces: surfaces.size, disposed } },
|
|
93
|
+
async dispose() {
|
|
94
|
+
if (disposed) return
|
|
95
|
+
disposed = true
|
|
96
|
+
await runtime.dispose()
|
|
97
|
+
host.dispose()
|
|
98
|
+
subscriptions.clear()
|
|
99
|
+
surfaces.clear()
|
|
100
|
+
timers.clear()
|
|
101
|
+
record({ type: 'disposed' })
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|