opencode-courier 0.2.2 → 0.3.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 (60) hide show
  1. package/README.md +247 -108
  2. package/dist/bounded.d.ts +8 -0
  3. package/dist/bounded.js +19 -0
  4. package/dist/cleanup.d.ts +21 -21
  5. package/dist/cleanup.js +61 -30
  6. package/dist/courier.d.ts +116 -15
  7. package/dist/courier.js +239 -74
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +300 -0
  11. package/dist/hub.js +343 -0
  12. package/dist/index.d.ts +5 -13
  13. package/dist/index.js +82 -313
  14. package/dist/json.d.ts +8 -0
  15. package/dist/json.js +15 -0
  16. package/dist/later.d.ts +7 -7
  17. package/dist/later.js +8 -7
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +356 -0
  21. package/dist/notices.js +678 -0
  22. package/dist/question/answer.d.ts +35 -0
  23. package/dist/question/answer.js +214 -0
  24. package/dist/question/index.d.ts +6 -0
  25. package/dist/question/index.js +7 -0
  26. package/dist/question/lifecycle.d.ts +43 -0
  27. package/dist/question/lifecycle.js +91 -0
  28. package/dist/question/pure.d.ts +48 -0
  29. package/dist/question/pure.js +74 -0
  30. package/dist/question/relay.d.ts +5 -0
  31. package/dist/question/relay.js +269 -0
  32. package/dist/question/runtime.d.ts +20 -0
  33. package/dist/question/runtime.js +74 -0
  34. package/dist/question/shared.d.ts +24 -0
  35. package/dist/question/shared.js +24 -0
  36. package/dist/relay.d.ts +32 -36
  37. package/dist/relay.js +6 -56
  38. package/dist/report.d.ts +48 -0
  39. package/dist/report.js +88 -0
  40. package/dist/roster.d.ts +48 -8
  41. package/dist/roster.js +140 -17
  42. package/dist/skill.d.ts +13 -0
  43. package/dist/skill.js +9 -0
  44. package/dist/stop.d.ts +41 -0
  45. package/dist/stop.js +120 -0
  46. package/dist/storage.d.ts +8 -1
  47. package/dist/storage.js +8 -5
  48. package/dist/tools.d.ts +94 -0
  49. package/dist/tools.js +297 -0
  50. package/dist/tree.d.ts +102 -0
  51. package/dist/tree.js +58 -0
  52. package/dist/version.d.ts +4 -10
  53. package/dist/version.js +4 -10
  54. package/dist/watch.d.ts +70 -42
  55. package/dist/watch.js +346 -51
  56. package/dist/webhook.d.ts +11 -28
  57. package/dist/webhook.js +46 -146
  58. package/package.json +16 -5
  59. package/dist/question.d.ts +0 -158
  60. package/dist/question.js +0 -768
