acrylblends 1.0.9 → 1.1.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.
Files changed (47) hide show
  1. package/dist/index.d.ts +15 -1
  2. package/dist/index.js +13 -7
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/api/summary.md +1 -1
  5. package/docs/framework/guides/bring-your-own-tool.md +2 -0
  6. package/docs/framework/guides/production.md +20 -0
  7. package/docs/framework/workshop/README.md +177 -274
  8. package/docs/framework/workshop/facilitator.md +107 -84
  9. package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
  10. package/lib/cordis/door.d.ts +3 -1
  11. package/lib/cordis/door.js +11 -1
  12. package/lib/cordis/state.d.ts +3 -2
  13. package/lib/cordis/state.js +370 -6
  14. package/lib/db/conformance.d.ts +6 -2
  15. package/lib/db/conformance.js +14 -2
  16. package/lib/door/host.d.ts +1 -1
  17. package/lib/door/host.js +1 -1
  18. package/lib/door/page.d.ts +10 -0
  19. package/lib/door/page.js +29 -7
  20. package/lib/kv/conformance.d.ts +6 -2
  21. package/lib/kv/conformance.js +14 -2
  22. package/lib/{snapshot-text-CBi3EMnj.js → snapshot-text-BwM6T2nG.js} +37 -5
  23. package/lib/vitest-binding-CctNu3jT.d.ts +15 -0
  24. package/lib/vitest-binding-CrH7rKg2.js +17 -0
  25. package/package.json +1 -5
  26. package/plugins/acryl-door/src/host/channel.ts +43 -5
  27. package/plugins/acryl-door/src/host/door.ts +5 -0
  28. package/plugins/acryl-door/src/host/page-routes.ts +1 -1
  29. package/plugins/acryl-door/src/host/page-socket.ts +1 -1
  30. package/plugins/acryl-door/src/page/channel.ts +8 -0
  31. package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
  32. package/plugins/acryl-door/src/page/index.ts +6 -1
  33. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  34. package/plugins/acryl-state/plugin.json +4 -3
  35. package/plugins/acryl-state/src/client-source.ts +244 -0
  36. package/plugins/acryl-state/src/service.ts +33 -6
  37. package/plugins/acryl-state/src/socket.ts +96 -0
  38. package/plugins/agent-panel/client/main.ts +105 -55
  39. package/plugins/agent-panel/index.ts +42 -5
  40. package/plugins/desktop/main.ts +5 -1
  41. package/plugins/instance/index.ts +6 -3
  42. package/templates/agent/AGENTS.md +3 -1
  43. package/templates/agent/src/routes.ts +38 -5
  44. package/templates/agent/test/app.test.ts +38 -2
  45. package/templates/chat/src/main.ts +1 -6
  46. package/templates/nodejs/src/main.ts +1 -6
  47. package/templates/notes/src/main.ts +1 -6
@@ -1,7 +1,7 @@
1
1
  import { t as Acryl } from "../app-CQcGWjrk.js";
2
2
  import { t as methodNotAllowed } from "../method-not-allowed-TO2_hnfl.js";
3
3
  import { r as streamSSE } from "../streaming-BlDaph8U.js";
4
- import { i as loopbackOnly } from "../loopback-only-BbasoF7R.js";
4
+ import { i as loopbackOnly, r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
5
5
  import "../helpers-BEjcdbgw.js";
6
6
  import "../src-BTBm4OPD.js";
7
7
  import "../middleware-BfG_fp8P.js";
@@ -58,6 +58,345 @@ function createDerived(key, inputs, compute, report = () => void 0) {
58
58
  };
59
59
  }
60
60
  //#endregion
