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/README.md +9 -7
- package/index.ts +3 -1
- package/package.json +3 -2
- package/src/config.ts +20 -1
- package/src/http.ts +54 -11
- package/src/openai/request.ts +1 -0
- package/src/openai/response.ts +50 -1
- package/src/openai/route.ts +25 -8
- package/src/openai/sampling.ts +96 -0
- package/src/responses/request.ts +377 -0
- package/src/responses/response.ts +225 -0
- package/src/responses/route.ts +72 -0
- package/src/runtime.ts +76 -4
- package/src/session/host.ts +40 -0
- package/src/session/prune.ts +175 -0
- package/src/session/state.ts +11 -1
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
|
-
/**
|
|
87
|
-
const VERSION =
|
|
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 = {
|
|
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),
|
package/src/session/host.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/session/state.ts
CHANGED
|
@@ -67,7 +67,12 @@ interface Entry {
|
|
|
67
67
|
timer?: ReturnType<typeof setTimeout>
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export
|
|
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,
|