package/dist/hub.js ADDED
@@ -0,0 +1,343 @@
1
+ import { randomInt } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+ import { deliverReleased } from "./group.js";
4
+ import { num, obj, str } from "./json.js";
5
+ import { deliverDue, TICK_MS } from "./later.js";
6
+ import { processWide } from "./storage.js";
7
+ /**
8
+ * The plugin's process-wide state on `globalThis`, shared by every loaded copy; only this module puts
9
+ * it there. `HUB_VERSION`, the fixed claim keys and version skew: docs/reference.md, § Several copies.
10
+ */
11
+ export const HUB_VERSION = 2;
12
+ /** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
13
+ export const HUB_KEY = "opencode-courier.hub";
14
+ const realTimers = {
15
+ every: (run, ms) => setInterval(run, ms),
16
+ stop: (timer) => clearInterval(timer),
17
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
18
+ };
19
+ /**
20
+ * The storage key, shared by every server on one data directory, naming the server whose scheduler
21
+ * delivers `courier_later` messages: `{ server, at }`, its id and when it last wrote (epoch ms).
22
+ */
23
+ export const OWNER_KEY = "scheduler/owner";
24
+ /** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
25
+ export const OWNER_EXPIRY_MS = 4 * TICK_MS;
26
+ /**
27
+ * How old the owner's own key may be for it to renew the key and deliver at once. An older one,
28
+ * which it may no longer hold by the time it writes, is taken again like a free one, with the wait.
29
+ */
30
+ export const OWNER_RENEW_MS = OWNER_EXPIRY_MS / 2;
31
+ /** The wait between writing the owner key and reading it back, at least `OWNER_WAIT_MS` and up to twice that. */
32
+ export const OWNER_WAIT_MS = 500;
33
+ /**
34
+ * This server's id in the owner key: the same for every copy of the plugin in the process, and
35
+ * different in every other process, a restart of this one included.
36
+ */
37
+ export const SERVER = `${hostname()}:${process.pid}:${performance.timeOrigin}`;
38
+ async function readOwner(ports) {
39
+ const value = obj(await ports.storage.get(OWNER_KEY));
40
+ const server = str(value.server);
41
+ const at = num(value.at);
42
+ return server !== undefined && at !== undefined ? { server, at } : undefined;
43
+ }
44
+ /**
45
+ * How long an unloading instance waits for the work it leaves behind: what it started itself, a tick
46
+ * or a notice under way through it, and on the last leave the owner key's release.
47
+ */
48
+ export const LEAVE_MS = 2_000;
49
+ /** Whether a key written at `at` still holds at `now`; one from the future, after the clock went back, holds as long. */
50
+ const holds = (at, now, ms) => Math.abs(now - at) < ms;
51
+ /** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
52
+ export const WATCHERS = 2;
53
+ /**
54
+ * Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a versioned
55
+ * key when another version holds that; its claim sets are the pre-hub keys', made when missing.
56
+ */
57
+ export function open(registry, timers = realTimers, server = SERVER) {
58
+ const shared = (name, create) => processWide(`opencode-courier.${name}`, create, registry);
59
+ const questionState = () => {
60
+ // Field by field, since a copy before the hub may have made it without the fields added later.
61
+ const state = shared("questions", () => ({}));
62
+ state.questions ??= new Map();
63
+ state.noticed ??= new Set();
64
+ state.shown ??= new Map();
65
+ state.passing ??= new Map();
66
+ state.answered ??= new Set();
67
+ state.loaded ??= new Set();
68
+ state.following ??= 0;
69
+ state.followed ??= false;
70
+ state.shutdowns ??= new Map();
71
+ state.closingWaiters ??= new Set();
72
+ return state;
73
+ };
74
+ const create = () => ({
75
+ version: HUB_VERSION,
76
+ members: new Set(),
77
+ scheduler: {},
78
+ watchers: [],
79
+ claimed: shared("claimed", () => new Set()),
80
+ watched: shared("watched", () => ({ seen: new Set(), waiting: new Set(), answered: new Set() })),
81
+ forms: shared("forms", () => ({ told: new Map(), settled: new Set() })),
82
+ locations: shared("locations", () => new Map()),
83
+ receivers: shared("receiver", () => ({})),
84
+ questions: questionState(),
85
+ skewLogged: false,
86
+ });
87
+ const found = registry[Symbol.for(HUB_KEY)];
88
+ // The version of the hub of another copy found under `HUB_KEY`, if one is there.
89
+ const skew = found != null && found.version !== HUB_VERSION ? { version: found.version } : undefined;
90
+ const hub = processWide(skew ? `${HUB_KEY}@${HUB_VERSION}` : HUB_KEY, create, registry);
91
+ /**
92
+ * Whether this server holds the owner key, taking or renewing it through `ports`. A free, expired
93
+ * or stalled key is written and read back after a random wait: the last of two writers holds it.
94
+ */
95
+ const own = async (ports) => {
96
+ const held = await readOwner(ports);
97
+ const now = ports.now();
98
+ if (held && held.server !== server && holds(held.at, now, OWNER_EXPIRY_MS))
99
+ return false;
100
+ await ports.storage.set(OWNER_KEY, { server, at: now });
101
+ if (fresh(held, now))
102
+ return true;
103
+ await timers.wait(randomInt(OWNER_WAIT_MS, 2 * OWNER_WAIT_MS));
104
+ return (await readOwner(ports))?.server === server;
105
+ };
106
+ /** Whether `held` is this server's key, recent enough to renew without reading it back. */
107
+ const fresh = (held, now) => held?.server === server && holds(held.at, now, OWNER_RENEW_MS);
108
+ /** Renews the owner key if this server holds it fresh: what a tick does, for a tick still delivering. */
109
+ const renew = async (ports) => {
110
+ const now = ports.now();
111
+ if (fresh(await readOwner(ports), now))
112
+ await ports.storage.set(OWNER_KEY, { server, at: now });
113
+ };
114
+ /** Whether a hub of another version in the process has instances, so its loop, under the same server id, still holds the key. */
115
+ const othersRunning = () => Object.getOwnPropertySymbols(registry).some((key) => {
116
+ const other = registry[key];
117
+ const name = key.description ?? "";
118
+ return other !== hub && (name === HUB_KEY || name.startsWith(`${HUB_KEY}@`)) && Number(other?.members?.size) > 0;
119
+ });
120
+ /**
121
+ * Removes the owner key if this server holds it, so another takes over at its next tick: once the
122
+ * loop has stopped, no tick is under way and no other hub version here runs a loop under this id.
123
+ */
124
+ const release = async (ports) => {
125
+ const scheduler = hub.scheduler;
126
+ const idle = () => scheduler.timer === undefined && !scheduler.running && !scheduler.ticking && !othersRunning();
127
+ if (idle() && (await readOwner(ports))?.server === server && idle())
128
+ await ports.storage.remove(OWNER_KEY);
129
+ };
130
+ /**
131
+ * Whether the tick under way still holds the next up: within a minute of its start it does; past
132
+ * that it is taken to hang, holding up only while its member is loaded. `recent` says which.
133
+ */
134
+ const busy = () => {
135
+ const { running, ticking } = hub.scheduler;
136
+ if (!running)
137
+ return { busy: ticking !== undefined && hub.members.has(ticking), recent: false };
138
+ const first = hub.members.values().next().value;
139
+ if (first && holds(running.since, first.later.now(), OWNER_EXPIRY_MS))
140
+ return { busy: true, recent: true };
141
+ return { busy: hub.members.has(running.member), recent: false };
142
+ };
143
+ /** `busy`, renewing the key for a recent tick under way, as each tick falling due meanwhile does. */
144
+ const underWay = () => {
145
+ const state = busy();
146
+ const first = hub.members.values().next().value;
147
+ if (state.recent && first)
148
+ void renew(first.later).catch((error) => first.log(`courier_later scheduler: ${String(error)}`));
149
+ return state.busy;
150
+ };
151
+ // One tick, through the first loaded member, by the server holding the owner key. The claim on each
152
+ // delivery stays, as a second line of defence against a pre-hub copy running its own interval.
153
+ const tick = async () => {
154
+ const scheduler = hub.scheduler;
155
+ if (underWay())
156
+ return;
157
+ scheduler.nudged = false;
158
+ const owner = hub.members.values().next().value;
159
+ if (!owner)
160
+ return;
161
+ let since;
162
+ try {
163
+ since = owner.later.now();
164
+ }
165
+ catch (error) {
166
+ owner.log(`courier_later scheduler: ${String(error)}`);
167
+ return;
168
+ }
169
+ let end = () => { };
170
+ const run = { member: owner, since, done: new Promise((resolve) => (end = resolve)) };
171
+ scheduler.running = run;
172
+ scheduler.ticking = owner;
173
+ // `done` settles however the tick ends, and the key is given back, a log that throws included:
174
+ // leaves wait for it. A log that throws is not logged again; there is nowhere left to tell.
175
+ const tell = (message) => {
176
+ try {
177
+ owner.log(message);
178
+ }
179
+ catch { }
180
+ };
181
+ try {
182
+ if ((await own(owner.later)) && scheduler.timer !== undefined && hub.members.has(owner)) {
183
+ await deliverDue(owner.later, hub.claimed);
184
+ await deliverReleased(owner.later, hub.claimed);
185
+ }
186
+ }
187
+ catch (error) {
188
+ tell(`courier_later scheduler: ${String(error)}`);
189
+ }
190
+ finally {
191
+ // A tick past its minute may have been followed by another, whose record stays.
192
+ if (scheduler.running === run) {
193
+ scheduler.running = undefined;
194
+ scheduler.ticking = undefined;
195
+ }
196
+ }
197
+ // A loop stopped meanwhile, its members gone, gives back the key this tick may have written.
198
+ if (scheduler.timer === undefined)
199
+ await release(owner.later).catch((error) => tell(`courier_later scheduler: owner key not released: ${String(error)}`));
200
+ end();
201
+ if (scheduler.nudged && scheduler.timer !== undefined)
202
+ void scheduler.tick?.();
203
+ };
204
+ // Through the hub's tick, the copy loaded last's, like the loop's; this copy's only before one joined. A
205
+ // tick under way runs once more when it ends, so nothing is started here meanwhile.
206
+ const nudge = () => {
207
+ const scheduler = hub.scheduler;
208
+ if (scheduler.timer === undefined)
209
+ return;
210
+ scheduler.nudged = true;
211
+ if (!busy().busy)
212
+ void (scheduler.tick ?? tick)();
213
+ };
214
+ // Subscriptions through the earliest members without one, the longest loaded, until there are
215
+ // `WATCHERS`; never through `leaving`.
216
+ const watch = (leaving) => {
217
+ const watching = new Set(hub.watchers.map((watcher) => watcher.member));
218
+ for (const member of hub.members) {
219
+ if (hub.watchers.length >= WATCHERS || !hub.subscribe)
220
+ return;
221
+ if (member !== leaving && !watching.has(member))
222
+ hub.watchers.push(hub.subscribe(member));
223
+ }
224
+ };
225
+ // The work each member of this copy started and handed over, until it settles.
226
+ const work = new WeakMap();
227
+ const track = (member, started) => {
228
+ const set = work.get(member) ?? new Set();
229
+ work.set(member, set);
230
+ const settled = started.catch(() => { }).finally(() => set.delete(settled));
231
+ set.add(settled);
232
+ };
233
+ const join = (member) => {
234
+ if (skew && !hub.skewLogged) {
235
+ hub.skewLogged = true;
236
+ member.log(`courier: another copy of the plugin with hub version ${String(skew.version)} has been loaded in this process; ` +
237
+ `this copy (hub version ${HUB_VERSION}) keeps its own under ${HUB_KEY}@${HUB_VERSION} and shares the claim sets with it`);
238
+ }
239
+ hub.members.add(member);
240
+ hub.locations.set(member, member.permission);
241
+ const scheduler = hub.scheduler;
242
+ scheduler.tick = tick;
243
+ if (scheduler.timer === undefined) {
244
+ scheduler.timer = timers.every(() => void hub.scheduler.tick?.(), TICK_MS);
245
+ void tick();
246
+ }
247
+ watch();
248
+ return () => {
249
+ hub.members.delete(member);
250
+ hub.locations.delete(member);
251
+ // What this member leaves behind: the work it handed over, and a tick under way through it,
252
+ // which keeps using its storage and session until it ends.
253
+ const behind = [...(work.get(member) ?? [])];
254
+ if (scheduler.running?.member === member)
255
+ behind.push(scheduler.running.done);
256
+ // The other subscription, already connected, follows the events meanwhile; this one is replaced
257
+ // before it ends, after its current event, so the relay's watcher count never drops to none.
258
+ const index = hub.watchers.findIndex((watcher) => watcher.member === member);
259
+ if (index >= 0) {
260
+ const [leaving] = hub.watchers.splice(index, 1);
261
+ watch(member);
262
+ leaving.stop.abort();
263
+ behind.push(leaving.done);
264
+ }
265
+ if (hub.members.size === 0 && scheduler.timer !== undefined) {
266
+ timers.stop(scheduler.timer);
267
+ scheduler.timer = undefined;
268
+ // After the tick under way, whichever copy started it, which may be writing the key.
269
+ behind.push((async () => {
270
+ await scheduler.running?.done;
271
+ await release(member.later);
272
+ })().catch((error) => member.log(`courier_later scheduler: owner key not released: ${String(error)}`)));
273
+ }
274
+ if (behind.length === 0)
275
+ return Promise.resolve();
276
+ return Promise.race([Promise.allSettled(behind).then(() => { }), timers.wait(LEAVE_MS)]);
277
+ };
278
+ };
279
+ return {
280
+ hub,
281
+ join,
282
+ track,
283
+ gate: shared("spawning", () => ({ reserved: new Set(), turn: Promise.resolve() })),
284
+ inbox: shared("inbox", () => new Map()),
285
+ nudge,
286
+ };
287
+ }
288
+ const opened = open(globalThis);
289
+ /** This copy's hub, in the process. */
290
+ export const hub = opened.hub;
291
+ /** Joins this copy's hub; see `Opened.join`. */
292
+ export const join = opened.join;
293
+ /** Hands this copy's hub work a member started; see `Opened.track`. */
294
+ export const track = opened.track;
295
+ /** The spawns under way in the process; see `Opened.gate`. */
296
+ export const gate = opened.gate;
297
+ /** The types of the items enqueued for spawned sessions; see `Opened.inbox`. */
298
+ export const inbox = opened.inbox;
299
+ /** Has this copy's scheduler deliver what is due now; see `Opened.nudge`. */
300
+ export const nudge = opened.nudge;
301
+ /** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
302
+ export function memberAt(location) {
303
+ let found;
304
+ for (const member of hub.members)
305
+ if (member.location === location)
306
+ found = member;
307
+ return found;
308
+ }
309
+ /**
310
+ * The question ports of the member that just joined at `location`, while it is loaded: resolved once,
311
+ * so a later instance at the same location does not take its place while this one is still loaded.
312
+ */
313
+ export function portsAt(location) {
314
+ const own = memberAt(location);
315
+ return () => (own && hub.members.has(own) ? own.questions : undefined);
316
+ }
317
+ /** The permission domains of the loaded instances. */
318
+ export const permissions = () => hub.locations.values();
319
+ /**
320
+ * For tests: forgets every remembered id, the spawns under way and the question relay's state, loaded
321
+ * instances and watcher count included. Members, locations and the receiver are left to unloading.
322
+ */
323
+ export function resetHub() {
324
+ gate.reserved.clear();
325
+ inbox.clear();
326
+ hub.claimed.clear();
327
+ hub.watched.seen.clear();
328
+ hub.watched.waiting.clear();
329
+ hub.watched.answered.clear();
330
+ hub.forms.told.clear();
331
+ hub.forms.settled.clear();
332
+ const questions = hub.questions;
333
+ questions.questions.clear();
334
+ questions.noticed.clear();
335
+ questions.shown.clear();
336
+ questions.passing.clear();
337
+ questions.answered.clear();
338
+ questions.loaded.clear();
339
+ questions.following = 0;
340
+ questions.followed = false;
341
+ questions.shutdowns.clear();
342
+ questions.closingWaiters.clear();
343
+ }
package/dist/index.d.ts CHANGED
@@ -1,21 +1,13 @@
1
1
  import { Plugin } from "@opencode/plugin";
