@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 +1 -1
- package/src/context-switch.ts +173 -35
- package/src/plugin.ts +21 -0
package/package.json
CHANGED
package/src/context-switch.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
//
|
|
25
|
-
// quiescers and one
|
|
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<
|
|
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<
|
|
50
|
+
quiescers: new Set<Quiescer>(),
|
|
33
51
|
depth: 0,
|
|
34
52
|
flushing: false,
|
|
35
53
|
}));
|
|
36
54
|
|
|
37
55
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
|
50
|
-
* {@link
|
|
51
|
-
* queued while busy (depth > 0) this no-ops and the
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
/**
|