opencode-courier 0.2.3 → 0.4.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.
- package/README.md +198 -193
- package/dist/index.d.ts +4 -8
- package/dist/index.js +66 -85
- package/dist/kernel/protocol.d.ts +20 -0
- package/dist/kernel/protocol.js +36 -0
- package/dist/kernel/tool.d.ts +5 -0
- package/dist/kernel/tool.js +5 -0
- package/dist/person/events.d.ts +23 -0
- package/dist/person/events.js +113 -0
- package/dist/person/form.d.ts +26 -0
- package/dist/person/form.js +64 -0
- package/dist/person/index.d.ts +17 -0
- package/dist/person/index.js +5 -0
- package/dist/{relay.d.ts → person/permission.d.ts} +29 -25
- package/dist/{relay.js → person/permission.js} +11 -9
- package/dist/person/ports.d.ts +58 -0
- package/dist/person/ports.js +2 -0
- package/dist/{question → person/question}/answer.d.ts +33 -4
- package/dist/{question → person/question}/answer.js +49 -17
- package/dist/person/question/index.d.ts +7 -0
- package/dist/{question → person/question}/index.js +3 -3
- package/dist/person/question/lifecycle.d.ts +43 -0
- package/dist/{question → person/question}/lifecycle.js +10 -19
- package/dist/{question → person/question}/pure.d.ts +4 -6
- package/dist/{question → person/question}/pure.js +3 -4
- package/dist/{question → person/question}/relay.d.ts +1 -1
- package/dist/{question → person/question}/relay.js +27 -35
- package/dist/{question → person/question}/runtime.d.ts +2 -3
- package/dist/{question → person/question}/runtime.js +2 -3
- package/dist/{question → person/question}/shared.d.ts +13 -3
- package/dist/person/question/shared.js +52 -0
- package/dist/person/question/types.d.ts +121 -0
- package/dist/person/question/types.js +1 -0
- package/dist/person/rpc-handlers.d.ts +160 -0
- package/dist/person/rpc-handlers.js +62 -0
- package/dist/person/rpc.d.ts +227 -0
- package/dist/person/rpc.js +70 -0
- package/dist/person/text.d.ts +68 -0
- package/dist/{notices.js → person/text.js} +53 -190
- package/dist/person/tools.d.ts +20 -0
- package/dist/person/tools.js +55 -0
- package/dist/person/tui.d.ts +46 -0
- package/dist/platform/bounded.d.ts +12 -0
- package/dist/{bounded.js → platform/bounded.js} +6 -4
- package/dist/platform/events.d.ts +34 -0
- package/dist/platform/events.js +121 -0
- package/dist/platform/hub.d.ts +195 -0
- package/dist/{hub.js → platform/hub.js} +78 -119
- package/dist/platform/json.d.ts +8 -0
- package/dist/platform/json.js +15 -0
- package/dist/platform/version.d.ts +16 -0
- package/dist/{version.js → platform/version.js} +5 -11
- package/dist/rpc.d.ts +2 -0
- package/dist/rpc.js +3 -0
- package/dist/scheduling/index.d.ts +6 -0
- package/dist/scheduling/index.js +5 -0
- package/dist/{later.d.ts → scheduling/later.d.ts} +13 -9
- package/dist/{later.js → scheduling/later.js} +18 -9
- package/dist/scheduling/text.d.ts +6 -0
- package/dist/scheduling/text.js +10 -0
- package/dist/scheduling/tools.d.ts +14 -0
- package/dist/scheduling/tools.js +48 -0
- package/dist/skill.d.ts +17 -0
- package/dist/skill.js +62 -0
- package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +5 -26
- package/dist/{cleanup.js → tree/cleanup.js} +20 -15
- package/dist/tree/context.d.ts +15 -0
- package/dist/tree/context.js +34 -0
- package/dist/tree/domain.d.ts +101 -0
- package/dist/tree/domain.js +68 -0
- package/dist/tree/events.d.ts +71 -0
- package/dist/tree/events.js +204 -0
- package/dist/tree/forget.d.ts +24 -0
- package/dist/tree/forget.js +59 -0
- package/dist/tree/group.d.ts +82 -0
- package/dist/tree/group.js +203 -0
- package/dist/tree/index.d.ts +10 -0
- package/dist/tree/index.js +11 -0
- package/dist/tree/limits.d.ts +10 -0
- package/dist/tree/limits.js +60 -0
- package/dist/tree/panel.d.ts +13 -0
- package/dist/tree/panel.js +75 -0
- package/dist/tree/ports.d.ts +276 -0
- package/dist/tree/ports.js +1 -0
- package/dist/tree/report.d.ts +23 -0
- package/dist/tree/report.js +33 -0
- package/dist/tree/roster.d.ts +27 -0
- package/dist/tree/roster.js +62 -0
- package/dist/tree/rpc-handlers.d.ts +201 -0
- package/dist/tree/rpc-handlers.js +20 -0
- package/dist/tree/rpc.d.ts +344 -0
- package/dist/tree/rpc.js +83 -0
- package/dist/tree/send.d.ts +14 -0
- package/dist/tree/send.js +99 -0
- package/dist/tree/spawn.d.ts +21 -0
- package/dist/tree/spawn.js +173 -0
- package/dist/tree/status.d.ts +128 -0
- package/dist/tree/status.js +62 -0
- package/dist/tree/stop.d.ts +33 -0
- package/dist/tree/stop.js +116 -0
- package/dist/tree/store.d.ts +24 -0
- package/dist/tree/store.js +264 -0
- package/dist/tree/subtree.d.ts +101 -0
- package/dist/tree/subtree.js +60 -0
- package/dist/tree/summary.d.ts +7 -0
- package/dist/tree/summary.js +35 -0
- package/dist/tree/text.d.ts +77 -0
- package/dist/tree/text.js +287 -0
- package/dist/tree/tools.d.ts +54 -0
- package/dist/tree/tools.js +167 -0
- package/dist/tree/tui.d.ts +53 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +1219 -0
- package/dist/webhook/index.d.ts +5 -0
- package/dist/webhook/index.js +3 -0
- package/dist/{webhook.d.ts → webhook/receiver.d.ts} +25 -36
- package/dist/{webhook.js → webhook/receiver.js} +66 -67
- package/dist/webhook/subscriptions.d.ts +29 -0
- package/dist/webhook/subscriptions.js +52 -0
- package/dist/webhook/text.d.ts +23 -0
- package/dist/webhook/text.js +82 -0
- package/dist/webhook/tools.d.ts +19 -0
- package/dist/webhook/tools.js +46 -0
- package/package.json +18 -7
- package/dist/bounded.d.ts +0 -10
- package/dist/courier.d.ts +0 -95
- package/dist/courier.js +0 -131
- package/dist/hub.d.ts +0 -325
- package/dist/json.d.ts +0 -4
- package/dist/json.js +0 -4
- package/dist/notices.d.ts +0 -246
- package/dist/question/index.d.ts +0 -6
- package/dist/question/lifecycle.d.ts +0 -52
- package/dist/question/shared.js +0 -24
- package/dist/roster.d.ts +0 -81
- package/dist/roster.js +0 -175
- package/dist/tools.d.ts +0 -74
- package/dist/tools.js +0 -228
- package/dist/version.d.ts +0 -22
- package/dist/watch.d.ts +0 -61
- package/dist/watch.js +0 -289
- /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
- /package/dist/{storage.js → platform/storage.js} +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import type { Hub } from "./hub.js";
|
|
3
|
+
/** One of OpenCode's events, as a subscription yields it. */
|
|
4
|
+
export interface HostEvent {
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly data?: unknown;
|
|
7
|
+
}
|
|
8
|
+
/** What following OpenCode's events needs of a member's ports: the stream, and a log. */
|
|
9
|
+
export interface EventPorts {
|
|
10
|
+
readonly event: Pick<Plugin.Context["event"], "subscribe">;
|
|
11
|
+
readonly log: (message: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/** What a module does with OpenCode's events, by phase; each phase runs the modules in list order. */
|
|
14
|
+
export interface EventModule<P extends EventPorts> {
|
|
15
|
+
/** For each event; the first to throw ends the phase. */
|
|
16
|
+
readonly handle?: (ports: P, event: HostEvent) => Promise<unknown> | undefined;
|
|
17
|
+
/** For each event, once the `handle` phase has ended, however it did; the first to throw ends this one too. */
|
|
18
|
+
readonly after?: (ports: P, event: HostEvent) => Promise<void> | undefined;
|
|
19
|
+
/** As a subscription starts; returns what to run at its first event, if anything. */
|
|
20
|
+
readonly followed?: () => (() => void) | undefined;
|
|
21
|
+
/** Alongside each (re)subscription; the watcher's end waits for it. */
|
|
22
|
+
readonly subscribed?: (ports: P) => Promise<void>;
|
|
23
|
+
/** Once a subscription has ended or broken. */
|
|
24
|
+
readonly left?: () => void;
|
|
25
|
+
}
|
|
26
|
+
/** How long to wait before subscribing again after the event stream ended or broke. */
|
|
27
|
+
export declare const RESUBSCRIBE_MS = 5000;
|
|
28
|
+
/** Follows OpenCode's events through `ports`, subscribing again after `retryMs` when a subscription ends, until `signal` aborts. */
|
|
29
|
+
export declare function follow<P extends EventPorts>(ports: P, modules: readonly EventModule<P>[], signal: AbortSignal, retryMs?: number): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Makes the hub start its subscriptions to OpenCode's events with this copy's modules, the copy loaded
|
|
32
|
+
* last; `join` starts them. The second is a standby, connected before the first's member leaves.
|
|
33
|
+
*/
|
|
34
|
+
export declare function watchFromHub<P extends EventPorts>(hub: Pick<Hub, "subscribe">, modules: readonly EventModule<P>[], retryMs?: number): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** How long to wait before subscribing again after the event stream ended or broke. */
|
|
2
|
+
export const RESUBSCRIBE_MS = 5_000;
|
|
3
|
+
const pause = (ms, signal) => new Promise((resolve) => {
|
|
4
|
+
const timer = setTimeout(done, ms);
|
|
5
|
+
function done() {
|
|
6
|
+
clearTimeout(timer);
|
|
7
|
+
signal.removeEventListener("abort", done);
|
|
8
|
+
resolve();
|
|
9
|
+
}
|
|
10
|
+
signal.addEventListener("abort", done);
|
|
11
|
+
});
|
|
12
|
+
const ENDED = { done: true, value: undefined };
|
|
13
|
+
/**
|
|
14
|
+
* `events` until it ends or `signal` aborts, the stream left open; each wait hears the abort only while
|
|
15
|
+
* it waits, so a watcher that follows events for the life of the process holds nothing per event.
|
|
16
|
+
*/
|
|
17
|
+
const until = (events, signal) => ({
|
|
18
|
+
[Symbol.asyncIterator]: () => ({
|
|
19
|
+
next: () => new Promise((resolve, reject) => {
|
|
20
|
+
if (signal.aborted)
|
|
21
|
+
return resolve(ENDED);
|
|
22
|
+
const stop = () => resolve(ENDED);
|
|
23
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
24
|
+
// Once the signal has aborted, whatever the stream ends this wait with is not wanted.
|
|
25
|
+
events.next().then((next) => {
|
|
26
|
+
signal.removeEventListener("abort", stop);
|
|
27
|
+
resolve(next);
|
|
28
|
+
}, (error) => {
|
|
29
|
+
signal.removeEventListener("abort", stop);
|
|
30
|
+
reject(error);
|
|
31
|
+
});
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
/** One event through the `handle` phase, then, however that ended, the `after` phase. */
|
|
36
|
+
async function handle(ports, modules, event) {
|
|
37
|
+
try {
|
|
38
|
+
for (const module of modules) {
|
|
39
|
+
const handling = module.handle?.(ports, event);
|
|
40
|
+
if (handling)
|
|
41
|
+
await handling;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
for (const module of modules) {
|
|
46
|
+
const after = module.after?.(ports, event);
|
|
47
|
+
if (after)
|
|
48
|
+
await after;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Handles one event, logging a failure rather than ending the watch. */
|
|
53
|
+
function handleLogged(ports, modules, event) {
|
|
54
|
+
return handle(ports, modules, event).catch((error) => {
|
|
55
|
+
const data = event.data;
|
|
56
|
+
const sessionID = data?.sessionID ?? data?.form?.sessionID;
|
|
57
|
+
ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Runs each module's `subscribed` alongside the new subscription, kept in `alongside` until done. */
|
|
61
|
+
function subscribed(ports, modules, alongside) {
|
|
62
|
+
for (const module of modules) {
|
|
63
|
+
if (!module.subscribed)
|
|
64
|
+
continue;
|
|
65
|
+
const running = module
|
|
66
|
+
.subscribed(ports)
|
|
67
|
+
.catch((error) => ports.log(`courier watch: ${String(error)}`))
|
|
68
|
+
.finally(() => alongside.delete(running));
|
|
69
|
+
alongside.add(running);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** One subscription, its events through `modules`' phases, until its stream ends or breaks or `signal` aborts. */
|
|
73
|
+
async function subscription(ports, modules, signal, alongside) {
|
|
74
|
+
let following = false;
|
|
75
|
+
let events;
|
|
76
|
+
try {
|
|
77
|
+
events = ports.event.subscribe({ signal })[Symbol.asyncIterator]();
|
|
78
|
+
let first = modules.flatMap((module) => module.followed?.() ?? []);
|
|
79
|
+
following = true;
|
|
80
|
+
subscribed(ports, modules, alongside);
|
|
81
|
+
for await (const event of until(events, signal)) {
|
|
82
|
+
for (const run of first)
|
|
83
|
+
run();
|
|
84
|
+
first = [];
|
|
85
|
+
await handleLogged(ports, modules, event);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!signal.aborted)
|
|
90
|
+
ports.log(`courier watch: event stream broke: ${String(error)}`);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (following)
|
|
94
|
+
for (const module of modules)
|
|
95
|
+
module.left?.();
|
|
96
|
+
// Closed without waiting: closing the adapter's stream waits on OpenCode, which may not answer
|
|
97
|
+
// while a location closes, and the leave waiting for this watcher must not wait for that too.
|
|
98
|
+
void events?.return?.().catch(() => { });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Follows OpenCode's events through `ports`, subscribing again after `retryMs` when a subscription ends, until `signal` aborts. */
|
|
102
|
+
export async function follow(ports, modules, signal, retryMs = RESUBSCRIBE_MS) {
|
|
103
|
+
const alongside = new Set();
|
|
104
|
+
while (!signal.aborted) {
|
|
105
|
+
await subscription(ports, modules, signal, alongside);
|
|
106
|
+
if (!signal.aborted)
|
|
107
|
+
await pause(retryMs, signal);
|
|
108
|
+
}
|
|
109
|
+
await Promise.allSettled(alongside);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Makes the hub start its subscriptions to OpenCode's events with this copy's modules, the copy loaded
|
|
113
|
+
* last; `join` starts them. The second is a standby, connected before the first's member leaves.
|
|
114
|
+
*/
|
|
115
|
+
export function watchFromHub(hub, modules, retryMs = RESUBSCRIBE_MS) {
|
|
116
|
+
hub.subscribe = (member) => {
|
|
117
|
+
const stop = new AbortController();
|
|
118
|
+
// A member of any copy runs them: every hub-v2 member's `watch` ports are the same shape.
|
|
119
|
+
return { member, stop, done: follow(member.watch, modules, stop.signal, retryMs) };
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { type Registry, type Storage } from "./storage.js";
|
|
2
|
+
/**
|
|
3
|
+
* The plugin's process-wide state on `globalThis`, shared by every loaded copy; only this module puts
|
|
4
|
+
* it there. `HUB_VERSION`, the fixed claim keys and version skew: docs/maintainers/architecture.md, § Several copies in one process.
|
|
5
|
+
*/
|
|
6
|
+
export declare const HUB_VERSION = 2;
|
|
7
|
+
/** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
|
|
8
|
+
export declare const HUB_KEY = "opencode-courier.hub";
|
|
9
|
+
/** The ports a member's scheduler keeps the owner key through: what every member's `later` ports have. */
|
|
10
|
+
export interface TickPorts {
|
|
11
|
+
readonly storage: Pick<Storage, "get" | "set" | "remove">;
|
|
12
|
+
readonly now: () => number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The events handled, and the permission requests a session was told about or that were answered
|
|
16
|
+
* first: claim sets shared by every subscription and copy, so each event is handled once.
|
|
17
|
+
*/
|
|
18
|
+
export interface Watched {
|
|
19
|
+
readonly seen: Set<string>;
|
|
20
|
+
readonly waiting: Set<string>;
|
|
21
|
+
readonly answered: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
/** The forms sessions were told about, kept apart from the permission requests. */
|
|
24
|
+
export interface FormsTold {
|
|
25
|
+
/** Forms still shown whose notice went out, or is going out: the notice's delivery. */
|
|
26
|
+
readonly told: Map<string, Promise<unknown>>;
|
|
27
|
+
/** Forms settled before anyone was told, so a notice whose roster lookup was overtaken is not sent. */
|
|
28
|
+
readonly settled: Set<string>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A loaded plugin instance: its location's directory and permission domain, and the ports a job
|
|
32
|
+
* run once for the whole process would run it through.
|
|
33
|
+
*/
|
|
34
|
+
export interface Member {
|
|
35
|
+
readonly directory: string;
|
|
36
|
+
/**
|
|
37
|
+
* The location OpenCode set the instance up with, the very object the instance's Effect half is
|
|
38
|
+
* handed too, which finds its member by it (`portsAt`). A member of a copy before it has none.
|
|
39
|
+
*/
|
|
40
|
+
readonly location?: object;
|
|
41
|
+
/** The location's permission domain, kept in `locations`. */
|
|
42
|
+
readonly permission: unknown;
|
|
43
|
+
/** The ports the scheduler's tick runs through: the owner key's, and the tick jobs'. */
|
|
44
|
+
readonly later: TickPorts;
|
|
45
|
+
/** The ports the hub's subscriptions to OpenCode's events run through. */
|
|
46
|
+
readonly watch: unknown;
|
|
47
|
+
/** The question relay's ports. */
|
|
48
|
+
readonly questions: unknown;
|
|
49
|
+
readonly log: (message: string) => void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A tick under way: the member it runs through, when it started, by the wall clock every member's
|
|
53
|
+
* `now` reads (the next ticks compare it with another member's), and its end.
|
|
54
|
+
*/
|
|
55
|
+
export interface Tick {
|
|
56
|
+
readonly member: Member;
|
|
57
|
+
readonly since: number;
|
|
58
|
+
/** Settles once the tick has ended, the owner key released if its loop stopped meanwhile; never rejects. */
|
|
59
|
+
readonly done: Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The scheduler's loop, one per hub: it runs while the hub has members, and each tick runs the jobs
|
|
63
|
+
* registered with `open` through the ports of the member that joined first and is still loaded.
|
|
64
|
+
*/
|
|
65
|
+
export interface Scheduler {
|
|
66
|
+
/** The loop's interval, while it runs. */
|
|
67
|
+
timer?: unknown;
|
|
68
|
+
/**
|
|
69
|
+
* The tick under way, whichever copy started it: for a minute the key is renewed for it and due
|
|
70
|
+
* ticks skip; past it, one whose member left holds nothing up. Older copies have only `ticking`.
|
|
71
|
+
*/
|
|
72
|
+
running?: Tick;
|
|
73
|
+
/**
|
|
74
|
+
* The member the tick under way runs through, if any, set and cleared with `running`, for a copy
|
|
75
|
+
* that reads only this: a tick that falls due meanwhile is skipped, unless that member has left.
|
|
76
|
+
*/
|
|
77
|
+
ticking?: Member;
|
|
78
|
+
/** One tick, by the copy of the plugin that joined last, so a copy loaded after an update runs its own code. */
|
|
79
|
+
tick?: () => Promise<void>;
|
|
80
|
+
/** Whether a nudge came since the tick under way started, whichever copy's, so it ticks once more when done. */
|
|
81
|
+
nudged?: boolean;
|
|
82
|
+
}
|
|
83
|
+
/** How the hub starts and stops the scheduler's loop, and waits before re-reading the owner key; tests pass their own. */
|
|
84
|
+
export interface Timers {
|
|
85
|
+
readonly every: (run: () => void, ms: number) => unknown;
|
|
86
|
+
readonly stop: (timer: unknown) => void;
|
|
87
|
+
readonly wait: (ms: number) => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
/** How often the hub's scheduler ticks: what a job delivers, a `courier_later` message, can be this late. */
|
|
90
|
+
export declare const TICK_MS = 15000;
|
|
91
|
+
/**
|
|
92
|
+
* The storage key, shared by every server on one data directory, naming the server whose scheduler
|
|
93
|
+
* delivers `courier_later` messages: `{ server, at }`, its id and when it last wrote (epoch ms).
|
|
94
|
+
*/
|
|
95
|
+
export declare const OWNER_KEY = "scheduler/owner";
|
|
96
|
+
/** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
|
|
97
|
+
export declare const OWNER_EXPIRY_MS: number;
|
|
98
|
+
/**
|
|
99
|
+
* How old the owner's own key may be for it to renew the key and deliver at once. An older one,
|
|
100
|
+
* which it may no longer hold by the time it writes, is taken again like a free one, with the wait.
|
|
101
|
+
*/
|
|
102
|
+
export declare const OWNER_RENEW_MS: number;
|
|
103
|
+
/** The wait between writing the owner key and reading it back, at least `OWNER_WAIT_MS` and up to twice that. */
|
|
104
|
+
export declare const OWNER_WAIT_MS = 500;
|
|
105
|
+
/**
|
|
106
|
+
* This server's id in the owner key: the same for every copy of the plugin in the process, and
|
|
107
|
+
* different in every other process, a restart of this one included.
|
|
108
|
+
*/
|
|
109
|
+
export declare const SERVER: string;
|
|
110
|
+
/**
|
|
111
|
+
* How long an unloading instance waits for the work it leaves behind: what it started itself, a tick
|
|
112
|
+
* or a notice under way through it, and on the last leave the owner key's release.
|
|
113
|
+
*/
|
|
114
|
+
export declare const LEAVE_MS = 2000;
|
|
115
|
+
/** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
|
|
116
|
+
export declare const WATCHERS = 2;
|
|
117
|
+
/** One of the hub's subscriptions to OpenCode's events: the member whose ports it runs through, and how it ends. */
|
|
118
|
+
export interface Watcher {
|
|
119
|
+
readonly member: Member;
|
|
120
|
+
readonly stop: AbortController;
|
|
121
|
+
/** Settles once the subscription has ended, after `stop`: after the event it is handling, not the stream's close. */
|
|
122
|
+
readonly done: Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
export interface Hub {
|
|
125
|
+
readonly version: typeof HUB_VERSION;
|
|
126
|
+
/** The instances loaded now, in the order they joined. */
|
|
127
|
+
readonly members: Set<Member>;
|
|
128
|
+
readonly scheduler: Scheduler;
|
|
129
|
+
/**
|
|
130
|
+
* The hub's subscriptions to OpenCode's events, each through another member: two while two members
|
|
131
|
+
* are loaded, so one is always connected when the other's member leaves.
|
|
132
|
+
*/
|
|
133
|
+
readonly watchers: Watcher[];
|
|
134
|
+
/**
|
|
135
|
+
* Starts a subscription through a member, by the copy of the plugin that set it last; until one
|
|
136
|
+
* has, the hub follows no events.
|
|
137
|
+
*/
|
|
138
|
+
subscribe?: (member: Member) => Watcher;
|
|
139
|
+
/** The ids of the `courier_later` messages being delivered. */
|
|
140
|
+
readonly claimed: Set<string>;
|
|
141
|
+
/** The events handled, and the permission requests a session was told about or that were answered first. */
|
|
142
|
+
readonly watched: Watched;
|
|
143
|
+
/** The forms sessions were told about, and those settled first. */
|
|
144
|
+
readonly forms: FormsTold;
|
|
145
|
+
/**
|
|
146
|
+
* The permission domain of every loaded instance, by instance. OpenCode keeps a request where its
|
|
147
|
+
* session runs, so a request of an isolated child is answered through the instance loaded in its worktree.
|
|
148
|
+
*/
|
|
149
|
+
readonly locations: Map<object, unknown>;
|
|
150
|
+
/** The webhook receiver's state: the object every copy shares under the key `receiver`. */
|
|
151
|
+
readonly receivers: object;
|
|
152
|
+
/** The question relay's state: the object every copy shares under the key `questions`. */
|
|
153
|
+
readonly questions: object;
|
|
154
|
+
/** Whether the hub of another version found under `HUB_KEY` has been logged. */
|
|
155
|
+
skewLogged: boolean;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A job each tick runs, in its turn, through the first loaded member, which may be another copy's:
|
|
159
|
+
* it uses only the ports every hub-v2 member has. `claimed` is the hub's claim set; a throw ends the tick.
|
|
160
|
+
*/
|
|
161
|
+
export type TickJob = (owner: Member, claimed: Set<string>) => Promise<void>;
|
|
162
|
+
/** A hub, as `open` finds or makes it, and how instances join it. */
|
|
163
|
+
export interface Opened {
|
|
164
|
+
readonly hub: Hub;
|
|
165
|
+
/**
|
|
166
|
+
* Adds a loaded instance to the hub and `locations`; returns its leave. The first join starts the
|
|
167
|
+
* scheduler and watchers, the last leave stops them; a leave waits `LEAVE_MS` for the member's work.
|
|
168
|
+
*/
|
|
169
|
+
readonly join: (member: Member) => () => Promise<void>;
|
|
170
|
+
/** Hands the hub work a member started, which its leave waits for; the member's own background work. */
|
|
171
|
+
readonly track: (member: Member, work: Promise<unknown>) => void;
|
|
172
|
+
/**
|
|
173
|
+
* Runs a tick now, or once the tick under way has ended, so what has just fallen due (a released
|
|
174
|
+
* group) is delivered at once rather than at the next tick; nothing while no instance is loaded.
|
|
175
|
+
*/
|
|
176
|
+
readonly nudge: () => void;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The value under `opencode-courier.<name>` every copy in the process shares, made by `create` the first
|
|
180
|
+
* time; another version, or a copy before the hub, may have made it, so its shape is kept for good.
|
|
181
|
+
*/
|
|
182
|
+
export declare function sharedState<T>(name: string, create: () => T, registry?: Registry): T;
|
|
183
|
+
/**
|
|
184
|
+
* This copy's hub in `registry`, and how its instances join it. Its ticks run `jobs` in order, kept in
|
|
185
|
+
* this copy's tick, which the hub runs once this copy has joined last, never in the hub itself.
|
|
186
|
+
*/
|
|
187
|
+
export declare function open(registry: Registry, jobs: readonly TickJob[], timers?: Timers, server?: string): Opened;
|
|
188
|
+
/** This copy's hub, in the process. */
|
|
189
|
+
export declare const hub: Hub;
|
|
190
|
+
/** Opens this copy's hub in the process, with its tick jobs; see `open`. */
|
|
191
|
+
export declare const openInProcess: (jobs: readonly TickJob[]) => Opened;
|
|
192
|
+
/** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
|
|
193
|
+
export declare function memberAt(location: object): Member | undefined;
|
|
194
|
+
/** The permission domains of the loaded instances. */
|
|
195
|
+
export declare const permissions: () => MapIterator<unknown>;
|
|
@@ -1,33 +1,12 @@
|
|
|
1
1
|
import { randomInt } from "node:crypto";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { num, obj, str } from "./json.js";
|
|
4
|
-
import { deliverDue, TICK_MS } from "./later.js";
|
|
5
4
|
import { processWide } from "./storage.js";
|
|
6
5
|
/**
|
|
7
|
-
* The plugin's process-wide state
|
|
8
|
-
*
|
|
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.
|
|
6
|
+
* The plugin's process-wide state on `globalThis`, shared by every loaded copy; only this module puts
|
|
7
|
+
* it there. `HUB_VERSION`, the fixed claim keys and version skew: docs/maintainers/architecture.md, § Several copies in one process.
|
|
29
8
|
*/
|
|
30
|
-
export const HUB_VERSION =
|
|
9
|
+
export const HUB_VERSION = 2;
|
|
31
10
|
/** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
|
|
32
11
|
export const HUB_KEY = "opencode-courier.hub";
|
|
33
12
|
const realTimers = {
|
|
@@ -35,11 +14,11 @@ const realTimers = {
|
|
|
35
14
|
stop: (timer) => clearInterval(timer),
|
|
36
15
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
37
16
|
};
|
|
17
|
+
/** How often the hub's scheduler ticks: what a job delivers, a `courier_later` message, can be this late. */
|
|
18
|
+
export const TICK_MS = 15_000;
|
|
38
19
|
/**
|
|
39
|
-
* The key
|
|
40
|
-
*
|
|
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.
|
|
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).
|
|
43
22
|
*/
|
|
44
23
|
export const OWNER_KEY = "scheduler/owner";
|
|
45
24
|
/** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
|
|
@@ -63,9 +42,8 @@ async function readOwner(ports) {
|
|
|
63
42
|
return server !== undefined && at !== undefined ? { server, at } : undefined;
|
|
64
43
|
}
|
|
65
44
|
/**
|
|
66
|
-
* How long an unloading instance waits
|
|
67
|
-
*
|
|
68
|
-
* owner key's release.
|
|
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.
|
|
69
47
|
*/
|
|
70
48
|
export const LEAVE_MS = 2_000;
|
|
71
49
|
/** Whether a key written at `at` still holds at `now`; one from the future, after the clock went back, holds as long. */
|
|
@@ -73,27 +51,18 @@ const holds = (at, now, ms) => Math.abs(now - at) < ms;
|
|
|
73
51
|
/** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
|
|
74
52
|
export const WATCHERS = 2;
|
|
75
53
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* objects are the ones under the keys of the copies before the hub, made when missing.
|
|
54
|
+
* The value under `opencode-courier.<name>` every copy in the process shares, made by `create` the first
|
|
55
|
+
* time; another version, or a copy before the hub, may have made it, so its shape is kept for good.
|
|
79
56
|
*/
|
|
80
|
-
export function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
};
|
|
57
|
+
export function sharedState(name, create, registry = globalThis) {
|
|
58
|
+
return processWide(`opencode-courier.${name}`, create, registry);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a versioned
|
|
62
|
+
* key when another version holds that; its claim sets are the pre-hub keys', made when missing.
|
|
63
|
+
*/
|
|
64
|
+
function find(registry) {
|
|
65
|
+
const shared = (name, create) => sharedState(name, create, registry);
|
|
97
66
|
const create = () => ({
|
|
98
67
|
version: HUB_VERSION,
|
|
99
68
|
members: new Set(),
|
|
@@ -104,17 +73,24 @@ export function open(registry, timers = realTimers, server = SERVER) {
|
|
|
104
73
|
forms: shared("forms", () => ({ told: new Map(), settled: new Set() })),
|
|
105
74
|
locations: shared("locations", () => new Map()),
|
|
106
75
|
receivers: shared("receiver", () => ({})),
|
|
107
|
-
|
|
76
|
+
// Filled in by `person/question/`, field by field, since a copy before the hub may have made it without them.
|
|
77
|
+
questions: shared("questions", () => ({})),
|
|
108
78
|
skewLogged: false,
|
|
109
79
|
});
|
|
110
80
|
const found = registry[Symbol.for(HUB_KEY)];
|
|
111
81
|
// The version of the hub of another copy found under `HUB_KEY`, if one is there.
|
|
112
82
|
const skew = found != null && found.version !== HUB_VERSION ? { version: found.version } : undefined;
|
|
113
|
-
|
|
83
|
+
return { hub: processWide(skew ? `${HUB_KEY}@${HUB_VERSION}` : HUB_KEY, create, registry), skew };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* This copy's hub in `registry`, and how its instances join it. Its ticks run `jobs` in order, kept in
|
|
87
|
+
* this copy's tick, which the hub runs once this copy has joined last, never in the hub itself.
|
|
88
|
+
*/
|
|
89
|
+
export function open(registry, jobs, timers = realTimers, server = SERVER) {
|
|
90
|
+
const { hub, skew } = find(registry);
|
|
114
91
|
/**
|
|
115
|
-
* Whether this server holds the owner key, taking or renewing it through `ports`.
|
|
116
|
-
* key is
|
|
117
|
-
* after a random wait: of two servers taking it at once, the one that wrote last holds it.
|
|
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.
|
|
118
94
|
*/
|
|
119
95
|
const own = async (ports) => {
|
|
120
96
|
const held = await readOwner(ports);
|
|
@@ -142,10 +118,8 @@ export function open(registry, timers = realTimers, server = SERVER) {
|
|
|
142
118
|
return other !== hub && (name === HUB_KEY || name.startsWith(`${HUB_KEY}@`)) && Number(other?.members?.size) > 0;
|
|
143
119
|
});
|
|
144
120
|
/**
|
|
145
|
-
* Removes the owner key if this server holds it, so another
|
|
146
|
-
*
|
|
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.
|
|
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.
|
|
149
123
|
*/
|
|
150
124
|
const release = async (ports) => {
|
|
151
125
|
const scheduler = hub.scheduler;
|
|
@@ -154,30 +128,33 @@ export function open(registry, timers = realTimers, server = SERVER) {
|
|
|
154
128
|
await ports.storage.remove(OWNER_KEY);
|
|
155
129
|
};
|
|
156
130
|
/**
|
|
157
|
-
* Whether the tick under way
|
|
158
|
-
*
|
|
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.
|
|
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.
|
|
162
133
|
*/
|
|
163
|
-
const
|
|
134
|
+
const busy = () => {
|
|
164
135
|
const { running, ticking } = hub.scheduler;
|
|
165
136
|
if (!running)
|
|
166
|
-
return ticking !== undefined && hub.members.has(ticking);
|
|
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();
|
|
167
146
|
const first = hub.members.values().next().value;
|
|
168
|
-
if (
|
|
147
|
+
if (state.recent && first)
|
|
169
148
|
void renew(first.later).catch((error) => first.log(`courier_later scheduler: ${String(error)}`));
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
return hub.members.has(running.member);
|
|
149
|
+
return state.busy;
|
|
173
150
|
};
|
|
174
|
-
// One tick, through the first member
|
|
175
|
-
//
|
|
176
|
-
// its own interval until it unloads, and has no owner key.
|
|
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.
|
|
177
153
|
const tick = async () => {
|
|
178
154
|
const scheduler = hub.scheduler;
|
|
179
155
|
if (underWay())
|
|
180
156
|
return;
|
|
157
|
+
scheduler.nudged = false;
|
|
181
158
|
const owner = hub.members.values().next().value;
|
|
182
159
|
if (!owner)
|
|
183
160
|
return;
|
|
@@ -202,8 +179,10 @@ export function open(registry, timers = realTimers, server = SERVER) {
|
|
|
202
179
|
catch { }
|
|
203
180
|
};
|
|
204
181
|
try {
|
|
205
|
-
if ((await own(owner.later)) && scheduler.timer !== undefined && hub.members.has(owner))
|
|
206
|
-
|
|
182
|
+
if ((await own(owner.later)) && scheduler.timer !== undefined && hub.members.has(owner)) {
|
|
183
|
+
for (const job of jobs)
|
|
184
|
+
await job(owner, hub.claimed);
|
|
185
|
+
}
|
|
207
186
|
}
|
|
208
187
|
catch (error) {
|
|
209
188
|
tell(`courier_later scheduler: ${String(error)}`);
|
|
@@ -219,6 +198,18 @@ export function open(registry, timers = realTimers, server = SERVER) {
|
|
|
219
198
|
if (scheduler.timer === undefined)
|
|
220
199
|
await release(owner.later).catch((error) => tell(`courier_later scheduler: owner key not released: ${String(error)}`));
|
|
221
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)();
|
|
222
213
|
};
|
|
223
214
|
// Subscriptions through the earliest members without one, the longest loaded, until there are
|
|
224
215
|
// `WATCHERS`; never through `leaving`.
|
|
@@ -262,9 +253,8 @@ export function open(registry, timers = realTimers, server = SERVER) {
|
|
|
262
253
|
const behind = [...(work.get(member) ?? [])];
|
|
263
254
|
if (scheduler.running?.member === member)
|
|
264
255
|
behind.push(scheduler.running.done);
|
|
265
|
-
// The other subscription, already connected, follows the events meanwhile; this one is
|
|
266
|
-
//
|
|
267
|
-
// It ends once the event it is handling, whose notice may be going out, has been handled.
|
|
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.
|
|
268
258
|
const index = hub.watchers.findIndex((watcher) => watcher.member === member);
|
|
269
259
|
if (index >= 0) {
|
|
270
260
|
const [leaving] = hub.watchers.splice(index, 1);
|
|
@@ -286,15 +276,17 @@ export function open(registry, timers = realTimers, server = SERVER) {
|
|
|
286
276
|
return Promise.race([Promise.allSettled(behind).then(() => { }), timers.wait(LEAVE_MS)]);
|
|
287
277
|
};
|
|
288
278
|
};
|
|
289
|
-
return {
|
|
279
|
+
return {
|
|
280
|
+
hub,
|
|
281
|
+
join,
|
|
282
|
+
track,
|
|
283
|
+
nudge,
|
|
284
|
+
};
|
|
290
285
|
}
|
|
291
|
-
const opened = open(globalThis);
|
|
292
286
|
/** This copy's hub, in the process. */
|
|
293
|
-
export const hub =
|
|
294
|
-
/**
|
|
295
|
-
export const
|
|
296
|
-
/** Hands this copy's hub work a member started; see `Opened.track`. */
|
|
297
|
-
export const track = opened.track;
|
|
287
|
+
export const hub = find(globalThis).hub;
|
|
288
|
+
/** Opens this copy's hub in the process, with its tick jobs; see `open`. */
|
|
289
|
+
export const openInProcess = (jobs) => open(globalThis, jobs);
|
|
298
290
|
/** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
|
|
299
291
|
export function memberAt(location) {
|
|
300
292
|
let found;
|
|
@@ -303,38 +295,5 @@ export function memberAt(location) {
|
|
|
303
295
|
found = member;
|
|
304
296
|
return found;
|
|
305
297
|
}
|
|
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
298
|
/** The permission domains of the loaded instances. */
|
|
316
299
|
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
|
-
}
|