opencode-plugboard 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/index.ts +101 -0
- package/package.json +33 -4
- package/src/config.ts +164 -0
- package/src/contract.ts +155 -0
- package/src/http.ts +324 -0
- package/src/log.ts +28 -0
- package/src/models.ts +100 -0
- package/src/openai/request.ts +334 -0
- package/src/openai/response.ts +286 -0
- package/src/openai/route.ts +216 -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 +535 -0
- package/src/session/caller.ts +192 -0
- package/src/session/context.ts +175 -0
- package/src/session/events.ts +344 -0
- package/src/session/history.ts +89 -0
- package/src/session/host.ts +215 -0
- package/src/session/media.ts +89 -0
- package/src/session/prune.ts +175 -0
- package/src/session/seeds.ts +47 -0
- package/src/session/state.ts +144 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded attachment preparation: request images become prompt files.
|
|
3
|
+
*
|
|
4
|
+
* Prompt files must be inline `data:` URLs. http(s) images are fetched once, only when the
|
|
5
|
+
* `remoteImages` option is on, with no redirects, a 20 s timeout and a 20 MB cap. All images of
|
|
6
|
+
* one request together may hold at most 50 MB (`images_too_large`), data URLs and fetched ones alike.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PlugRequest } from "../contract.js"
|
|
10
|
+
|
|
11
|
+
const MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024
|
|
12
|
+
/** Aggregate size of the prepared data URLs of one request. */
|
|
13
|
+
const MAX_TOTAL_IMAGE_BYTES = 50 * 1024 * 1024
|
|
14
|
+
const TOO_MANY = () => new ImageError("images_too_large", "The request's images are larger than 50 MB in total")
|
|
15
|
+
|
|
16
|
+
/** What one request's images may still use, in data URL characters (== bytes held). */
|
|
17
|
+
interface Budget {
|
|
18
|
+
remaining: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A caller image that cannot be used; `code` becomes the error code of the 400 response. */
|
|
22
|
+
export class ImageError extends Error {
|
|
23
|
+
constructor(
|
|
24
|
+
readonly code: string,
|
|
25
|
+
message: string,
|
|
26
|
+
) {
|
|
27
|
+
super(message)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function toDataUrl(url: string, signal: AbortSignal, remoteImages: boolean, budget: Budget): Promise<string> {
|
|
32
|
+
if (url.startsWith("data:")) {
|
|
33
|
+
if (url.length > budget.remaining) throw TOO_MANY()
|
|
34
|
+
return url
|
|
35
|
+
}
|
|
36
|
+
if (!/^https?:\/\//i.test(url)) throw new ImageError("unsupported_image_url", "Unsupported image URL scheme; use a data: URL or an http(s) URL")
|
|
37
|
+
if (!remoteImages)
|
|
38
|
+
throw new ImageError("remote_images_disabled", "Remote image URLs are disabled; send a data: URL or set the plugboard `remoteImages` option")
|
|
39
|
+
// Messages never carry the URL or the fetch error text: both can hold credentials in the query string.
|
|
40
|
+
const response = await fetch(url, { redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]) }).catch((error: unknown) => {
|
|
41
|
+
const reason = error instanceof Error && error.name === "TimeoutError" ? "timed out" : "network error"
|
|
42
|
+
throw new ImageError("image_fetch_failed", `Could not fetch image (${reason})`)
|
|
43
|
+
})
|
|
44
|
+
if (!response.ok) throw new ImageError("image_fetch_failed", `Could not fetch image (HTTP ${response.status})`)
|
|
45
|
+
const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? ""
|
|
46
|
+
if (!mediaType.startsWith("image/")) throw new ImageError("invalid_image", `URL did not return an image (content-type: ${mediaType || "none"})`)
|
|
47
|
+
const declared = Number(response.headers.get("content-length"))
|
|
48
|
+
// The data URL is 4/3 of the raw size plus a short prefix: stop reading once it cannot fit the budget.
|
|
49
|
+
const budgetRaw = Math.floor((budget.remaining * 3) / 4)
|
|
50
|
+
const limit = Math.min(MAX_REMOTE_IMAGE_BYTES, budgetRaw)
|
|
51
|
+
const tooLarge = () => (limit < MAX_REMOTE_IMAGE_BYTES ? TOO_MANY() : new ImageError("image_too_large", "Image is larger than 20 MB"))
|
|
52
|
+
if (Number.isFinite(declared) && declared > limit) throw tooLarge()
|
|
53
|
+
const chunks: Uint8Array[] = []
|
|
54
|
+
let size = 0
|
|
55
|
+
const reader = response.body?.getReader()
|
|
56
|
+
if (!reader) throw new ImageError("invalid_image", "Image response had no body")
|
|
57
|
+
while (true) {
|
|
58
|
+
const { done, value } = await reader.read()
|
|
59
|
+
if (done) break
|
|
60
|
+
size += value.byteLength
|
|
61
|
+
if (size > limit) {
|
|
62
|
+
void reader.cancel().catch(() => {})
|
|
63
|
+
throw tooLarge()
|
|
64
|
+
}
|
|
65
|
+
chunks.push(value)
|
|
66
|
+
}
|
|
67
|
+
const dataUrl = `data:${mediaType};base64,${Buffer.concat(chunks).toString("base64")}`
|
|
68
|
+
if (dataUrl.length > budget.remaining) throw TOO_MANY()
|
|
69
|
+
return dataUrl
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Prompt files for every image in the request, in message order, named `image-1`, `image-2`, ... Throws `ImageError`. */
|
|
73
|
+
export async function prepareImages(
|
|
74
|
+
request: PlugRequest,
|
|
75
|
+
signal: AbortSignal,
|
|
76
|
+
remoteImages: boolean,
|
|
77
|
+
): Promise<Array<{ uri: string; name: string }>> {
|
|
78
|
+
const images: Array<{ uri: string; name: string }> = []
|
|
79
|
+
const budget: Budget = { remaining: MAX_TOTAL_IMAGE_BYTES }
|
|
80
|
+
for (const message of request.messages)
|
|
81
|
+
for (const part of message.content)
|
|
82
|
+
if (part.type === "media") {
|
|
83
|
+
signal.throwIfAborted()
|
|
84
|
+
const uri = await toDataUrl(part.url, signal, remoteImages, budget)
|
|
85
|
+
budget.remaining -= uri.length
|
|
86
|
+
images.push({ uri, name: `image-${images.length + 1}` })
|
|
87
|
+
}
|
|
88
|
+
return images
|
|
89
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed tools.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode's `session.hook("context")` may only advertise tool definitions that
|
|
5
|
+
* correspond to a *registered* Tool.Info (unmatched entries are dropped), so plugboard owns a
|
|
6
|
+
* pool of registered seeds (`maxTools` of them) and re-keys them in the hook:
|
|
7
|
+
* the caller's name becomes the map key (what the provider sees), while the seed keeps its
|
|
8
|
+
* own `name` (what the executor dispatches on).
|
|
9
|
+
*
|
|
10
|
+
* A seed's `execute` returns at once with `SEED_RESULT`, so history shows the call as
|
|
11
|
+
* completed. A tool result always makes the runner start a second model step
|
|
12
|
+
* (`record.needsContinuation`, runner/step.ts), and no tool result, hook or agent setting
|
|
13
|
+
* ends the loop (failing the seed, even with a typed non-`Tool.Error`, is converted to a
|
|
14
|
+
* normal tool failure, and `Permission.DeclinedError` is only reachable from host tools).
|
|
15
|
+
* So plugboard stops the turn itself: once the step has settled with a normal `tool-calls`
|
|
16
|
+
* finish (`session.step.ended`) it interrupts the session. Nothing orders that interrupt
|
|
17
|
+
* before the runner's next step, so a second step can occasionally start and be wasted (extra
|
|
18
|
+
* tokens, a stray assistant message in history, never a wrong response; tracked in issue #14).
|
|
19
|
+
* The captured `session.tool.called` events are what a `tool_calls` response needs.
|
|
20
|
+
* Seeds are registered only inside the plugboard data root, denied to every agent there,
|
|
21
|
+
* and allowed again by the permissions of plugboard's own request sessions. Those permissions
|
|
22
|
+
* deny every other tool (defense in depth: if a second step ever runs outside the context
|
|
23
|
+
* hook's replacement, it cannot run real OpenCode tools).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { PlugRequest, PlugTool } from "../contract.js"
|
|
27
|
+
|
|
28
|
+
export const SEED_PREFIX = "plugboard_seed_"
|
|
29
|
+
|
|
30
|
+
/** Output of every seed tool call: the caller, not OpenCode, runs the tool. */
|
|
31
|
+
export const SEED_RESULT = "Returned to caller (opencode-plugboard)"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Session permissions of a request session. The host takes the last matching rule (session rules
|
|
35
|
+
* come after the agent's): everything is denied, then the seeds are allowed again.
|
|
36
|
+
*/
|
|
37
|
+
export const REQUEST_PERMISSIONS: ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }> = [
|
|
38
|
+
{ action: "*", resource: "*", effect: "deny" },
|
|
39
|
+
{ action: `${SEED_PREFIX}*`, resource: "*", effect: "allow" },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/** The caller tools that get a seed in the model request (`tool_choice` none offers none, a named tool only that one). */
|
|
43
|
+
export function seedToolsFor(request: PlugRequest): ReadonlyArray<PlugTool> {
|
|
44
|
+
const choice = request.toolChoice
|
|
45
|
+
if (choice === "none") return []
|
|
46
|
+
return typeof choice === "object" ? request.tools.filter((tool) => tool.name === choice.name) : request.tools
|
|
47
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session ownership store shared across plugin generations.
|
|
3
|
+
*
|
|
4
|
+
* The generation that serves an HTTP request and the generation inside the app directory that
|
|
5
|
+
* runs its session are different plugin instances (one per Location), and either can be replaced
|
|
6
|
+
* at any time. So per-request state is kept by session ID in one store owned by the process
|
|
7
|
+
* runtime (src/runtime.ts): the serving side registers it, the app-directory hook looks it up,
|
|
8
|
+
* and the process-wide event pump dispatches bus events to the request's event sink. Events of
|
|
9
|
+
* sessions that are not registered are dropped before anything is buffered.
|
|
10
|
+
*
|
|
11
|
+
* An entry is the request guard: while it exists, the context hook replaces the model request
|
|
12
|
+
* and tools. It outlives the HTTP response. It is released when the host reports the end of the
|
|
13
|
+
* session's execution (`session.execution.*`), or, after the response finished, when the bounded
|
|
14
|
+
* cleanup of `retire` runs out. Timers here are unref'd and owned by the process runtime's store,
|
|
15
|
+
* never by a plugin finalizer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { PlugError, PlugRequest } from "../contract.js"
|
|
19
|
+
import { errorMessage, logError } from "../log.js"
|
|
20
|
+
|
|
21
|
+
/** Receives the flattened bus events of one session. The collector implements it. */
|
|
22
|
+
export interface EventSink {
|
|
23
|
+
push(event: Record<string, unknown>): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Synthetic event (`{ type, error: PlugError }`) that fails a request from inside the process: lost event stream, preparation mismatch. */
|
|
27
|
+
export const FAILURE_EVENT = "plugboard.failure"
|
|
28
|
+
|
|
29
|
+
export interface RequestState {
|
|
30
|
+
readonly sessionID: string
|
|
31
|
+
readonly app: string
|
|
32
|
+
readonly requestID: string
|
|
33
|
+
readonly request: PlugRequest
|
|
34
|
+
readonly collector: EventSink
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Retirement {
|
|
38
|
+
/** How long the guard may outlive the response while the host execution has not ended. */
|
|
39
|
+
readonly timeoutMs: number
|
|
40
|
+
/** Called once if the timeout runs out (the host never confirmed the end of the execution). Must not block. */
|
|
41
|
+
onTimeout(): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SessionStore {
|
|
45
|
+
/** Own a request session; replaces any state registered under the same session ID. */
|
|
46
|
+
register(state: RequestState): void
|
|
47
|
+
lookup(sessionID: string): RequestState | undefined
|
|
48
|
+
/** Route one bus envelope (`{ type, data: { sessionID, ... } }`) to its request's sink, if any. Releases the session when its execution ended. */
|
|
49
|
+
dispatch(event: unknown): void
|
|
50
|
+
/** Stop owning a session; false when it was not registered. */
|
|
51
|
+
release(sessionID: string): boolean
|
|
52
|
+
/**
|
|
53
|
+
* The response is over: keep the guard until the host reports the end of the execution, or
|
|
54
|
+
* until `timeoutMs` passes, which is logged and released after `onTimeout`. No-op when the
|
|
55
|
+
* session is already released.
|
|
56
|
+
*/
|
|
57
|
+
retire(sessionID: string, retirement: Retirement): void
|
|
58
|
+
/** Fail every registered request with `error`; returns how many were registered. */
|
|
59
|
+
failAll(error: PlugError): number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const EXECUTION_END = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
|
|
63
|
+
|
|
64
|
+
interface Entry {
|
|
65
|
+
readonly state: RequestState
|
|
66
|
+
readonly startedAt: number
|
|
67
|
+
timer?: ReturnType<typeof setTimeout>
|
|
68
|
+
}
|
|
69
|
+
|
|
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 {
|
|
76
|
+
const entries = new Map<string, Entry>()
|
|
77
|
+
|
|
78
|
+
const release = (sessionID: string): boolean => {
|
|
79
|
+
const entry = entries.get(sessionID)
|
|
80
|
+
if (!entry) return false
|
|
81
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
82
|
+
return entries.delete(sessionID)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
register(state) {
|
|
87
|
+
const previous = entries.get(state.sessionID)
|
|
88
|
+
if (previous?.timer) clearTimeout(previous.timer)
|
|
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
|
+
}
|
|
95
|
+
},
|
|
96
|
+
lookup: (sessionID) => entries.get(sessionID)?.state,
|
|
97
|
+
release,
|
|
98
|
+
dispatch(event) {
|
|
99
|
+
const envelope = event as { type?: unknown; data?: Record<string, unknown> } | null
|
|
100
|
+
const sessionID = envelope?.data?.sessionID
|
|
101
|
+
if (typeof sessionID !== "string") return
|
|
102
|
+
const entry = entries.get(sessionID)
|
|
103
|
+
if (!entry) return
|
|
104
|
+
try {
|
|
105
|
+
entry.state.collector.push({ type: envelope?.type, ...envelope?.data })
|
|
106
|
+
} catch (error) {
|
|
107
|
+
logError("session.event_failed", { error: errorMessage(error) })
|
|
108
|
+
}
|
|
109
|
+
if (typeof envelope?.type === "string" && EXECUTION_END.has(envelope.type)) release(sessionID)
|
|
110
|
+
},
|
|
111
|
+
retire(sessionID, retirement) {
|
|
112
|
+
const entry = entries.get(sessionID)
|
|
113
|
+
if (!entry || entry.timer) return
|
|
114
|
+
entry.timer = setTimeout(() => {
|
|
115
|
+
if (entries.get(sessionID) !== entry) return
|
|
116
|
+
logError("session.cleanup_timeout", {
|
|
117
|
+
sessionID,
|
|
118
|
+
requestID: entry.state.requestID,
|
|
119
|
+
app: entry.state.app,
|
|
120
|
+
model: entry.state.request.model,
|
|
121
|
+
durationMs: Date.now() - entry.startedAt,
|
|
122
|
+
outcome: "execution_end_not_confirmed",
|
|
123
|
+
})
|
|
124
|
+
try {
|
|
125
|
+
retirement.onTimeout()
|
|
126
|
+
} catch (error) {
|
|
127
|
+
logError("session.cleanup_failed", { requestID: entry.state.requestID, error: errorMessage(error) })
|
|
128
|
+
}
|
|
129
|
+
entries.delete(sessionID)
|
|
130
|
+
}, retirement.timeoutMs)
|
|
131
|
+
entry.timer.unref?.()
|
|
132
|
+
},
|
|
133
|
+
failAll(error) {
|
|
134
|
+
for (const entry of entries.values()) {
|
|
135
|
+
try {
|
|
136
|
+
entry.state.collector.push({ type: FAILURE_EVENT, error })
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
logError("session.event_failed", { error: errorMessage(cause) })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return entries.size
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|