61
+ //#region ../../plugins/acryl-state/src/client-source.ts
62
+ /**
63
+ * The browser module served at `/api/state/client.js`, kept as text so it travels with the plugin wherever the plugin goes (copied into an app as source, or bundled): there is
64
+ * no file to find at run time and no compiler needed to serve it. `String.raw` keeps every backslash as written, so what is below is the module exactly as the browser gets it.
65
+ * The code has no imports and uses no backtick or dollar-brace of its own.
66
+ *
67
+ * import { watch } from '/api/state/client.js'
68
+ * const stop = watch('tasks.count', (value) => { badge.textContent = String(value) })
69
+ *
70
+ * One WebSocket for the whole page carries every key; a WebSocket is not one of the browser's six connections per host, so a page that watches ten keys, in as many windows,
71
+ * leaves every one of them free for requests. Where the WebSocket cannot open (no `ws` package on the server, a proxy that does not upgrade), each key is followed by its
72
+ * event stream instead, with one console warning.
73
+ */
74
+ const CLIENT_SOURCE = String.raw`// acryl-state: watch(key, onValue, onError?) follows a state key over one WebSocket for the page.
75
+ const HEARTBEAT_SILENCE_MS = 45000
76
+ const RETRY_MIN_MS = 250
77
+ const RETRY_MAX_MS = 10000
78
+
79
+ const here = import.meta.url
80
+ const watchers = new Map()
81
+ const latest = new Map()
82
+ const streams = new Map()
83
+ const complained = new Set()
84
+ let socket
85
+ let timer
86
+ let watchdog
87
+ let attempts = 0
88
+ let opened = false
89
+ let following = 'socket'
90
+ let suspended = false
91
+ let warnedStream = false
92
+
93
+ const socketUrl = () => {
94
+ const url = new URL('ws', here)
95
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
96
+ return url.href
97
+ }
98
+ const streamUrl = (key) => new URL(encodeURIComponent(key) + '/events?json=1', here).href
99
+
100
+ const deliver = (key, text) => {
101
+ if (latest.get(key) === text) return
102
+ latest.set(key, text)
103
+ let value
104
+ try {
105
+ value = JSON.parse(text)
106
+ } catch {
107
+ return
108
+ }
109
+ for (const watcher of [...(watchers.get(key) ?? [])]) {
110
+ try {
111
+ watcher.onValue(value)
112
+ } catch (cause) {
113
+ console.error('acryl-state: a watcher of ' + key + ' threw', cause)
114
+ }
115
+ }
116
+ }
117
+
118
+ const fail = (key, message) => {
119
+ const set = watchers.get(key)
120
+ if (set === undefined) return
121
+ let told = false
122
+ for (const watcher of [...set]) {
123
+ if (watcher.onError === undefined) continue
124
+ told = true
125
+ try {
126
+ watcher.onError(new Error(message))
127
+ } catch (cause) {
128
+ console.error('acryl-state: an error handler of ' + key + ' threw', cause)
129
+ }
130
+ }
131
+ if (!told && !complained.has(key)) {
132
+ complained.add(key)
133
+ console.warn('acryl-state: ' + key + ': ' + message)
134
+ }
135
+ }
136
+
137
+ const send = (frame) => {
138
+ if (socket !== undefined && socket.readyState === 1) socket.send(JSON.stringify(frame))
139
+ }
140
+
141
+ const arm = () => {
142
+ clearTimeout(watchdog)
143
+ watchdog = setTimeout(() => lost(socket), HEARTBEAT_SILENCE_MS)
144
+ }
145
+
146
+ const closeSocket = () => {
147
+ clearTimeout(watchdog)
148
+ const current = socket
149
+ socket = undefined
150
+ if (current === undefined) return
151
+ current.onopen = current.onmessage = current.onerror = current.onclose = null
152
+ try {
153
+ current.close(1000, 'page left')
154
+ } catch {
155
+ // Already closed.
156
+ }
157
+ }
158
+
159
+ const closeStreams = () => {
160
+ for (const source of streams.values()) source.close()
161
+ streams.clear()
162
+ }
163
+
164
+ const openStream = (key) => {
165
+ if (streams.has(key) || typeof EventSource === 'undefined') return
166
+ const source = new EventSource(streamUrl(key))
167
+ streams.set(key, source)
168
+ source.addEventListener('value', (event) => deliver(key, event.data))
169
+ source.onerror = () => {
170
+ // A network error is retried by the browser itself; a refusal (the key does not exist) ends the stream for good.
171
+ if (source.readyState === 2) {
172
+ streams.delete(key)
173
+ fail(key, 'the event stream was refused (no such state?)')
174
+ }
175
+ }
176
+ }
177
+
178
+ const fallBack = () => {
179
+ following = 'stream'
180
+ if (!warnedStream) {
181
+ warnedStream = true
182
+ console.warn('acryl-state: the WebSocket did not open, so each key is followed by its own event stream (each holds one of the browser\'s six connections to this host)')
183
+ }
184
+ for (const key of watchers.keys()) openStream(key)
185
+ }
186
+
187
+ // A socket that closes, errors or goes quiet: before it ever opened this is a server without WebSockets, so the streams take over; after, the server went away and the page tries again.
188
+ const lost = (which) => {
189
+ if (which !== socket) return
190
+ closeSocket()
191
+ if (watchers.size === 0 || suspended) return
192
+ if (!opened) {
193
+ fallBack()
194
+ return
195
+ }
196
+ const delay = Math.min(RETRY_MIN_MS * 2 ** attempts, RETRY_MAX_MS)
197
+ attempts += 1
198
+ clearTimeout(timer)
199
+ timer = setTimeout(connect, delay)
200
+ }
201
+
202
+ const connect = () => {
203
+ clearTimeout(timer)
204
+ if (socket !== undefined || watchers.size === 0 || suspended || following !== 'socket') return
205
+ let made
206
+ try {
207
+ made = new WebSocket(socketUrl())
208
+ } catch {
209
+ fallBack()
210
+ return
211
+ }
212
+ socket = made
213
+ made.onopen = () => {
214
+ opened = true
215
+ attempts = 0
216
+ arm()
217
+ for (const key of watchers.keys()) send({ t: 'sub', key })
218
+ }
219
+ made.onmessage = (event) => {
220
+ if (made !== socket) return
221
+ arm()
222
+ let frame
223
+ try {
224
+ frame = JSON.parse(event.data)
225
+ } catch {
226
+ return
227
+ }
228
+ if (frame === null || typeof frame !== 'object' || typeof frame.key !== 'string') return
229
+ if (typeof frame.error === 'string') fail(frame.key, frame.error)
230
+ else deliver(frame.key, JSON.stringify(frame.value))
231
+ }
232
+ made.onerror = () => lost(made)
233
+ made.onclose = () => lost(made)
234
+ }
235
+
236
+ const start = () => {
237
+ if (watchers.size === 0) return
238
+ if (following === 'stream') for (const key of watchers.keys()) openStream(key)
239
+ else connect()
240
+ }
241
+
242
+ if (typeof addEventListener === 'function') {
243
+ // A page in the back/forward cache keeps its connections open for as long as it is kept; let them go when the page is hidden for good, and open them again when it comes back.
244
+ addEventListener('pagehide', () => {
245
+ suspended = true
246
+ clearTimeout(timer)
247
+ closeSocket()
248
+ closeStreams()
249
+ })
250
+ addEventListener('pageshow', () => {
251
+ if (!suspended) return
252
+ suspended = false
253
+ start()
254
+ })
255
+ }
256
+
257
+ // Follow the state key: onValue gets the value now and after every change, as the JSON value the server holds (a string stays a string). onError, when given, is told of a key that
258
+ // does not exist; without it the page console gets one warning. Returns the function that stops watching.
259
+ export function watch(key, onValue, onError) {
260
+ if (typeof key !== 'string' || key === '') throw new TypeError('watch(key, onValue): the key is a state key like "tasks.count"')
261
+ if (typeof onValue !== 'function') throw new TypeError('watch(key, onValue): onValue is a function')
262
+ const watcher = { onValue, onError }
263
+ let set = watchers.get(key)
264
+ const first = set === undefined
265
+ if (first) {
266
+ set = new Set()
267
+ watchers.set(key, set)
268
+ }
269
+ set.add(watcher)
270
+ complained.delete(key)
271
+ if (!first && latest.has(key)) {
272
+ const text = latest.get(key)
273
+ queueMicrotask(() => {
274
+ if (!set.has(watcher)) return
275
+ try {
276
+ onValue(JSON.parse(text))
277
+ } catch (cause) {
278
+ console.error('acryl-state: a watcher of ' + key + ' threw', cause)
279
+ }
280
+ })
281
+ }
282
+ if (first && !suspended) {
283
+ if (following === 'stream') openStream(key)
284
+ else if (socket === undefined) connect()
285
+ else send({ t: 'sub', key })
286
+ }
287
+ let stopped = false
288
+ return () => {
289
+ if (stopped) return
290
+ stopped = true
291
+ set.delete(watcher)
292
+ if (set.size > 0) return
293
+ watchers.delete(key)
294
+ latest.delete(key)
295
+ complained.delete(key)
296
+ streams.get(key)?.close()
297
+ streams.delete(key)
298
+ send({ t: 'unsub', key })
299
+ if (watchers.size === 0) {
300
+ clearTimeout(timer)
301
+ closeSocket()
302
+ }
303
+ }
304
+ }
305
+ `;
306
+ /** A refusal (403) unless the upgrade comes from this machine to this app, and from its own page when it comes from a page at all. */
307
+ function refuseStranger(c) {
308
+ const authority = loopbackAuthority(c);
309
+ if (authority === void 0) return c.json({ error: "request authority rejected" }, 403);
310
+ const origin = c.req.header("origin");
311
+ const site = c.req.header("sec-fetch-site");
312
+ if (origin !== void 0 && origin !== authority.origin || site !== void 0 && site !== "same-origin") return c.json({ error: "only this app's own page may follow its state" }, 403);
313
+ }
314
+ /** The route handler that upgrades and follows keys of `cells` for as long as the socket is open. */
315
+ function stateSocket(cells, options) {
316
+ const heartbeatMs = options.heartbeatMs ?? 15e3;
317
+ return options.upgrade(() => {
318
+ const following = /* @__PURE__ */ new Map();
319
+ let beat;
320
+ return {
321
+ onOpen(_event, ws) {
322
+ beat = setInterval(() => {
323
+ try {
324
+ ws.send(JSON.stringify({ t: "ping" }));
325
+ } catch {}
326
+ }, heartbeatMs);
327
+ beat.unref();
328
+ },
329
+ onMessage(event, ws) {
330
+ if (typeof event.data !== "string") return;
331
+ let message;
332
+ try {
333
+ message = JSON.parse(event.data);
334
+ } catch {
335
+ return;
336
+ }
337
+ if (typeof message.key !== "string") return;
338
+ const key = message.key;
339
+ const tell = (frame) => {
340
+ try {
341
+ ws.send(JSON.stringify(frame));
342
+ } catch {}
343
+ };
344
+ const value = (current) => {
345
+ let text;
346
+ try {
347
+ text = JSON.stringify({
348
+ key,
349
+ value: current === void 0 ? null : current
350
+ });
351
+ } catch {
352
+ tell({
353
+ key,
354
+ error: "the value cannot be sent as JSON"
355
+ });
356
+ return;
357
+ }
358
+ try {
359
+ ws.send(text);
360
+ } catch {}
361
+ };
362
+ if (message.t === "unsub") {
363
+ following.get(key)?.();
364
+ following.delete(key);
365
+ return;
366
+ }
367
+ if (message.t !== "sub") return;
368
+ const cell = cells.find(key);
369
+ if (cell === void 0) {
370
+ tell({
371
+ key,
372
+ error: "no such state"
373
+ });
374
+ return;
375
+ }
376
+ const known = following.has(key);
377
+ if (!known && following.size >= 256) {
378
+ tell({
379
+ key,
380
+ error: "too many keys on one socket"
381
+ });
382
+ return;
383
+ }
384
+ if (known) {
385
+ value(cell.get());
386
+ return;
387
+ }
388
+ value(cell.get());
389
+ following.set(key, cell.subscribe(value));
390
+ },
391
+ onClose() {
392
+ if (beat !== void 0) clearInterval(beat);
393
+ for (const stop of following.values()) stop();
394
+ following.clear();
395
+ }
396
+ };
397
+ }, { textOnly: true });
398
+ }
399
+ //#endregion
61
400
  //#region ../../plugins/acryl-state/src/service.ts
