@matatbread/matbot-plugin-api 0.4.3 → 0.4.4

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.3",
7
+ "version": "0.4.4",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -5,80 +5,218 @@ import { globalSlot } from './global-state.js';
5
5
  /**
6
6
  * A context switch is the machine analogue of an OS one: it pages in any pending machine state and
7
7
  * sets the owner. {@link runAs} is the bare "set the owner" instruction (the principal carrier);
8
- * {@link contextSwitch} is the scheduler operation layered on top — it also lands any machine
9
- * mutation that was deferred while the machine was busy.
8
+ * {@link machineBusy} is the other half — "hold the machine, I am mid-operation" — and
9
+ * {@link contextSwitch} is the two together, for an operation that is both.
10
10
  *
11
11
  * Why deferral exists: a service like the `StorageBackend` is the system of record, and swapping it
12
12
  * under a running operation would split a compare-and-swap (or a turn) across two backends. So a
13
- * swap is queued and applied only at the *quiescent edge* — the instant no context is active — which
14
- * is exactly a context-switch boundary. The principal carrier stays a pure identity primitive; this
15
- * module owns the machine-update half, so the two concerns share call sites without sharing code.
13
+ * swap is queued and applied only at the *quiescent edge* — the instant nothing holds the machine.
14
+ * The principal carrier stays a pure identity primitive; this module owns the machine half.
16
15
  *
17
- * The host registers what to flush ({@link onContextQuiesce}); the three entry points (a web
18
- * request, a telegram message, a turn) switch context through {@link contextSwitch} instead of
19
- * calling {@link runAs} directly. The boot principal (an `enter`, not a `run`) is the depth-0
20
- * baseline and is never counted.
16
+ * **What counts as busy is the operation, not the turn.** The two are not the same thing, and
17
+ * assuming they were is what made this edge wrong: a turn commits its session document, and *then*
18
+ * its pump reads that document back, appends followup markers, rewrites it for a retract, and starts
19
+ * the next queued turn — all of it store work outside the turn, and all of it quiescent under a
20
+ * per-turn hold. A mutation landing in that window splits exactly what the deferral exists to keep
21
+ * whole. So the pump holds the machine for its whole queue and switches principal per item, which is
22
+ * also where accounting already flushes: "the end of a turn" is not a moment anything can be totalled
23
+ * or swapped at, and the queue draining is.
24
+ *
25
+ * The host registers what to flush ({@link onContextQuiesce}). Entry points that receive work but do
26
+ * not perform it — a web request holding an SSE stream open, a telegram message — use {@link runAs}
27
+ * and are deliberately NOT busy: their scope spans a long-lived stream, and counting it would mean
28
+ * the machine never reaches an edge at all. The boot principal (an `enter`, not a `run`) is likewise
29
+ * never counted.
21
30
  */
22
31
 
23
32
  // State-shaped and reachable from a plugin (a storage plugin registering a deferred-swap flusher, or
24
- // switching context), so it lives in the global slot: a duplicated plugin-api copy shares one set of
25
- // quiescers and one depth count rather than splitting them. See ./global-state.ts.
33
+ // holding the machine), so it lives in the global slot: a duplicated plugin-api copy shares one set
34
+ // of quiescers and one hold count rather than splitting them. See ./global-state.ts.
35
+ //
36
+ // `depth` keeps its name across the change of meaning — it counts machine holds now, of which a
37
+ // principal-bearing context switch is one — because the slot is shared by every loaded copy of this
38
+ // module, including published older ones. Both spellings of "busy" increment the same field, so a
39
+ // skewed install still agrees on when the machine is idle; a renamed field would have left an old
40
+ // copy marking a turn busy on `depth` while a new copy read `busy` and called the machine quiet.
26
41
  interface ContextSwitchState {
27
- quiescers: Set<() => void>;
42
+ quiescers: Set<Quiescer>;
28
43
  depth: number;
29
44
  flushing: boolean;
45
+ /** The flush currently settling, when a flusher went async. Held so a second caller joins the one
46
+ * in flight rather than starting a second, and so {@link quiesced} has something to wait on. */
47
+ inflight?: Promise<void> | undefined;
30
48
  }
31
49
  const cs = globalSlot<ContextSwitchState>('context-switch', () => ({
32
- quiescers: new Set<() => void>(),
50
+ quiescers: new Set<Quiescer>(),
33
51
  depth: 0,
34
52
  flushing: false,
35
53
  }));