2
2
  import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
3
- import { type QuestionPorts } from "./question.js";
4
- /** What a question relay needs from the plugin instance that wraps the question tool. */
5
- export interface RelaySlot {
6
- ports?: QuestionPorts;
7
- }
8
3
  /**
9
- * The courier tools, scheduler, webhook receiver and event watcher, as a promise plugin. `relay`
10
- * receives this instance's ports for the question relay while it is loaded.
4
+ * The courier tools and webhook receiver, as a promise plugin joining the hub that runs the scheduler
5
+ * and the watcher; its member carries the location by which the question relay finds its ports.
11
6
  */
12
- export declare const courier: (relay?: RelaySlot) => Plugin.Plugin;
7
+ export declare const courier: () => Plugin.Plugin;
13
8
  /**
14
- * The plugin OpenCode loads. It is an Effect plugin, because the question relay wraps OpenCode's own
15
- * question tool, whose execute the promise API hands out only as a promise that cannot be
16
- * interrupted, so a child's question could not be withdrawn once its parent answered. Everything
17
- * else is the promise plugin above, run through the plugin package's own adapter, as OpenCode runs
18
- * a promise plugin.
9
+ * The plugin OpenCode loads: an Effect plugin, since the question relay wraps OpenCode's question tool
10
+ * and only the Effect API makes its call interruptible; the rest is the promise plugin, adapted.
19
11
  */
20
12
  declare const _default: EffectPlugin.Plugin<import("effect/Scope").Scope>;
21
13
  export default _default;