62
401
  /**
63
402
  * The ACRYL state service (`ctx.state`): observable cells owned by plugins. A cell created from a plugin's context is removed
@@ -69,6 +408,8 @@ const KEEP_ALIVE_MS = 15e3;
69
408
  /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
70
409
  const name = "acryl-state";
71
410
  const KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
411
+ /** Names under `/api/state` that are routes, not keys: a cell cannot be called by one. */
412
+ const RESERVED_KEYS = /* @__PURE__ */ new Set(["ws", "client.js"]);
72
413
  var State = class extends Service {
73
414
  cells = /* @__PURE__ */ new Map();
74
415
  constructor(ctx) {
@@ -76,6 +417,7 @@ var State = class extends Service {
76
417
  }
77
418
  claim(key) {
78
419
  if (!KEY_PATTERN.test(key)) throw new TypeError(`acryl-state: key "${key}" must be dotted lowercase words, like "market.sources"`);
420
+ if (RESERVED_KEYS.has(key)) throw new TypeError(`acryl-state: "${key}" is the name of a route under /api/state, choose another key`);
79
421
  if (this.cells.has(key)) throw new Error(`acryl-state: key "${key}" is already in use`);
80
422
  }
81
423
  report = (error) => {
@@ -111,9 +453,10 @@ var State = class extends Service {
111
453
  }
112
454
  /**
113
455
  * The current value of `key` as a server-sent event stream: the value now, then one event per change, until the client cancels or the router
114
- * is disposed (both abort the stream). A comment line goes out every 15 seconds so proxies and browsers keep an idle stream open.
456
+ * is disposed (both abort the stream). A comment line goes out every 15 seconds so proxies and browsers keep an idle stream open. A string value travels as it is; with `json`
457
+ * (the `?json=1` of the browser client) every value is JSON, so `"12"` and `12` can be told apart.
115
458
  */
116
- stream(key, c) {
459
+ stream(key, c, json = false) {
117
460
  const cell = this.find(key);
118
461
  if (cell === void 0) return void 0;
119
462
  return streamSSE(c, async (stream) => {
@@ -124,7 +467,7 @@ var State = class extends Service {
124
467
  const send = (value) => {
125
468
  queue = queue.then(() => stream.writeSSE({
126
469
  event: "value",
127
- data: typeof value === "string" ? value : JSON.stringify(value)
470
+ data: typeof value === "string" && !json ? value : JSON.stringify(value)
128
471
  })).catch(() => {});
129
472
  };
130
473
  send(cell.get());
@@ -146,14 +489,35 @@ function apply(ctx) {
146
489
  const state = new State(ctx);
147
490
  ctx.inject(["router"], (inner) => {
148
491
  const api = new Acryl();
149
- api.use(loopbackOnly({ inProcess: "allow" }), methodNotAllowed({ app: api })).get("/", (c) => c.json({ keys: state.keys() })).get("/:key", (c) => {
492
+ let socket;
493
+ api.use(loopbackOnly({ inProcess: "allow" }), methodNotAllowed({ app: api })).get("/", (c) => c.json({ keys: state.keys() })).get("/client.js", (c) => c.body(CLIENT_SOURCE, 200, {
494
+ "content-type": "text/javascript; charset=utf-8",
495
+ "cache-control": "no-cache"
496
+ })).get("/ws", async (c, next) => {
497
+ const upgrade = socket;
498
+ if (upgrade === void 0) return c.json({ error: "this server does not upgrade to a WebSocket" }, 404);
499
+ const refusal = refuseStranger(c);
500
+ if (refusal !== void 0) return refusal;
501
+ return await upgrade(c, next);
502
+ }).get("/:key", (c) => {
150
503
  const cell = state.find(c.req.param("key"));
151
504
  return cell === void 0 ? c.json({ error: "no such state" }, 404) : c.json({
152
505
  key: cell.key,
153
506
  value: cell.get()
154
507
  });
155
- }).get("/:key/events", (c) => state.stream(c.req.param("key"), c) ?? c.json({ error: "no such state" }, 404));
508
+ }).get("/:key/events", (c) => state.stream(c.req.param("key"), c, c.req.query("json") === "1") ?? c.json({ error: "no such state" }, 404));
156
509
  inner.router.route("/api/state", api);
510
+ inner.inject(["nodeServer"], (withServer) => {
511
+ try {
512
+ const upgrade = withServer.nodeServer.upgrades();
513
+ socket = stateSocket(state, { upgrade });
514
+ withServer.effect(() => () => {
515
+ socket = void 0;
516
+ }, "acryl-state: the WebSocket route");
517
+ } catch (cause) {
518
+ ctx.logger.warn(`acryl-state: state is followed by event streams only (${cause instanceof Error ? cause.message : String(cause)})`);
519
+ }
520
+ });
157
521
  inner.inject(["routerHttp"], (http) => {
158
522
  http.routerHttp.mount("/api/state");
159
523
  });
@@ -1,4 +1,5 @@
1
1
  import { t as Db } from "../database-DBHdwefi.js";
2
+ import { t as VitestApi } from "../vitest-binding-CctNu3jT.js";
2
3
 
3
4
  //#region ../../plugins/acryl-db/src/conformance.d.ts
4
5
  /** A fresh, empty database from the provider under test, and how to close it. */
@@ -6,6 +7,9 @@ interface DbUnderTest {
6
7
  readonly db: Db;
7
8
  close(): Promise<void>;
8
9
  }
9
- declare function describeDbConformance(provider: string, open: () => Promise<DbUnderTest>): void;
10
+ declare function describeDbConformance$1(provider: string, open: () => Promise<DbUnderTest>): void;
10
11
  //#endregion
11
- export { DbUnderTest, describeDbConformance };
12
+ //#region src/db-conformance.d.ts
13
+ declare function describeDbConformance(provider: string, open: Parameters<typeof describeDbConformance$1>[1], vitest: VitestApi): void;
14
+ //#endregion
15
+ export { type DbUnderTest, type VitestApi, describeDbConformance };
@@ -1,8 +1,8 @@
1
1
  import { i as applyMigrations, n as MIGRATIONS_TABLE, r as readMigrationFiles } from "../src-MWcsMS8i.js";
2
+ import { a as it, i as expect, n as bind, r as describe, t as afterEach } from "../vitest-binding-CrH7rKg2.js";
2
3
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { tmpdir } from "node:os";
5
- import { afterEach, describe, expect, it } from "vitest";
6
6
  //#region ../../plugins/acryl-db/src/conformance.ts
7
7
  /**
8
8
  * The `db` conformance suite: what every provider of the port must do, as Vitest tests. A provider passes it to be accepted
@@ -24,7 +24,7 @@ function gate() {
24
24
  open
25
25
  };
26
26
  }
27
- function describeDbConformance(provider, open) {
27
+ function describeDbConformance$1(provider, open) {
28
28
  describe(`db conformance: ${provider}`, () => {
29
29
  const opened = [];
30
30
  const fresh = async () => {
@@ -264,4 +264,16 @@ function describeDbConformance(provider, open) {
264
264
  });
265
265
  }
266
266
  //#endregion
267
+ //#region src/db-conformance.ts
268
+ /**
269
+ * `acrylblends/db/conformance`: the `db` conformance suite a provider of the port passes. It runs on the vitest your test hands it, so the package declares no vitest:
270
+ *
271
+ * import * as vitest from 'vitest'
272
+ * describeDbConformance('my-provider', open, vitest)
273
+ */
274
+ function describeDbConformance(provider, open, vitest) {
275
+ bind(vitest);
276
+ describeDbConformance$1(provider, open);
277
+ }
278
+ //#endregion
267
279
  export { describeDbConformance };
@@ -1,6 +1,6 @@
1
1
  import { t as Acryl } from "../app-D8riatvv.js";
2
2
  import { n as AuditEntry, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
3
- import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-Dw-siYTp.js";
3
+ import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-BFpULBCj.js";
4
4
 
5
5
  //#region ../../plugins/acryl-door/src/host/page-routes.d.ts
6
6
  /** How often the Host writes a `ping`; the page gives up on a stream that is silent for three of these. */
package/lib/door/host.js CHANGED
@@ -1,2 +1,2 @@
1
- import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-CBi3EMnj.js";
1
+ import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-BwM6T2nG.js";
2
2
  export { AuditLog, HEARTBEAT_MS, UiChannel, auditPath, pageChannelRoutes, renderSnapshot };
@@ -4,6 +4,8 @@ import { _ as UiRequest, v as UiResult } from "../protocol-a2aqrp2X.js";
4
4
  interface DriverActivity {
5
5
  /** True from the moment a call starts until it ends. */
6
6
  readonly busy: boolean;
7
+ /** True from the moment a run asked for from this page's panel starts until it ends, between the calls too. */
8
+ readonly running: boolean;
7
9
  /** What the agent is doing, for the indicator ("click", "type", ...). */
8
10
  readonly doing: string;
9
11
  /** True after the user pressed the kill switch. */
@@ -32,6 +34,7 @@ declare class UiDriver {
32
34
  private busyCount;
33
35
  private doing;
34
36
  private current;
37
+ private runningFlag;
35
38
  private readonly options;
36
39
  constructor(options: UiDriverOptions);
37
40
  /** The current activity; the same object until something changes, as a store snapshot must be. */
@@ -39,6 +42,11 @@ declare class UiDriver {
39
42
  subscribe(listener: (activity: DriverActivity) => void): () => void;
40
43
  /** The user pressed the kill switch: pending calls settle as `killed` and new ones are refused. */
41
44
  kill(): void;
45
+ /**
46
+ * A run asked for from this page's panel starts (`true`) or ends (`false`). The indicator stays up for the whole run, not only while a call is in flight. A new run is a new
47
+ * question from the person, so it also allows the agent to drive again after an earlier Stop.
48
+ */
49
+ setRunning(running: boolean): void;
42
50
  /** The user allowed the agent to drive again. */
43
51
  resume(): void;
44
52
  /** The driver goes away: pending calls settle as `unloaded`; refs are gone. */
@@ -87,6 +95,8 @@ interface PageChannelOptions {
87
95
  readonly post?: (url: string, body: string) => Promise<unknown>;
88
96
  readonly silenceMs?: number;
89
97
  readonly now?: () => number;
98
+ /** The Host says a run asked for from this page's panel started (`true`) or ended (`false`); false too when the stream is lost, until the Host says again. */
99
+ readonly onRun?: (running: boolean) => void;
90
100
  }
91
101
  declare class PageChannel {
92
102
  private source;
package/lib/door/page.js CHANGED
@@ -99,6 +99,10 @@ var PageChannel = class {
99
99
  heard();
100
100
  });
101
101
  source.addEventListener("ping", heard);
102
+ source.addEventListener("run", (event) => {
103
+ heard();
104
+ this.options.onRun?.(event.data === "1");
105
+ });
102
106
  source.addEventListener("call", (event) => {
103
107
  heard();
104
108
  if (typeof event.data === "string") this.answer(event.data);
@@ -170,6 +174,7 @@ var PageChannel = class {
170
174
  if (source !== void 0) {
171
175
  source.onerror = null;
172
176
  source.close();
177
+ this.options.onRun?.(false);
173
178
  }
174
179
  }
175
180
  answer(text) {
@@ -938,9 +943,11 @@ var UiDriver = class {
938
943
  doing = "";
939
944
  current = {
940
945
  busy: false,
946
+ running: false,
941
947
  doing: "",
942
948
  killed: false
943
949
  };
950
+ runningFlag = false;
944
951
  options;
945
952
  constructor(options) {
946
953
  this.options = options;
@@ -969,6 +976,16 @@ var UiDriver = class {
969
976
  this.snapshots.clear();
970
977
  this.emit();
971
978
  }
979
+ /**
980
+ * A run asked for from this page's panel starts (`true`) or ends (`false`). The indicator stays up for the whole run, not only while a call is in flight. A new run is a new
981
+ * question from the person, so it also allows the agent to drive again after an earlier Stop.
982
+ */
983
+ setRunning(running) {
984
+ if (running === this.runningFlag) return;
985
+ this.runningFlag = running;
986
+ if (running) this.killedFlag = false;
987
+ this.emit();
988
+ }
972
989
  /** The user allowed the agent to drive again. */
973
990
  resume() {
974
991
  this.killedFlag = false;
@@ -1058,10 +1075,11 @@ var UiDriver = class {
1058
1075
  emit() {
1059
1076
  const next = {
1060
1077
  busy: this.busyCount > 0,
1078
+ running: this.runningFlag,
1061
1079
  doing: this.doing,
1062
1080
  killed: this.killedFlag
1063
1081
  };
1064
- if (next.busy === this.current.busy && next.doing === this.current.doing && next.killed === this.current.killed) return;
1082
+ if (next.busy === this.current.busy && next.running === this.current.running && next.doing === this.current.doing && next.killed === this.current.killed) return;
1065
1083
  this.current = next;
1066
1084
  const activity = next;
1067
1085
  for (const listener of [...this.listeners]) listener(activity);
@@ -1111,6 +1129,8 @@ const LABELS = {
1111
1129
  scroll: "scrolling",
1112
1130
  wait: "waiting"
1113
1131
  };
1132
+ /** Dispatched on the page's window by the Stop button: the agent panel in the same page cancels its run. */
1133
+ const STOP_RUN_EVENT = "acryl:stop-run";
1114
1134
  const STYLE_ID = "acryl-door-indicator-style";
1115
1135
  const STYLES = `
1116
1136
  .acrylDoorBar { position: fixed; z-index: 2147483647; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
@@ -1133,12 +1153,16 @@ function mountIndicator(driver, document) {
1133
1153
  bar.setAttribute("data-acryl-no-agent", "");
1134
1154
  bar.setAttribute("role", "status");
1135
1155
  bar.setAttribute("aria-live", "polite");
1156
+ const stopAgent = () => {
1157
+ driver.kill();
1158
+ document.defaultView?.dispatchEvent(new CustomEvent(STOP_RUN_EVENT));
1159
+ };
1136
1160
  const onKey = (event) => {
1137
- if (event.key === "Escape" && event.isTrusted && driver.activity.busy) driver.kill();
1161
+ if (event.key === "Escape" && event.isTrusted && (driver.activity.busy || driver.activity.running)) stopAgent();
1138
1162
  };
1139
1163
  document.addEventListener("keydown", onKey, true);
1140
1164
  const render = (activity) => {
1141
- if (!activity.busy && !activity.killed) {
1165
+ if (!activity.busy && !activity.running && !activity.killed) {
1142
1166
  bar.remove();
1143
1167
  return;
1144
1168
  }
@@ -1158,11 +1182,9 @@ function mountIndicator(driver, document) {
1158
1182
  const dot = document.createElement("span");
1159
1183
  dot.className = "acrylDoorDot";
1160
1184
  dot.setAttribute("aria-hidden", "true");
1161
- text.textContent = `Agent is driving: ${LABELS[activity.doing] ?? activity.doing}`;
1185
+ text.textContent = `Agent is driving: ${activity.busy ? LABELS[activity.doing] ?? activity.doing : "working on your request"}`;
1162
1186
  button.textContent = "Stop";
1163
- button.addEventListener("click", () => {
1164
- driver.kill();
1165
- });
1187
+ button.addEventListener("click", stopAgent);
1166
1188
  bar.replaceChildren(dot, text, button);
1167
1189
  }
1168
1190
  if (!bar.isConnected) document.body.append(bar);
@@ -1,3 +1,4 @@
1
+ import { t as VitestApi } from "../vitest-binding-CctNu3jT.js";
1
2
  import { n as Kv } from "../kv-9f-AlRKN.js";
2
3
 
3
4
  //#region ../../plugins/acryl-kv/src/conformance.d.ts
@@ -8,6 +9,9 @@ interface KvUnderTest {
8
9
  /** Close this store (if it is still open) and open the same storage again, to prove what was written is durable. */
9
10
  reopen(): Promise<KvUnderTest>;
10
11
  }
11
- declare function describeKvConformance(provider: string, open: () => Promise<KvUnderTest>): void;
12
+ declare function describeKvConformance$1(provider: string, open: () => Promise<KvUnderTest>): void;
12
13
  //#endregion
13
- export { KvUnderTest, describeKvConformance };
14
+ //#region src/kv-conformance.d.ts
15
+ declare function describeKvConformance(provider: string, open: Parameters<typeof describeKvConformance$1>[1], vitest: VitestApi): void;
16
+ //#endregion
17
+ export { type KvUnderTest, type VitestApi, describeKvConformance };
@@ -1,10 +1,10 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { a as it, i as expect, n as bind, r as describe, t as afterEach } from "../vitest-binding-CrH7rKg2.js";
2
2
  //#region ../../plugins/acryl-kv/src/conformance.ts
3
3
  /**
4
4
  * The `kv` conformance suite: what every provider of the port must do, as Vitest tests. A provider passes it to be accepted
5
5
  * (`describeKvConformance('acryl-kv-json', open)` in its own tests), so the same cases run on a file and on SQL.
6
6
  */
7
- function describeKvConformance(provider, open) {
7
+ function describeKvConformance$1(provider, open) {
8
8
  describe(`kv conformance: ${provider}`, () => {
9
9
  const opened = [];
10
10
  const track = (subject) => {
@@ -298,4 +298,16 @@ function describeKvConformance(provider, open) {
298
298
  });
299
299
  }
300
300
  //#endregion
301
+ //#region src/kv-conformance.ts
302
+ /**
303
+ * `acrylblends/kv/conformance`: the `kv` conformance suite a provider of the port passes. It runs on the vitest your test hands it, so the package declares no vitest:
304
+ *
305
+ * import * as vitest from 'vitest'
306
+ * describeKvConformance('my-provider', open, vitest)
307
+ */
308
+ function describeKvConformance(provider, open, vitest) {
309
+ bind(vitest);
310
+ describeKvConformance$1(provider, open);
311
+ }
312
+ //#endregion
301
313
  export { describeKvConformance };