opencode-plugboard 0.2.1 → 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/src/runtime.ts CHANGED
@@ -18,7 +18,10 @@
18
18
  * scope, forwarding bus events of request sessions to the session store. If it dies, the
19
19
  * requests in flight fail with a 502 instead of waiting for their deadline;
20
20
  * - the session store (src/session/state.ts), which outlives the listener and owns the request
21
- * guards, their cleanup timers included.
21
+ * guards, their cleanup timers included;
22
+ * - the history sweep state: every registered request session is indexed in the host's plugin
23
+ * storage and, at most every `SWEEP_INTERVAL_MS`, a sweep prunes old ones past the `history`
24
+ * limits (src/session/prune.ts). Both start from request handling (the store's `register`).
22
25
  *
23
26
  * Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
24
27
  * rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
@@ -42,6 +45,8 @@ import { Cause, Effect, Stream } from "effect"
42
45
  import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
43
46
  import { errorMessage, log, logError } from "./log.js"
44
47
  import { ensureAppDir } from "./session/history.js"
48
+ import type { HistoryPort } from "./session/host.js"
49
+ import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
45
50
  import { createSessionStore, type SessionStore } from "./session/state.js"
46
51
 
47
52
  export type Handler = (request: IncomingMessage, response: ServerResponse) => void
@@ -76,15 +81,23 @@ export interface GenerationInput {
76
81
  * never from setup or a finalizer.
77
82
  */
78
83
  readonly refresh: () => Promise<void>
84
+ /** The host storage and session deletion used to index and prune request sessions, with the configured limits (identical in every accepted generation). */
85
+ readonly history: { readonly port: HistoryPort; readonly limits: HistoryLimits }
79
86
  }
80
87
 
81
88
  const GRACE_MS = 10_000
89
+ /** Earliest next history sweep after one ran (sweeps are triggered by requests, never by a timer). */
90
+ const SWEEP_INTERVAL_MS = 10 * 60_000
91
+ /** Earliest next sweep when one left work behind (its budget ran out). */
92
+ const SWEEP_BACKLOG_MS = 60_000
93
+ /** A sweep stops starting host calls after this long; a hung one stops blocking later sweeps 5 s after. */
94
+ const SWEEP_DEADLINE_MS = 60_000
82
95
  /** How long `ensureBackend` waits for a Location to boot and its generation to become ready. */
83
96
  const BACKEND_TIMEOUT_MS = 15_000
84
97
  const BACKEND_POLL_MS = 25
85
98
 
86
- /** 3: the runtime tracks confirmed directories and rejected generations (a version-2 runtime lacks both). */
87
- const VERSION = 3
99
+ /** 4: adds the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
100
+ const VERSION = 4
88
101
  const KEY = Symbol.for("opencode-plugboard.runtime")
89
102
 
90
103
  interface Listener {
@@ -102,6 +115,7 @@ interface Generation {
102
115
  readonly handler: Handler
103
116
  readonly loadCatalog: GenerationInput["loadCatalog"]
104
117
  readonly refresh: GenerationInput["refresh"]
118
+ readonly history: GenerationInput["history"]
105
119
  /** The context hook and transforms are installed (setup got to its end). */
106
120
  hooked: boolean
107
121
  /** The transforms have been rebuilt since `confirmed` became true for the directory. */
@@ -124,6 +138,8 @@ interface Runtime {
124
138
  readonly confirmed: Set<string>
125
139
  /** Live rejected generations per directory: tells "rejected" from "no generation yet". */
126
140
  readonly rejected: Map<string, number>
141
+ /** History sweep rate limit: no sweep starts before `nextAt` (epoch ms) or while one `running`. */
142
+ readonly sweep: { nextAt: number; running: boolean }
127
143
  listener?: Listener
128
144
  /** Present while a fiber is subscribed to the server event bus. */
129
145
  pump?: Pump
@@ -140,7 +156,14 @@ function runtime(): Runtime | undefined {
140
156
  const slot = globalThis as Record<symbol, unknown>
141
157
  const existing = slot[KEY] as { readonly version: number } | undefined
142
158
  if (!existing) {
143
- const created: Runtime = { version: VERSION, sessions: createSessionStore(), generations: [], confirmed: new Set(), rejected: new Map() }
159
+ const created: Runtime = {
160
+ version: VERSION,
161
+ sessions: createSessionStore({ onRegister: (state) => trackSession(created, state.app, state.sessionID) }),
162
+ generations: [],
163
+ confirmed: new Set(),
164
+ rejected: new Map(),
165
+ sweep: { nextAt: 0, running: false },
166
+ }
144
167
  slot[KEY] = created
145
168
  return created
146
169
  }
@@ -152,6 +175,54 @@ function runtime(): Runtime | undefined {
152
175
  return undefined
153
176
  }
154
177
 
178
+ /**
179
+ * A request session was just created: index it, then sweep if due. Runs from request handling
180
+ * (the store's `register`), never awaited, never throws. Uses the preferred generation's host
181
+ * handles; the storage and `session.remove` are server-wide, so any live generation works.
182
+ */
183
+ function trackSession(rt: Runtime, app: string, sessionID: string): void {
184
+ const history = preferred(rt)?.history
185
+ if (!history) return logError("history.index_skipped", { app, sessionID, reason: "no live generation" })
186
+ // No limits, no index: an unbounded index would grow opencode.db by a row per request forever (#85).
187
+ if (history.limits.maxSessions === undefined && history.limits.maxAgeDays === undefined) return
188
+ void recordSession(history.port, app, sessionID, Date.now())
189
+ .catch((error) => logError("history.index_failed", { app, sessionID, error: errorMessage(error) }))
190
+ .then(() => maybeSweep(rt, history))
191
+ }
192
+
193
+ /**
194
+ * Run a history sweep when limits are set, none is running and the rate limit allows. Bounded:
195
+ * see `sweepHistory`. A sweep that leaves work behind is followed up after `SWEEP_BACKLOG_MS`,
196
+ * a complete one after `SWEEP_INTERVAL_MS`. Logs one line with counts only.
197
+ */
198
+ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Promise<void> {
199
+ const { limits, port } = history
200
+ if (limits.maxSessions === undefined && limits.maxAgeDays === undefined) return
201
+ const now = Date.now()
202
+ if (rt.sweep.running || now < rt.sweep.nextAt) return
203
+ rt.sweep.running = true
204
+ rt.sweep.nextAt = now + SWEEP_INTERVAL_MS
205
+ const deadline = AbortSignal.timeout(SWEEP_DEADLINE_MS)
206
+ let timer: ReturnType<typeof setTimeout> | undefined
207
+ try {
208
+ const result = await Promise.race([
209
+ sweepHistory({ port, limits, sessions: rt.sessions, now, signal: deadline }),
210
+ new Promise<undefined>((resolve) => {
211
+ timer = setTimeout(() => resolve(undefined), SWEEP_DEADLINE_MS + 5_000)
212
+ timer.unref?.()
213
+ }),
214
+ ])
215
+ if (!result) return logError("history.sweep_timeout", { limit: SWEEP_DEADLINE_MS })
216
+ if (result.more) rt.sweep.nextAt = Date.now() + SWEEP_BACKLOG_MS
217
+ log("history.swept", { ...result, durationMs: Date.now() - now })
218
+ } catch (error) {
219
+ logError("history.sweep_failed", { error: errorMessage(error) })
220
+ } finally {
221
+ clearTimeout(timer)
222
+ rt.sweep.running = false
223
+ }
224
+ }
225
+
155
226
  /** The session store shared by every generation in the process. */
156
227
  export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedSessions
157
228
 
@@ -290,6 +361,7 @@ export function registerGeneration(input: GenerationInput): Registration {
290
361
  handler: input.handler,
291
362
  loadCatalog: input.loadCatalog,
292
363
  refresh: input.refresh,
364
+ history: input.history,
293
365
  hooked: false,
294
366
  // A directory confirmed before this Location booted needs no rebuild: its transforms already read `confirmed()` as true.
295
367
  activated: rt.confirmed.has(directory),
@@ -89,6 +89,46 @@ export function createSessionPort(ctx: HostContext): SessionPort {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * What the history index needs from the host: the plugin's own key-value storage (`ctx.storage`,
94
+ * namespaced by plugin id and shared by every Location of the server) and `session.remove`. The
95
+ * plugin session API has no `list`, so plugboard indexes its own request sessions in that storage
96
+ * and prunes from the index (src/session/prune.ts).
97
+ */
98
+ export interface HistoryPort {
99
+ put(key: string, value: string): Promise<void>
100
+ drop(key: string): Promise<void>
101
+ /** Keys under `prefix` in ascending order, after `after` (exclusive); `next` is set when more remain. */
102
+ scan(input: { readonly prefix: string; readonly after?: string; readonly limit: number }): Promise<{ readonly keys: ReadonlyArray<string>; readonly next?: string }>
103
+ /** `missing` when the session no longer exists (the host's `Session.NotFoundError`); any other failure rejects. */
104
+ removeSession(sessionID: string): Promise<"removed" | "missing">
105
+ }
106
+
107
+ const failureTag = (error: unknown): unknown => (typeof error === "object" && error !== null ? (error as { _tag?: unknown })._tag : undefined)
108
+
109
+ /** `ctx.storage` and `ctx.session.remove` as a Promise port. The branded session ID is the same host-issued string as above. */
110
+ export function createHistoryPort(ctx: HostContext): HistoryPort {
111
+ return {
112
+ put: (key, value) => Effect.runPromise(ctx.storage.set(key, value)),
113
+ drop: (key) => Effect.runPromise(ctx.storage.remove(key)),
114
+ scan: (input) =>
115
+ Effect.runPromise(ctx.storage.scan(input)).then((result) => ({
116
+ keys: result.entries.map((entry) => entry.key),
117
+ ...(result.next === undefined ? {} : { next: result.next }),
118
+ })),
119
+ removeSession: (sessionID) =>
120
+ Effect.runPromise(
121
+ ctx.session.remove({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["remove"]>[0]).pipe(
122
+ Effect.match({ onSuccess: () => undefined, onFailure: (error) => ({ error }) }),
123
+ ),
124
+ ).then((failed) => {
125
+ if (failed === undefined) return "removed" as const
126
+ if (failureTag(failed.error) === "Session.NotFoundError") return "missing" as const
127
+ throw failed.error
128
+ }),
129
+ }
130
+ }
131
+
92
132
  const record = (value: unknown): Record<string, unknown> | undefined =>
93
133
  typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
94
134
 
@@ -0,0 +1,175 @@
1
+ /**
2
+ * History pruning: an index of plugboard's own request sessions and the sweep that deletes the
3
+ * old ones.
4
+ *
5
+ * The plugin session API has no `list`, so plugboard cannot ask the host which sessions it owns.
6
+ * It keeps an index in the plugin's own host storage (`ctx.storage`, a key-value table in the
7
+ * host database, shared by every Location): one key per request session,
8
+ *
9
+ * history/<app>/<created, 15 digits of epoch ms>/<sessionID>
10
+ *
11
+ * Everything the sweep needs is in the key, so a scan returns an app's sessions oldest first.
12
+ * Sessions created before this index existed are not in it and are never pruned.
13
+ *
14
+ * Both functions run from request handling only (the runtime calls them), never from plugin
15
+ * setup or a finalizer. A sweep does bounded work: it reads at most `maxScan` index entries per
16
+ * app, makes at most `maxDeletes` host deletions and stops at its deadline; what is left waits for
17
+ * the next sweep. A session whose request guard is still in the session store (the host
18
+ * execution has not ended) is never deleted.
19
+ */
20
+
21
+ import type { HistoryPort } from "./host.js"
22
+ import type { SessionStore } from "./state.js"
23
+
24
+ /** Pruning limits. A field that is unset is not applied; with none set nothing is ever deleted. */
25
+ export interface HistoryLimits {
26
+ /** Keep at most this many sessions per app (the newest). */
27
+ readonly maxSessions?: number
28
+ /** Delete sessions older than this many days. */
29
+ readonly maxAgeDays?: number
30
+ }
31
+
32
+ export interface SweepResult {
33
+ readonly apps: number
34
+ /** Index entries read. */
35
+ readonly scanned: number
36
+ readonly removed: number
37
+ /** Already gone from the host (deleted by hand); only the index entry was dropped. */
38
+ readonly missing: number
39
+ /** Host deletions or index updates that failed; retried by the next sweep. */
40
+ readonly failed: number
41
+ /** Over a limit but skipped because the request guard is still active. */
42
+ readonly active: number
43
+ /** Work was left (budget, deadline or scan cap reached): run the next sweep soon. */
44
+ readonly more: boolean
45
+ }
46
+
47
+ export interface SweepInput {
48
+ readonly port: HistoryPort
49
+ readonly limits: HistoryLimits
50
+ readonly sessions: SessionStore
51
+ readonly now: number
52
+ /** Aborts the sweep between host calls. */
53
+ readonly signal: AbortSignal
54
+ /** Most host deletions per sweep. */
55
+ readonly maxDeletes?: number
56
+ /** Most index entries read per app per sweep. */
57
+ readonly maxScan?: number
58
+ }
59
+
60
+ const PREFIX = "history/"
61
+ const AT_WIDTH = 15
62
+ const DAY_MS = 86_400_000
63
+ const PAGE = 500
64
+ const MAX_DELETES = 200
65
+ const MAX_SCAN = 20_000
66
+
67
+ export const indexKey = (app: string, createdAt: number, sessionID: string): string =>
68
+ `${PREFIX}${app}/${String(Math.max(0, Math.floor(createdAt))).padStart(AT_WIDTH, "0")}/${sessionID}`
69
+
70
+ /** Index one new request session. The value repeats the ID; the key carries app and time. */
71
+ export const recordSession = (port: HistoryPort, app: string, sessionID: string, createdAt: number): Promise<void> =>
72
+ port.put(indexKey(app, createdAt, sessionID), sessionID)
73
+
74
+ interface Entry {
75
+ readonly key: string
76
+ readonly sessionID: string
77
+ readonly createdAt: number
78
+ }
79
+
80
+ function parseKey(key: string): Entry | undefined {
81
+ const [, at, ...rest] = key.slice(PREFIX.length).split("/")
82
+ const createdAt = Number(at)
83
+ const sessionID = rest.join("/")
84
+ if (!/^\d+$/.test(at ?? "") || !Number.isFinite(createdAt) || sessionID.length === 0) return undefined
85
+ return { key, sessionID, createdAt }
86
+ }
87
+
88
+ /** App names in the index, found by hopping over each app's keys (one tiny scan per app). */
89
+ async function indexedApps(port: HistoryPort, signal: AbortSignal): Promise<string[]> {
90
+ const apps: string[] = []
91
+ let after: string | undefined
92
+ while (!signal.aborted) {
93
+ const page = await port.scan({ prefix: PREFIX, ...(after === undefined ? {} : { after }), limit: 1 })
94
+ const first = page.keys[0]
95
+ if (first === undefined) break
96
+ const app = first.slice(PREFIX.length).split("/")[0] ?? ""
97
+ if (app.length > 0) apps.push(app)
98
+ // `\uffff` sorts after every character of an ID or timestamp, so this skips the rest of the app.
99
+ after = `${PREFIX}${app}/\uffff`
100
+ }
101
+ return apps
102
+ }
103
+
104
+ /** One app's entries, oldest first, at most `maxScan`; `capped` when more remain in the index. */
105
+ async function readApp(port: HistoryPort, app: string, maxScan: number, signal: AbortSignal): Promise<{ entries: Entry[]; capped: boolean }> {
106
+ const entries: Entry[] = []
107
+ const prefix = `${PREFIX}${app}/`
108
+ let after: string | undefined
109
+ while (!signal.aborted) {
110
+ const page = await port.scan({ prefix, ...(after === undefined ? {} : { after }), limit: Math.min(PAGE, maxScan - entries.length) })
111
+ for (const key of page.keys) {
112
+ const entry = parseKey(key)
113
+ if (entry) entries.push(entry)
114
+ }
115
+ if (page.next === undefined) return { entries, capped: false }
116
+ if (entries.length >= maxScan) return { entries, capped: true }
117
+ after = page.next
118
+ }
119
+ return { entries, capped: true }
120
+ }
121
+
122
+ /**
123
+ * One pass over the index. Per app, the sessions to delete are a prefix of its oldest-first list:
124
+ * as many as exceed `maxSessions`, or are older than `maxAgeDays`, whichever is more. A session
125
+ * with an active request guard is skipped (it still counts as kept). Entries of sessions the host
126
+ * no longer has are dropped. A scan that hit `maxScan` only undercounts the app, so it can only
127
+ * delete fewer sessions than the limit asks for, never more.
128
+ */
129
+ export async function sweepHistory(input: SweepInput): Promise<SweepResult> {
130
+ const { port, limits, sessions, signal } = input
131
+ const maxDeletes = input.maxDeletes ?? MAX_DELETES
132
+ const maxScan = input.maxScan ?? MAX_SCAN
133
+ const cutoff = limits.maxAgeDays === undefined ? undefined : input.now - limits.maxAgeDays * DAY_MS
134
+ let scanned = 0
135
+ let removed = 0
136
+ let missing = 0
137
+ let failed = 0
138
+ let active = 0
139
+ let attempts = 0
140
+ let more = false
141
+
142
+ const apps = await indexedApps(port, signal)
143
+ for (const app of apps) {
144
+ if (signal.aborted) {
145
+ more = true
146
+ break
147
+ }
148
+ const { entries, capped } = await readApp(port, app, maxScan, signal)
149
+ scanned += entries.length
150
+ if (capped) more = true
151
+ const excess = limits.maxSessions === undefined ? 0 : Math.max(0, entries.length - limits.maxSessions)
152
+ const expired = cutoff === undefined ? 0 : entries.findIndex((entry) => entry.createdAt >= cutoff)
153
+ const count = Math.max(excess, cutoff === undefined ? 0 : expired === -1 ? entries.length : expired)
154
+ for (const entry of entries.slice(0, count)) {
155
+ if (signal.aborted || attempts >= maxDeletes) {
156
+ more = true
157
+ break
158
+ }
159
+ // Checked right before the host call: a guard that is still registered means the host execution is running.
160
+ if (sessions.lookup(entry.sessionID)) {
161
+ active++
162
+ continue
163
+ }
164
+ attempts++
165
+ try {
166
+ if ((await port.removeSession(entry.sessionID)) === "removed") removed++
167
+ else missing++
168
+ await port.drop(entry.key)
169
+ } catch {
170
+ failed++
171
+ }
172
+ }
173
+ }
174
+ return { apps: apps.length, scanned, removed, missing, failed, active, more }
175
+ }
@@ -67,7 +67,12 @@ interface Entry {
67
67
  timer?: ReturnType<typeof setTimeout>
68
68
  }
69
69
 
70
- export function createSessionStore(): SessionStore {
70
+ export interface SessionStoreOptions {
71
+ /** Called once per `register`, from request handling (the runtime indexes the session for pruning). Must not block; errors are logged. */
72
+ readonly onRegister?: (state: RequestState) => void
73
+ }
74
+
75
+ export function createSessionStore(options: SessionStoreOptions = {}): SessionStore {
71
76
  const entries = new Map<string, Entry>()
72
77
 
73
78
  const release = (sessionID: string): boolean => {
@@ -82,6 +87,11 @@ export function createSessionStore(): SessionStore {
82
87
  const previous = entries.get(state.sessionID)
83
88
  if (previous?.timer) clearTimeout(previous.timer)
84
89
  entries.set(state.sessionID, { state, startedAt: Date.now() })
90
+ try {
91
+ options.onRegister?.(state)
92
+ } catch (error) {
93
+ logError("session.register_hook_failed", { error: errorMessage(error) })
94
+ }
85
95
  },
86
96
  lookup: (sessionID) => entries.get(sessionID)?.state,
87
97
  release,