opencode-courier 0.2.2 → 0.2.3
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 +130 -95
- package/dist/bounded.d.ts +10 -0
- package/dist/bounded.js +21 -0
- package/dist/cleanup.d.ts +13 -18
- package/dist/cleanup.js +38 -19
- package/dist/courier.d.ts +1 -5
- package/dist/courier.js +1 -20
- package/dist/hub.d.ts +325 -0
- package/dist/hub.js +340 -0
- package/dist/index.d.ts +4 -8
- package/dist/index.js +70 -308
- package/dist/json.d.ts +4 -0
- package/dist/json.js +4 -0
- package/dist/later.d.ts +1 -1
- package/dist/later.js +2 -2
- package/dist/notices.d.ts +246 -0
- package/dist/notices.js +399 -0
- package/dist/question/answer.d.ts +35 -0
- package/dist/question/answer.js +217 -0
- package/dist/question/index.d.ts +6 -0
- package/dist/question/index.js +7 -0
- package/dist/question/lifecycle.d.ts +52 -0
- package/dist/question/lifecycle.js +100 -0
- package/dist/question/pure.d.ts +49 -0
- package/dist/question/pure.js +75 -0
- package/dist/question/relay.d.ts +5 -0
- package/dist/question/relay.js +283 -0
- package/dist/question/runtime.d.ts +21 -0
- package/dist/question/runtime.js +75 -0
- package/dist/question/shared.d.ts +24 -0
- package/dist/question/shared.js +24 -0
- package/dist/relay.d.ts +31 -33
- package/dist/relay.js +5 -53
- package/dist/roster.d.ts +29 -1
- package/dist/roster.js +101 -9
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +8 -5
- package/dist/tools.d.ts +74 -0
- package/dist/tools.js +228 -0
- package/dist/watch.d.ts +28 -37
- package/dist/watch.js +148 -38
- package/dist/webhook.d.ts +1 -13
- package/dist/webhook.js +36 -127
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -768
package/dist/hub.d.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import type { Server } from "node:http";
|
|
3
|
+
import { type LaterPorts } from "./later.js";
|
|
4
|
+
import type { Asked, Outcome, Withdrawal } from "./notices.js";
|
|
5
|
+
import type { Permissions, Waiting } from "./relay.js";
|
|
6
|
+
import type { RosterStorage } from "./roster.js";
|
|
7
|
+
import { type Registry } from "./storage.js";
|
|
8
|
+
import type { WebhookConfig, WebhookPorts } from "./webhook.js";
|
|
9
|
+
type Context = Plugin.Context;
|
|
10
|
+
/**
|
|
11
|
+
* The plugin's process-wide state, in one place. OpenCode sets the plugin up once per location
|
|
12
|
+
* (project or worktree), all in one process, and after an update loads the new copy of the package
|
|
13
|
+
* next to the old one until the old one unloads; so the state every instance shares lives on
|
|
14
|
+
* `globalThis`, outside the module graph, and this is the only module that puts it there.
|
|
15
|
+
*
|
|
16
|
+
* The hub is versioned by `HUB_VERSION`, not by the package's version: copies whose hub has the
|
|
17
|
+
* same shape share one hub, however far apart their releases, and only a change to the hub's shape
|
|
18
|
+
* (the ports a `Member` carries included) or to the meaning of one of its fields bumps it. An
|
|
19
|
+
* optional field that a copy without it gets along without, such as `Member.location` or
|
|
20
|
+
* `Scheduler.running`, is added without a bump while no release has the hub (the latest is 0.2.2).
|
|
21
|
+
*
|
|
22
|
+
* Version skew: a copy that finds a hub of another version under `opencode-courier.hub` logs that
|
|
23
|
+
* once and runs its own hub under `opencode-courier.hub@<version>`. Whatever its version, a hub
|
|
24
|
+
* takes the claim sets and the other shared objects of the copies before the hub from their own
|
|
25
|
+
* keys (`opencode-courier.claimed`, `.watched`, `.forms`, `.questions`, `.receiver`, `.locations`),
|
|
26
|
+
* making each when it is missing, so every copy in the process, with a hub or without, claims an
|
|
27
|
+
* event or a delivery in the same set, and an old and a new copy never both act on it. Those keys'
|
|
28
|
+
* shapes and meanings are therefore fixed: a hub version that needs other ones makes new keys, and
|
|
29
|
+
* keeps claiming in the old ones too while a copy that uses them may still be loaded. `members` is
|
|
30
|
+
* the one thing each version keeps to itself, so a job a hub runs once for its members runs once
|
|
31
|
+
* per hub version, and still claims what it acts on in the shared sets, which keep it from acting
|
|
32
|
+
* twice.
|
|
33
|
+
*/
|
|
34
|
+
export declare const HUB_VERSION = 1;
|
|
35
|
+
/** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
|
|
36
|
+
export declare const HUB_KEY = "opencode-courier.hub";
|
|
37
|
+
/**
|
|
38
|
+
* A question this process knows: one whose call waits (`call`), or one whose call was cut off,
|
|
39
|
+
* which is answered by message. `link` withdraws the top session's question call that passes its
|
|
40
|
+
* answer on, once the question is settled without that call.
|
|
41
|
+
*/
|
|
42
|
+
export interface Question extends Asked {
|
|
43
|
+
/** The directory of the location the call runs in, whose shutdown withdraws its form. */
|
|
44
|
+
directory?: string;
|
|
45
|
+
/** Hands the waiting call the top session's outcome; true once the call has ended with it. */
|
|
46
|
+
call?: (outcome: Outcome) => Promise<boolean>;
|
|
47
|
+
link?: (how: Withdrawal) => void;
|
|
48
|
+
/** Storing the question and telling the top session, while that is under way. */
|
|
49
|
+
relaying?: Promise<boolean>;
|
|
50
|
+
/** Working out what became of it once its call ended without the top session's answer. */
|
|
51
|
+
settling?: Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The relay's timings, carried by its ports: how long a question cut off by a closing location
|
|
55
|
+
* waits before an instance still loaded tells its top session, how long an answer waits for another
|
|
56
|
+
* one to it that is still being passed on, how long what follows a call's end waits for the notice
|
|
57
|
+
* of the question to go out, and how long a dismissal is held to see whether the location is
|
|
58
|
+
* shutting down.
|
|
59
|
+
*/
|
|
60
|
+
export interface QuestionTiming {
|
|
61
|
+
readonly closingGraceMs: number;
|
|
62
|
+
readonly passingWaitMs: number;
|
|
63
|
+
readonly relayWaitMs: number;
|
|
64
|
+
readonly dismissalGraceMs: number;
|
|
65
|
+
}
|
|
66
|
+
export interface QuestionPorts {
|
|
67
|
+
readonly storage: RosterStorage;
|
|
68
|
+
readonly session: Pick<Context["session"], "synthetic">;
|
|
69
|
+
/** The directory of the instance's location. */
|
|
70
|
+
readonly directory: string;
|
|
71
|
+
/** The clock, in epoch milliseconds, as OpenCode's location shutdowns are recorded. */
|
|
72
|
+
readonly now: () => number;
|
|
73
|
+
readonly timing: QuestionTiming;
|
|
74
|
+
readonly newID: () => string;
|
|
75
|
+
readonly log: (message: string) => void;
|
|
76
|
+
}
|
|
77
|
+
export interface WatchPorts {
|
|
78
|
+
readonly storage: RosterStorage;
|
|
79
|
+
readonly session: Pick<Context["session"], "synthetic">;
|
|
80
|
+
readonly event: Pick<Context["event"], "subscribe">;
|
|
81
|
+
/**
|
|
82
|
+
* The permission domains of every loaded location, whose pending requests are relayed when the
|
|
83
|
+
* watcher (re)subscribes: one watcher serves them all, and an isolated child's are in its worktree's.
|
|
84
|
+
*/
|
|
85
|
+
readonly permissions: () => Iterable<Pick<Context["permission"], "list">>;
|
|
86
|
+
/** The clock a location shutdown is recorded by: the question relay's, which judges it. */
|
|
87
|
+
readonly now: () => number;
|
|
88
|
+
readonly log: (message: string) => void;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What every plugin instance in the process shares. A hub follows OpenCode's events through two of
|
|
92
|
+
* its members, one a standby, and a copy of another hub version, or one from before the hub, follows
|
|
93
|
+
* them too, all sent the same events; so an event id is claimed synchronously and handled once.
|
|
94
|
+
* `waiting` holds the permission requests a session was told about and has not answered;
|
|
95
|
+
* `answered`, requests answered before anyone was told, so a notice whose roster lookup was
|
|
96
|
+
* overtaken by the answer is not sent. `forms` does the same for the forms of spawned sessions.
|
|
97
|
+
*/
|
|
98
|
+
export interface WatchState {
|
|
99
|
+
readonly seen: Set<string>;
|
|
100
|
+
readonly waiting: Waiting;
|
|
101
|
+
readonly answered: Set<string>;
|
|
102
|
+
readonly forms: FormsTold;
|
|
103
|
+
}
|
|
104
|
+
/** The forms sessions were told about, kept apart from the permission requests. */
|
|
105
|
+
export interface FormsTold {
|
|
106
|
+
/** Forms still shown whose notice went out, or is going out: the notice's delivery. */
|
|
107
|
+
readonly told: Map<string, Promise<unknown>>;
|
|
108
|
+
/** Forms settled before anyone was told, so a notice whose roster lookup was overtaken is not sent. */
|
|
109
|
+
readonly settled: Set<string>;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The webhook receiver, one per process: every instance configured with a `webhook` option joins
|
|
113
|
+
* `instances`, the first one starts the server, and the last one to unload stops it.
|
|
114
|
+
*/
|
|
115
|
+
export interface Receiver {
|
|
116
|
+
readonly config: WebhookConfig;
|
|
117
|
+
readonly instances: Set<WebhookPorts>;
|
|
118
|
+
readonly server: Promise<Server | undefined>;
|
|
119
|
+
}
|
|
120
|
+
/** The current receiver, if any, and the previous one while it closes, which a new one waits for. */
|
|
121
|
+
export interface Receivers {
|
|
122
|
+
current?: Receiver;
|
|
123
|
+
closing?: Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The question relay's state, shared by every instance, since a child and the session it asks can
|
|
127
|
+
* be in different locations: the questions told to a top session and not yet settled, the cut-off
|
|
128
|
+
* ones whose top session has been told so, and the question calls waiting for their form to be shown.
|
|
129
|
+
*/
|
|
130
|
+
export interface QuestionState {
|
|
131
|
+
readonly questions: Map<string, Question>;
|
|
132
|
+
readonly noticed: Set<string>;
|
|
133
|
+
readonly shown: Map<string, () => void>;
|
|
134
|
+
/** The questions whose answer is being passed on, each until that is done, and those whose answer went out. */
|
|
135
|
+
readonly passing: Map<string, Promise<unknown>>;
|
|
136
|
+
readonly answered: Set<string>;
|
|
137
|
+
/**
|
|
138
|
+
* The ports of the loaded instances, of every copy of the plugin. A release before the hub picks
|
|
139
|
+
* one here to tell a cut-off question through, so every copy keeps it filled; this copy reads its
|
|
140
|
+
* hub's members, and this only for an instance of another copy, when it has none.
|
|
141
|
+
*/
|
|
142
|
+
readonly loaded: Set<QuestionPorts>;
|
|
143
|
+
/** How many instances follow OpenCode's events now, and whether any has since the process started. */
|
|
144
|
+
following: number;
|
|
145
|
+
followed: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* When OpenCode last reported each location shutting down (`location.shutdown`), by directory
|
|
148
|
+
* in epoch milliseconds by the clock of the watcher that saw it; under `""` when the event named no location.
|
|
149
|
+
*/
|
|
150
|
+
readonly shutdowns: Map<string, number>;
|
|
151
|
+
/** Woken when a location shuts down or an instance unloads: dismissals held to see whether one follows. */
|
|
152
|
+
readonly closingWaiters: Set<() => void>;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A loaded plugin instance: its location's directory and permission domain, and the ports a job
|
|
156
|
+
* run once for the whole process would run it through.
|
|
157
|
+
*/
|
|
158
|
+
export interface Member {
|
|
159
|
+
readonly directory: string;
|
|
160
|
+
/**
|
|
161
|
+
* The location OpenCode set the instance up with, the very object the instance's Effect half is
|
|
162
|
+
* handed too, which finds its member by it (`portsAt`). A member of a copy before it has none.
|
|
163
|
+
*/
|
|
164
|
+
readonly location?: object;
|
|
165
|
+
readonly permission: Permissions;
|
|
166
|
+
readonly later: LaterPorts;
|
|
167
|
+
readonly watch: WatchPorts;
|
|
168
|
+
readonly questions: QuestionPorts;
|
|
169
|
+
readonly log: (message: string) => void;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A tick under way: the member it runs through, when it started, by the wall clock every member's
|
|
173
|
+
* `now` reads (the next ticks compare it with another member's), and its end.
|
|
174
|
+
*/
|
|
175
|
+
export interface Tick {
|
|
176
|
+
readonly member: Member;
|
|
177
|
+
readonly since: number;
|
|
178
|
+
/** Settles once the tick has ended, the owner key released if its loop stopped meanwhile; never rejects. */
|
|
179
|
+
readonly done: Promise<void>;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The scheduler's loop, one per hub: it runs while the hub has members, and each tick delivers the
|
|
183
|
+
* due `courier_later` messages through the ports of the member that joined first and is still loaded.
|
|
184
|
+
*/
|
|
185
|
+
export interface Scheduler {
|
|
186
|
+
/** The loop's interval, while it runs. */
|
|
187
|
+
timer?: unknown;
|
|
188
|
+
/**
|
|
189
|
+
* The tick under way, if any, whichever copy of the plugin started it and whether or not its
|
|
190
|
+
* member is still loaded. A tick that falls due meanwhile renews the owner key for it, for up to a
|
|
191
|
+
* minute from its start, and is skipped; past that minute one whose member has left holds up
|
|
192
|
+
* nothing, and the next tick starts. Optional, since a copy of the same hub version made before it
|
|
193
|
+
* existed keeps only `ticking`.
|
|
194
|
+
*/
|
|
195
|
+
running?: Tick;
|
|
196
|
+
/**
|
|
197
|
+
* The member the tick under way runs through, if any, set and cleared with `running`, for a copy
|
|
198
|
+
* that reads only this: a tick that falls due meanwhile is skipped, unless that member has left.
|
|
199
|
+
*/
|
|
200
|
+
ticking?: Member;
|
|
201
|
+
/** One tick, by the copy of the plugin that joined last, so a copy loaded after an update runs its own code. */
|
|
202
|
+
tick?: () => Promise<void>;
|
|
203
|
+
}
|
|
204
|
+
/** How the hub starts and stops the scheduler's loop, and waits before re-reading the owner key; tests pass their own. */
|
|
205
|
+
export interface Timers {
|
|
206
|
+
readonly every: (run: () => void, ms: number) => unknown;
|
|
207
|
+
readonly stop: (timer: unknown) => void;
|
|
208
|
+
readonly wait: (ms: number) => Promise<void>;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The key in the plugin's storage, shared by every OpenCode server on one data directory, naming
|
|
212
|
+
* the server whose scheduler delivers the `courier_later` messages: `{ server, at }`, its id and
|
|
213
|
+
* when it last wrote the key, in epoch milliseconds. Outside `later/`, which the schedulers scan
|
|
214
|
+
* for messages, those of copies before the key included.
|
|
215
|
+
*/
|
|
216
|
+
export declare const OWNER_KEY = "scheduler/owner";
|
|
217
|
+
/** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
|
|
218
|
+
export declare const OWNER_EXPIRY_MS: number;
|
|
219
|
+
/**
|
|
220
|
+
* How old the owner's own key may be for it to renew the key and deliver at once. An older one,
|
|
221
|
+
* which it may no longer hold by the time it writes, is taken again like a free one, with the wait.
|
|
222
|
+
*/
|
|
223
|
+
export declare const OWNER_RENEW_MS: number;
|
|
224
|
+
/** The wait between writing the owner key and reading it back, at least `OWNER_WAIT_MS` and up to twice that. */
|
|
225
|
+
export declare const OWNER_WAIT_MS = 500;
|
|
226
|
+
/**
|
|
227
|
+
* This server's id in the owner key: the same for every copy of the plugin in the process, and
|
|
228
|
+
* different in every other process, a restart of this one included.
|
|
229
|
+
*/
|
|
230
|
+
export declare const SERVER: string;
|
|
231
|
+
/**
|
|
232
|
+
* How long an unloading instance waits, before it lets go, for the work it leaves behind: what it
|
|
233
|
+
* started itself, a tick or an event's notice still under way through it, and on the last leave the
|
|
234
|
+
* owner key's release.
|
|
235
|
+
*/
|
|
236
|
+
export declare const LEAVE_MS = 2000;
|
|
237
|
+
/** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
|
|
238
|
+
export declare const WATCHERS = 2;
|
|
239
|
+
/** One of the hub's subscriptions to OpenCode's events: the member whose ports it runs through, and how it ends. */
|
|
240
|
+
export interface Watcher {
|
|
241
|
+
readonly member: Member;
|
|
242
|
+
readonly stop: AbortController;
|
|
243
|
+
/** Settles once the subscription has ended, after `stop`: after the event it is handling, not the stream's close. */
|
|
244
|
+
readonly done: Promise<void>;
|
|
245
|
+
}
|
|
246
|
+
export interface Hub {
|
|
247
|
+
readonly version: typeof HUB_VERSION;
|
|
248
|
+
/** The instances loaded now, in the order they joined. */
|
|
249
|
+
readonly members: Set<Member>;
|
|
250
|
+
readonly scheduler: Scheduler;
|
|
251
|
+
/**
|
|
252
|
+
* The hub's subscriptions to OpenCode's events, each through another member: two while two members
|
|
253
|
+
* are loaded, so one is always connected when the other's member leaves.
|
|
254
|
+
*/
|
|
255
|
+
readonly watchers: Watcher[];
|
|
256
|
+
/**
|
|
257
|
+
* Starts a subscription through a member, by the copy of the plugin that set it last; until one
|
|
258
|
+
* has, the hub follows no events.
|
|
259
|
+
*/
|
|
260
|
+
subscribe?: (member: Member) => Watcher;
|
|
261
|
+
/** The ids of the `courier_later` messages being delivered. */
|
|
262
|
+
readonly claimed: Set<string>;
|
|
263
|
+
/** The events handled, and the permission requests a session was told about or that were answered first. */
|
|
264
|
+
readonly watched: Omit<WatchState, "forms">;
|
|
265
|
+
/** The forms sessions were told about, and those settled first. */
|
|
266
|
+
readonly forms: FormsTold;
|
|
267
|
+
/**
|
|
268
|
+
* The permission domain of every loaded instance, by instance. OpenCode keeps a request where its
|
|
269
|
+
* session runs, so a request of an isolated child is answered through the instance loaded in its worktree.
|
|
270
|
+
*/
|
|
271
|
+
readonly locations: Map<object, Permissions>;
|
|
272
|
+
readonly receivers: Receivers;
|
|
273
|
+
readonly questions: QuestionState;
|
|
274
|
+
/** Whether the hub of another version found under `HUB_KEY` has been logged. */
|
|
275
|
+
skewLogged: boolean;
|
|
276
|
+
}
|
|
277
|
+
/** A hub, as `open` finds or makes it, and how instances join it. */
|
|
278
|
+
export interface Opened {
|
|
279
|
+
readonly hub: Hub;
|
|
280
|
+
/**
|
|
281
|
+
* Adds a loaded instance to the hub and its permission domain to `locations`; returns its leave,
|
|
282
|
+
* which takes both out again. The first instance to join logs a hub of another version found
|
|
283
|
+
* under `HUB_KEY`. An instance joining a hub without members starts the scheduler's loop, with a
|
|
284
|
+
* tick at once; the last one to leave stops it. An instance joining while the hub has fewer than
|
|
285
|
+
* `WATCHERS` subscriptions to OpenCode's events starts one through itself; when one leaves that
|
|
286
|
+
* has one, another member without one starts its replacement before it ends. The last leave
|
|
287
|
+
* settles once the scheduler's owner key is released, after the tick under way, whichever copy
|
|
288
|
+
* started it. Every leave first waits for the work its member leaves behind: what was handed to
|
|
289
|
+
* `track`, a tick under way through it, and the subscription it ran, which ends after the event
|
|
290
|
+
* it is handling; all of it, the release included, for up to `LEAVE_MS`. The question relay's
|
|
291
|
+
* own work is not waited for: it outlives the instance on purpose.
|
|
292
|
+
*/
|
|
293
|
+
readonly join: (member: Member) => () => Promise<void>;
|
|
294
|
+
/** Hands the hub work a member started, which its leave waits for; the member's own background work. */
|
|
295
|
+
readonly track: (member: Member, work: Promise<unknown>) => void;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a
|
|
299
|
+
* versioned key when a hub of another version holds that. Either way, its claim sets and shared
|
|
300
|
+
* objects are the ones under the keys of the copies before the hub, made when missing.
|
|
301
|
+
*/
|
|
302
|
+
export declare function open(registry: Registry, timers?: Timers, server?: string): Opened;
|
|
303
|
+
/** This copy's hub, in the process. */
|
|
304
|
+
export declare const hub: Hub;
|
|
305
|
+
/** Joins this copy's hub; see `Opened.join`. */
|
|
306
|
+
export declare const join: (member: Member) => () => Promise<void>;
|
|
307
|
+
/** Hands this copy's hub work a member started; see `Opened.track`. */
|
|
308
|
+
export declare const track: (member: Member, work: Promise<unknown>) => void;
|
|
309
|
+
/** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
|
|
310
|
+
export declare function memberAt(location: object): Member | undefined;
|
|
311
|
+
/**
|
|
312
|
+
* The question ports of the member that has just joined at `location`, the newest there, for as long
|
|
313
|
+
* as it stays loaded: resolved once, so an instance joining later at the same location, while this
|
|
314
|
+
* one is still loaded, does not take its place.
|
|
315
|
+
*/
|
|
316
|
+
export declare function portsAt(location: object): () => QuestionPorts | undefined;
|
|
317
|
+
/** The permission domains of the loaded instances. */
|
|
318
|
+
export declare const permissions: () => MapIterator<Permissions>;
|
|
319
|
+
/**
|
|
320
|
+
* For tests: forgets every remembered id and the question relay's state, the relay's loaded
|
|
321
|
+
* instances and its count of watchers included. The hub's members, the locations and the webhook
|
|
322
|
+
* receiver are left alone; leaving and unloading take them out.
|
|
323
|
+
*/
|
|
324
|
+
export declare function resetHub(): void;
|
|
325
|
+
export {};
|
package/dist/hub.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { num, obj, str } from "./json.js";
|
|
4
|
+
import { deliverDue, TICK_MS } from "./later.js";
|
|
5
|
+
import { processWide } from "./storage.js";
|
|
6
|
+
/**
|
|
7
|
+
* The plugin's process-wide state, in one place. OpenCode sets the plugin up once per location
|
|
8
|
+
* (project or worktree), all in one process, and after an update loads the new copy of the package
|
|
9
|
+
* next to the old one until the old one unloads; so the state every instance shares lives on
|
|
10
|
+
* `globalThis`, outside the module graph, and this is the only module that puts it there.
|
|
11
|
+
*
|
|
12
|
+
* The hub is versioned by `HUB_VERSION`, not by the package's version: copies whose hub has the
|
|
13
|
+
* same shape share one hub, however far apart their releases, and only a change to the hub's shape
|
|
14
|
+
* (the ports a `Member` carries included) or to the meaning of one of its fields bumps it. An
|
|
15
|
+
* optional field that a copy without it gets along without, such as `Member.location` or
|
|
16
|
+
* `Scheduler.running`, is added without a bump while no release has the hub (the latest is 0.2.2).
|
|
17
|
+
*
|
|
18
|
+
* Version skew: a copy that finds a hub of another version under `opencode-courier.hub` logs that
|
|
19
|
+
* once and runs its own hub under `opencode-courier.hub@<version>`. Whatever its version, a hub
|
|
20
|
+
* takes the claim sets and the other shared objects of the copies before the hub from their own
|
|
21
|
+
* keys (`opencode-courier.claimed`, `.watched`, `.forms`, `.questions`, `.receiver`, `.locations`),
|
|
22
|
+
* making each when it is missing, so every copy in the process, with a hub or without, claims an
|
|
23
|
+
* event or a delivery in the same set, and an old and a new copy never both act on it. Those keys'
|
|
24
|
+
* shapes and meanings are therefore fixed: a hub version that needs other ones makes new keys, and
|
|
25
|
+
* keeps claiming in the old ones too while a copy that uses them may still be loaded. `members` is
|
|
26
|
+
* the one thing each version keeps to itself, so a job a hub runs once for its members runs once
|
|
27
|
+
* per hub version, and still claims what it acts on in the shared sets, which keep it from acting
|
|
28
|
+
* twice.
|
|
29
|
+
*/
|
|
30
|
+
export const HUB_VERSION = 1;
|
|
31
|
+
/** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
|
|
32
|
+
export const HUB_KEY = "opencode-courier.hub";
|
|
33
|
+
const realTimers = {
|
|
34
|
+
every: (run, ms) => setInterval(run, ms),
|
|
35
|
+
stop: (timer) => clearInterval(timer),
|
|
36
|
+
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The key in the plugin's storage, shared by every OpenCode server on one data directory, naming
|
|
40
|
+
* the server whose scheduler delivers the `courier_later` messages: `{ server, at }`, its id and
|
|
41
|
+
* when it last wrote the key, in epoch milliseconds. Outside `later/`, which the schedulers scan
|
|
42
|
+
* for messages, those of copies before the key included.
|
|
43
|
+
*/
|
|
44
|
+
export const OWNER_KEY = "scheduler/owner";
|
|
45
|
+
/** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
|
|
46
|
+
export const OWNER_EXPIRY_MS = 4 * TICK_MS;
|
|
47
|
+
/**
|
|
48
|
+
* How old the owner's own key may be for it to renew the key and deliver at once. An older one,
|
|
49
|
+
* which it may no longer hold by the time it writes, is taken again like a free one, with the wait.
|
|
50
|
+
*/
|
|
51
|
+
export const OWNER_RENEW_MS = OWNER_EXPIRY_MS / 2;
|
|
52
|
+
/** The wait between writing the owner key and reading it back, at least `OWNER_WAIT_MS` and up to twice that. */
|
|
53
|
+
export const OWNER_WAIT_MS = 500;
|
|
54
|
+
/**
|
|
55
|
+
* This server's id in the owner key: the same for every copy of the plugin in the process, and
|
|
56
|
+
* different in every other process, a restart of this one included.
|
|
57
|
+
*/
|
|
58
|
+
export const SERVER = `${hostname()}:${process.pid}:${performance.timeOrigin}`;
|
|
59
|
+
async function readOwner(ports) {
|
|
60
|
+
const value = obj(await ports.storage.get(OWNER_KEY));
|
|
61
|
+
const server = str(value.server);
|
|
62
|
+
const at = num(value.at);
|
|
63
|
+
return server !== undefined && at !== undefined ? { server, at } : undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* How long an unloading instance waits, before it lets go, for the work it leaves behind: what it
|
|
67
|
+
* started itself, a tick or an event's notice still under way through it, and on the last leave the
|
|
68
|
+
* owner key's release.
|
|
69
|
+
*/
|
|
70
|
+
export const LEAVE_MS = 2_000;
|
|
71
|
+
/** Whether a key written at `at` still holds at `now`; one from the future, after the clock went back, holds as long. */
|
|
72
|
+
const holds = (at, now, ms) => Math.abs(now - at) < ms;
|
|
73
|
+
/** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
|
|
74
|
+
export const WATCHERS = 2;
|
|
75
|
+
/**
|
|
76
|
+
* Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a
|
|
77
|
+
* versioned key when a hub of another version holds that. Either way, its claim sets and shared
|
|
78
|
+
* objects are the ones under the keys of the copies before the hub, made when missing.
|
|
79
|
+
*/
|
|
80
|
+
export function open(registry, timers = realTimers, server = SERVER) {
|
|
81
|
+
const shared = (name, create) => processWide(`opencode-courier.${name}`, create, registry);
|
|
82
|
+
const questionState = () => {
|
|
83
|
+
// Field by field, since a copy before the hub may have made it without the fields added later.
|
|
84
|
+
const state = shared("questions", () => ({}));
|
|
85
|
+
state.questions ??= new Map();
|
|
86
|
+
state.noticed ??= new Set();
|
|
87
|
+
state.shown ??= new Map();
|
|
88
|
+
state.passing ??= new Map();
|
|
89
|
+
state.answered ??= new Set();
|
|
90
|
+
state.loaded ??= new Set();
|
|
91
|
+
state.following ??= 0;
|
|
92
|
+
state.followed ??= false;
|
|
93
|
+
state.shutdowns ??= new Map();
|
|
94
|
+
state.closingWaiters ??= new Set();
|
|
95
|
+
return state;
|
|
96
|
+
};
|
|
97
|
+
const create = () => ({
|
|
98
|
+
version: HUB_VERSION,
|
|
99
|
+
members: new Set(),
|
|
100
|
+
scheduler: {},
|
|
101
|
+
watchers: [],
|
|
102
|
+
claimed: shared("claimed", () => new Set()),
|
|
103
|
+
watched: shared("watched", () => ({ seen: new Set(), waiting: new Set(), answered: new Set() })),
|
|
104
|
+
forms: shared("forms", () => ({ told: new Map(), settled: new Set() })),
|
|
105
|
+
locations: shared("locations", () => new Map()),
|
|
106
|
+
receivers: shared("receiver", () => ({})),
|
|
107
|
+
questions: questionState(),
|
|
108
|
+
skewLogged: false,
|
|
109
|
+
});
|
|
110
|
+
const found = registry[Symbol.for(HUB_KEY)];
|
|
111
|
+
// The version of the hub of another copy found under `HUB_KEY`, if one is there.
|
|
112
|
+
const skew = found != null && found.version !== HUB_VERSION ? { version: found.version } : undefined;
|
|
113
|
+
const hub = processWide(skew ? `${HUB_KEY}@${HUB_VERSION}` : HUB_KEY, create, registry);
|
|
114
|
+
/**
|
|
115
|
+
* Whether this server holds the owner key, taking or renewing it through `ports`. Its own fresh
|
|
116
|
+
* key is renewed. A free one, an expired one, or its own after a stall, is written and read back
|
|
117
|
+
* after a random wait: of two servers taking it at once, the one that wrote last holds it.
|
|
118
|
+
*/
|
|
119
|
+
const own = async (ports) => {
|
|
120
|
+
const held = await readOwner(ports);
|
|
121
|
+
const now = ports.now();
|
|
122
|
+
if (held && held.server !== server && holds(held.at, now, OWNER_EXPIRY_MS))
|
|
123
|
+
return false;
|
|
124
|
+
await ports.storage.set(OWNER_KEY, { server, at: now });
|
|
125
|
+
if (fresh(held, now))
|
|
126
|
+
return true;
|
|
127
|
+
await timers.wait(randomInt(OWNER_WAIT_MS, 2 * OWNER_WAIT_MS));
|
|
128
|
+
return (await readOwner(ports))?.server === server;
|
|
129
|
+
};
|
|
130
|
+
/** Whether `held` is this server's key, recent enough to renew without reading it back. */
|
|
131
|
+
const fresh = (held, now) => held?.server === server && holds(held.at, now, OWNER_RENEW_MS);
|
|
132
|
+
/** Renews the owner key if this server holds it fresh: what a tick does, for a tick still delivering. */
|
|
133
|
+
const renew = async (ports) => {
|
|
134
|
+
const now = ports.now();
|
|
135
|
+
if (fresh(await readOwner(ports), now))
|
|
136
|
+
await ports.storage.set(OWNER_KEY, { server, at: now });
|
|
137
|
+
};
|
|
138
|
+
/** Whether a hub of another version in the process has instances, so its loop, under the same server id, still holds the key. */
|
|
139
|
+
const othersRunning = () => Object.getOwnPropertySymbols(registry).some((key) => {
|
|
140
|
+
const other = registry[key];
|
|
141
|
+
const name = key.description ?? "";
|
|
142
|
+
return other !== hub && (name === HUB_KEY || name.startsWith(`${HUB_KEY}@`)) && Number(other?.members?.size) > 0;
|
|
143
|
+
});
|
|
144
|
+
/**
|
|
145
|
+
* Removes the owner key if this server holds it, so another server takes over at its next tick:
|
|
146
|
+
* once the loop has stopped and no tick is under way, whichever copy started it (a tick whose loop
|
|
147
|
+
* stopped releases it as it ends), and while no hub of another version in the process still runs a
|
|
148
|
+
* loop under the same id.
|
|
149
|
+
*/
|
|
150
|
+
const release = async (ports) => {
|
|
151
|
+
const scheduler = hub.scheduler;
|
|
152
|
+
const idle = () => scheduler.timer === undefined && !scheduler.running && !scheduler.ticking && !othersRunning();
|
|
153
|
+
if (idle() && (await readOwner(ports))?.server === server && idle())
|
|
154
|
+
await ports.storage.remove(OWNER_KEY);
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Whether the tick under way, kept on the hub by whichever copy started it, still holds up the
|
|
158
|
+
* next: within a minute of its start it does, and the key is renewed for it, through the first
|
|
159
|
+
* member still loaded, since a delivery may run longer than the key holds; past the minute it is
|
|
160
|
+
* taken to hang, and holds up the next only while its member is still loaded. A tick of a copy that
|
|
161
|
+
* keeps only `ticking` holds it up while its member is loaded, without renewing the key.
|
|
162
|
+
*/
|
|
163
|
+
const underWay = () => {
|
|
164
|
+
const { running, ticking } = hub.scheduler;
|
|
165
|
+
if (!running)
|
|
166
|
+
return ticking !== undefined && hub.members.has(ticking);
|
|
167
|
+
const first = hub.members.values().next().value;
|
|
168
|
+
if (first && holds(running.since, first.later.now(), OWNER_EXPIRY_MS)) {
|
|
169
|
+
void renew(first.later).catch((error) => first.log(`courier_later scheduler: ${String(error)}`));
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return hub.members.has(running.member);
|
|
173
|
+
};
|
|
174
|
+
// One tick, through the first member still loaded, by the server holding the owner key. The claim
|
|
175
|
+
// on each delivery stays, as the second line of defence against a copy before the hub, which runs
|
|
176
|
+
// its own interval until it unloads, and has no owner key.
|
|
177
|
+
const tick = async () => {
|
|
178
|
+
const scheduler = hub.scheduler;
|
|
179
|
+
if (underWay())
|
|
180
|
+
return;
|
|
181
|
+
const owner = hub.members.values().next().value;
|
|
182
|
+
if (!owner)
|
|
183
|
+
return;
|
|
184
|
+
let since;
|
|
185
|
+
try {
|
|
186
|
+
since = owner.later.now();
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
owner.log(`courier_later scheduler: ${String(error)}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
let end = () => { };
|
|
193
|
+
const run = { member: owner, since, done: new Promise((resolve) => (end = resolve)) };
|
|
194
|
+
scheduler.running = run;
|
|
195
|
+
scheduler.ticking = owner;
|
|
196
|
+
// `done` settles however the tick ends, and the key is given back, a log that throws included:
|
|
197
|
+
// leaves wait for it. A log that throws is not logged again; there is nowhere left to tell.
|
|
198
|
+
const tell = (message) => {
|
|
199
|
+
try {
|
|
200
|
+
owner.log(message);
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
if ((await own(owner.later)) && scheduler.timer !== undefined && hub.members.has(owner))
|
|
206
|
+
await deliverDue(owner.later, hub.claimed);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
tell(`courier_later scheduler: ${String(error)}`);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
// A tick past its minute may have been followed by another, whose record stays.
|
|
213
|
+
if (scheduler.running === run) {
|
|
214
|
+
scheduler.running = undefined;
|
|
215
|
+
scheduler.ticking = undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// A loop stopped meanwhile, its members gone, gives back the key this tick may have written.
|
|
219
|
+
if (scheduler.timer === undefined)
|
|
220
|
+
await release(owner.later).catch((error) => tell(`courier_later scheduler: owner key not released: ${String(error)}`));
|
|
221
|
+
end();
|
|
222
|
+
};
|
|
223
|
+
// Subscriptions through the earliest members without one, the longest loaded, until there are
|
|
224
|
+
// `WATCHERS`; never through `leaving`.
|
|
225
|
+
const watch = (leaving) => {
|
|
226
|
+
const watching = new Set(hub.watchers.map((watcher) => watcher.member));
|
|
227
|
+
for (const member of hub.members) {
|
|
228
|
+
if (hub.watchers.length >= WATCHERS || !hub.subscribe)
|
|
229
|
+
return;
|
|
230
|
+
if (member !== leaving && !watching.has(member))
|
|
231
|
+
hub.watchers.push(hub.subscribe(member));
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
// The work each member of this copy started and handed over, until it settles.
|
|
235
|
+
const work = new WeakMap();
|
|
236
|
+
const track = (member, started) => {
|
|
237
|
+
const set = work.get(member) ?? new Set();
|
|
238
|
+
work.set(member, set);
|
|
239
|
+
const settled = started.catch(() => { }).finally(() => set.delete(settled));
|
|
240
|
+
set.add(settled);
|
|
241
|
+
};
|
|
242
|
+
const join = (member) => {
|
|
243
|
+
if (skew && !hub.skewLogged) {
|
|
244
|
+
hub.skewLogged = true;
|
|
245
|
+
member.log(`courier: another copy of the plugin with hub version ${String(skew.version)} has been loaded in this process; ` +
|
|
246
|
+
`this copy (hub version ${HUB_VERSION}) keeps its own under ${HUB_KEY}@${HUB_VERSION} and shares the claim sets with it`);
|
|
247
|
+
}
|
|
248
|
+
hub.members.add(member);
|
|
249
|
+
hub.locations.set(member, member.permission);
|
|
250
|
+
const scheduler = hub.scheduler;
|
|
251
|
+
scheduler.tick = tick;
|
|
252
|
+
if (scheduler.timer === undefined) {
|
|
253
|
+
scheduler.timer = timers.every(() => void hub.scheduler.tick?.(), TICK_MS);
|
|
254
|
+
void tick();
|
|
255
|
+
}
|
|
256
|
+
watch();
|
|
257
|
+
return () => {
|
|
258
|
+
hub.members.delete(member);
|
|
259
|
+
hub.locations.delete(member);
|
|
260
|
+
// What this member leaves behind: the work it handed over, and a tick under way through it,
|
|
261
|
+
// which keeps using its storage and session until it ends.
|
|
262
|
+
const behind = [...(work.get(member) ?? [])];
|
|
263
|
+
if (scheduler.running?.member === member)
|
|
264
|
+
behind.push(scheduler.running.done);
|
|
265
|
+
// The other subscription, already connected, follows the events meanwhile; this one is
|
|
266
|
+
// replaced before it ends, so the question relay's count of watchers never drops to none.
|
|
267
|
+
// It ends once the event it is handling, whose notice may be going out, has been handled.
|
|
268
|
+
const index = hub.watchers.findIndex((watcher) => watcher.member === member);
|
|
269
|
+
if (index >= 0) {
|
|
270
|
+
const [leaving] = hub.watchers.splice(index, 1);
|
|
271
|
+
watch(member);
|
|
272
|
+
leaving.stop.abort();
|
|
273
|
+
behind.push(leaving.done);
|
|
274
|
+
}
|
|
275
|
+
if (hub.members.size === 0 && scheduler.timer !== undefined) {
|
|
276
|
+
timers.stop(scheduler.timer);
|
|
277
|
+
scheduler.timer = undefined;
|
|
278
|
+
// After the tick under way, whichever copy started it, which may be writing the key.
|
|
279
|
+
behind.push((async () => {
|
|
280
|
+
await scheduler.running?.done;
|
|
281
|
+
await release(member.later);
|
|
282
|
+
})().catch((error) => member.log(`courier_later scheduler: owner key not released: ${String(error)}`)));
|
|
283
|
+
}
|
|
284
|
+
if (behind.length === 0)
|
|
285
|
+
return Promise.resolve();
|
|
286
|
+
return Promise.race([Promise.allSettled(behind).then(() => { }), timers.wait(LEAVE_MS)]);
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
return { hub, join, track };
|
|
290
|
+
}
|
|
291
|
+
const opened = open(globalThis);
|
|
292
|
+
/** This copy's hub, in the process. */
|
|
293
|
+
export const hub = opened.hub;
|
|
294
|
+
/** Joins this copy's hub; see `Opened.join`. */
|
|
295
|
+
export const join = opened.join;
|
|
296
|
+
/** Hands this copy's hub work a member started; see `Opened.track`. */
|
|
297
|
+
export const track = opened.track;
|
|
298
|
+
/** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
|
|
299
|
+
export function memberAt(location) {
|
|
300
|
+
let found;
|
|
301
|
+
for (const member of hub.members)
|
|
302
|
+
if (member.location === location)
|
|
303
|
+
found = member;
|
|
304
|
+
return found;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The question ports of the member that has just joined at `location`, the newest there, for as long
|
|
308
|
+
* as it stays loaded: resolved once, so an instance joining later at the same location, while this
|
|
309
|
+
* one is still loaded, does not take its place.
|
|
310
|
+
*/
|
|
311
|
+
export function portsAt(location) {
|
|
312
|
+
const own = memberAt(location);
|
|
313
|
+
return () => (own && hub.members.has(own) ? own.questions : undefined);
|
|
314
|
+
}
|
|
315
|
+
/** The permission domains of the loaded instances. */
|
|
316
|
+
export const permissions = () => hub.locations.values();
|
|
317
|
+
/**
|
|
318
|
+
* For tests: forgets every remembered id and the question relay's state, the relay's loaded
|
|
319
|
+
* instances and its count of watchers included. The hub's members, the locations and the webhook
|
|
320
|
+
* receiver are left alone; leaving and unloading take them out.
|
|
321
|
+
*/
|
|
322
|
+
export function resetHub() {
|
|
323
|
+
hub.claimed.clear();
|
|
324
|
+
hub.watched.seen.clear();
|
|
325
|
+
hub.watched.waiting.clear();
|
|
326
|
+
hub.watched.answered.clear();
|
|
327
|
+
hub.forms.told.clear();
|
|
328
|
+
hub.forms.settled.clear();
|
|
329
|
+
const questions = hub.questions;
|
|
330
|
+
questions.questions.clear();
|
|
331
|
+
questions.noticed.clear();
|
|
332
|
+
questions.shown.clear();
|
|
333
|
+
questions.passing.clear();
|
|
334
|
+
questions.answered.clear();
|
|
335
|
+
questions.loaded.clear();
|
|
336
|
+
questions.following = 0;
|
|
337
|
+
questions.followed = false;
|
|
338
|
+
questions.shutdowns.clear();
|
|
339
|
+
questions.closingWaiters.clear();
|
|
340
|
+
}
|