36
54
 
37
55
  /**
38
- * Register a machine-update flush, run at every quiescent edge (no context active). Flushers MUST be
39
- * idempotent: a no-op when nothing is pending, since the edge is reached after every operation.
40
- * Returns an unregister fn. A throwing flusher is isolated — one must not block the others or escape
41
- * into the operation that triggered the edge.
56
+ * A machine-update flush. Returning a promise makes the edge *wait*: the deferred work is no longer
57
+ * merely started at the edge but finished before the machine is handed to whoever asked to be
58
+ * quiesced first (see {@link quiesced}). That matters for work that reads and rewrites a document the
59
+ * next operation is about to read — a store edge deferred out of a turn lands in the window before
60
+ * the next turn takes its copy, or not at all.
42
61
  */
43
- export function onContextQuiesce(flush: () => void): () => void {
62
+ export type Quiescer = () => void | Promise<void>;
63
+
64
+ /**
65
+ * Register a machine-update flush, run at every quiescent edge (nothing holding the machine).
66
+ * Flushers MUST be idempotent: a no-op when nothing is pending, since the edge is reached after every
67
+ * operation. Returns an unregister fn.
68
+ *
69
+ * What the edge guarantees a flusher:
70
+ *
71
+ * - Flushers are **invoked in registration order**, in one synchronous sweep; one that goes async is
72
+ * started in that order and then left to settle alongside the rest.
73
+ * - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing
74
+ * yields at all, which is what lets the host stage a mutation and land it with a bare
75
+ * `flushIfQuiescent()` — in effect before the call returns.
76
+ * - **No second sweep begins until the current one has completely settled**, and a mutation staged
77
+ * meanwhile is turned away rather than applied.
78
+ * - A throwing flusher is isolated, and so is a rejecting one — neither may deny the others their
79
+ * completion, nor escape into the operation whose release happened to reach the edge.
80
+ *
81
+ * **It does not give exclusivity, and could not.** A synchronous flusher has it for free — the runtime
82
+ * is single-threaded — and that is the argument for keeping a flusher synchronous whenever the work
83
+ * allows. The moment one awaits, the window is open: `depth` is 0 while a flush settles, so an HTTP
84
+ * endpoint can accept a request and run a tool call to completion inside a single `await`, and only an
85
+ * operation that asked to be {@link quiesced} first (the pump) is held back. Flushers may therefore
86
+ * overlap each other too, and sequencing them would buy nothing — it would remove one source of
87
+ * concurrent mutation while leaving every other in place, at the price of every flusher waiting on the
88
+ * slowest.
89
+ *
90
+ * So contention over a service is the *service's* to resolve, not the sweep's: a `Store` answers it
91
+ * with compare-and-swap, and a subsystem with a stronger requirement owns a stronger answer. The
92
+ * sweep's job is to make contention rare, not to pretend the machine stops.
93
+ *
94
+ * **Known gap, not caused here.** Compare-and-swap answers "did this document change?", not "did the
95
+ * medium change?" — a read from one `StorageBackend` and a write to another compares a version from
96
+ * the first against the second, and a session migrates with nothing in a position to notice. Staging
97
+ * a swap rather than applying it while a flush settles narrows that window, but it is a mitigation
98
+ * in the wrong place: an HTTP tool call has always been able to straddle a swap the same way, so the
99
+ * exposure predates asynchronous flushers and is merely easier to reach with them. The fix belongs
100
+ * where the consequence lands — a `cas` that checks the backend it is writing to is the one it read
101
+ * from, and fails when it is not.
102
+ *
103
+ * The keys that repoint *immediately* — `Vault`, `KnowledgeIndex`, `Notifier`, anything a plugin
104
+ * registers — carry no protection here or anywhere: they change under a running turn too, by the
105
+ * design decision that only the system of record is worth deferring. A flusher that depends on one
106
+ * should resolve it once rather than either side of an await, exactly as a turn should.
107
+ */
108
+ export function onContextQuiesce(flush: Quiescer): () => void {
44
109
  cs.quiescers.add(flush);
45
110
  return () => { cs.quiescers.delete(flush); };
46
111
  }
47
112
 
48
113
  /**
49
- * Run the registered flushers iff no context is currently active — the quiescent edge. Called by
50
- * {@link contextSwitch} at both edges, and by the host right after it *queues* a deferred mutation:
51
- * queued while busy (depth > 0) this no-ops and the exit edge lands it; queued while idle (depth 0)
114
+ * Run the registered flushers iff nothing holds the machine — the quiescent edge. Called by
115
+ * {@link machineBusy} at both edges, and by the host right after it *queues* a deferred mutation:
116
+ * queued while busy (depth > 0) this no-ops and the release edge lands it; queued while idle (depth 0)
52
117
  * it applies immediately, so an idle-time swap doesn't wait for the next request to take effect.
118
+ *
119
+ * Returns the settling promise when some flusher went async, and `undefined` when the edge is already
120
+ * complete — the caller decides whether it can afford to wait ({@link quiesced} does; a synchronous
121
+ * caller cannot). Re-entering while one is settling joins it rather than starting a second: `flushing`
122
+ * stays raised for the whole asynchronous extent, not just the synchronous sweep.
53
123
  */
54
- export function flushIfQuiescent(): void {
55
- if (cs.depth !== 0 || cs.flushing) return;
124
+ export function flushIfQuiescent(): Promise<void> | undefined {
125
+ if (cs.depth !== 0 || cs.flushing) return cs.inflight;
56
126
  cs.flushing = true;
57
- try {
58
- for (const q of cs.quiescers) {
59
- try { q(); }
60
- catch (e) { console.error('[matbot] context-quiesce flush threw:', e instanceof Error ? e.message : e); }
61
- }
62
- } finally {
127
+
128
+ // Snapshot: a one-shot flusher unregisters itself as it fires, and a flusher may register another.
129
+ // Iterating a copy makes both safe and settles what a mid-sweep registration means — it was not
130
+ // registered when this edge began, so it runs at the next one.
131
+ const settling = [...cs.quiescers]
132
+ .map(q => {
133
+ // A synchronous throw is the flusher's own business, never that of the operation whose release
134
+ // happened to reach the edge. A rejection is handled below, by allSettled.
135
+ try { return q() ?? undefined; }
136
+ catch (e) { console.error('[matbot] context-quiesce flush threw:', e instanceof Error ? e.message : e); return undefined; }
137
+ })
138
+ .filter((p): p is Promise<void> => p !== undefined);
139
+
140
+ if (settling.length === 0) {
141
+ // Every flusher was synchronous, so the edge is complete on return — which is what lets the host
142
+ // stage a mutation and land it with a bare `flushIfQuiescent()`, in effect before the call returns.
63
143
  cs.flushing = false;
144
+ cs.inflight = undefined;
145
+ return undefined;
64
146
  }
147
+
148
+ // allSettled, not all: one flusher's rejection must not deny the others their completion, and the
149
+ // aggregate must never reject — the release below depends on it, and there is nobody to catch it.
150
+ const all = Promise.allSettled(settling).then(results => {
151
+ for (const r of results) {
152
+ if (r.status === 'rejected') console.error('[matbot] context-quiesce flush rejected:', r.reason instanceof Error ? r.reason.message : r.reason);
153
+ }
154
+ cs.flushing = false;
155
+ cs.inflight = undefined;
156
+ });
157
+ cs.inflight = all;
158
+ return all;
65
159
  }
66
160
 
67
161
  /**
68
- * Switch into the machine as `principal`: land any pending machine mutation (only safe while idle),
69
- * then run `fn` under the principal. Mirrors {@link runAs}'s sync/async return — when `fn` is async,
70
- * the context stays open until its promise settles, so the depth count tracks the real operation.
162
+ * Wait until the edge is not merely reached but *complete*: reach it, and let any deferred work it
163
+ * starts (or finds already settling) finish. An operation that must not overlap deferred work — a turn
164
+ * about to take its copy of a session that a previous turn's deferred edit is rewriting — awaits this
165
+ * before holding the machine.
166
+ *
167
+ * **One pass, deliberately not a loop.** An `async` flusher returns a promise whether or not it had
168
+ * anything to do — "nothing pending" is unobservable from out here, since an immediately-resolved
169
+ * promise and a real unit of work are the same object to us. A loop that waited for a pass to produce
170
+ * no promise would therefore never terminate against an idempotent async flusher, and neither would a
171
+ * re-run scheduled after each settle: each pass would manufacture the evidence for the next. So work
172
+ * staged *during* a flush lands at the following edge — the next `machineBusy` release, or the next
173
+ * caller through here — which is all the mount contract has ever promised: eventual and ordered,
174
+ * never timed.
71
175
  */
72
- export function contextSwitch<T>(principal: Principal, fn: () => T): T {
176
+ export async function quiesced(): Promise<void> {
177
+ await flushIfQuiescent();
178
+ }
179
+
180
+ /**
181
+ * Hold the machine for the extent of `fn`: land any pending mutation first (only safe while idle),
182
+ * then keep the machine off-limits to further ones until `fn` is done. Mirrors {@link runAs}'s
183
+ * sync/async return — when `fn` is async the hold stays up until its promise settles, so the count
184
+ * tracks the real operation rather than the synchronous call.
185
+ *
186
+ * The hold is released on **every** exit — a synchronous throw, a rejected promise, a bare `return`
187
+ * from anywhere inside — because the release is bound to the call, not written at the exits. That is
188
+ * the whole reason this is a wrapper and not a `begin()`/`end()` pair: a caller with a dozen early
189
+ * returns (the pump has several per queue item) cannot leave the counter stuck, and a stuck counter
190
+ * is unrecoverable — every later flush would silently no-op forever, and the symptom would be a
191
+ * deferred mutation that simply never happens.
192
+ */
193
+ export function machineBusy<T>(fn: () => T): T {
73
194
  flushIfQuiescent();
74
195
  cs.depth++;
75
- const settle = (): void => { cs.depth--; flushIfQuiescent(); };
196
+ let settled = false;
197
+ const settle = (): void => {
198
+ if (settled) return; // the sync-throw path settles before rethrowing; never settle twice
199
+ settled = true;
200
+ cs.depth--;
201
+ flushIfQuiescent();
202
+ };
76
203
  let result: T;
77
204
  try {
78
- result = runAs(principal, fn);
205
+ result = fn();
79
206
  } catch (e) {
80
207
  settle();
81
208
  throw e;
82
209
  }
83
210
  return result instanceof Promise ? (result.finally(settle) as T) : (settle(), result);
84
211
  }
212
+
213
+ /**
214
+ * Switch into the machine as `principal`: hold it for the extent of `fn` ({@link machineBusy}) and
215
+ * run `fn` under the principal ({@link runAs}). The composition, for an operation that both owns the
216
+ * machine and has an owner — a single-turn request standing on its own. An operation that decomposes
217
+ * into differently-owned units (the pump: one queue, a principal per item) holds the machine once
218
+ * around the whole of it and calls `runAs` per unit instead.
219
+ */
220
+ export function contextSwitch<T>(principal: Principal, fn: () => T): T {
221
+ return machineBusy(() => runAs(principal, fn));
222
+ }
package/src/plugin.ts CHANGED
@@ -361,6 +361,27 @@ export interface StorageBackend {
361
361
  createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
362
362
  readonly fileStore: FileStore;
363
363
  close?(): Promise<void>;
364
+ /**
365
+ * The namespaces this backend currently holds documents for — the `createStore` arguments that
366
+ * would reach real data. `createStore` is addressed BY name, so without this a caller can only read
367
+ * a namespace it already knows about; enumerating is what makes a backend traversable (copying one
368
+ * backend into another, auditing what is stored, reporting on a `.data` directory).
369
+ *
370
+ * **Optional because absence is a type.** A backend addressing a medium with no listing operation
371
+ * cannot answer, and must not guess: a caller that needs a complete list degrades to being told the
372
+ * namespaces explicitly rather than silently working from a partial one. Do not implement this by
373
+ * returning "the namespaces `createStore` happened to be called with this session" — that is a lower
374
+ * bound masquerading as an answer, and a traversal built on it silently skips whatever no plugin
375
+ * touched. If the medium cannot be listed, leave it off, or throw with a reason.
376
+ *
377
+ * Excludes the file store: files are a separate axis with their own enumeration
378
+ * ({@link FileStore.list}), not a namespace among the document stores.
379
+ *
380
+ * A namespace holding no documents may be omitted — "empty" and "absent" are not usefully different
381
+ * to a caller that is about to read it. Implementations return the list sorted, so a diff of two
382
+ * backends is stable.
383
+ */
384
+ namespaces?(): Promise<string[]>;
364
385
  }
365
386
 
366
387
  /**