@bybrawe/opencode-loop 0.5.27 → 0.5.29

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. package/src/source/v1.js +69 -0
@@ -0,0 +1,23 @@
1
+ import { normalizeOpenCodeEvent } from "./events.js"
2
+
3
+ export function observeRuntimeEvent(manager, input) {
4
+ const event = normalizeOpenCodeEvent(input)
5
+ if (!event || !manager) return event
6
+
7
+ try {
8
+ if (event.kind === "server" && event.action === "disposed") {
9
+ manager.dispose?.("server-disposed")
10
+ return event
11
+ }
12
+
13
+ if (!event.sessionID) return event
14
+ if (event.kind === "session" && event.action === "deleted") {
15
+ manager.remove?.(event.sessionID, { reason: "session-deleted" })
16
+ return event
17
+ }
18
+
19
+ manager.observeExternal?.(event.sessionID)
20
+ } catch {}
21
+
22
+ return event
23
+ }
@@ -0,0 +1,212 @@
1
+ import { now } from "../core/args.js"
2
+ import { isGoalJob } from "../core/jobs.js"
3
+ import { readState } from "../core/state.js"
4
+
5
+ const DEFAULT_IDLE_DEBOUNCE_MS = 1_200
6
+ const DEFAULT_BUSY_RETRY_MS = 5_000
7
+ const DEFAULT_MIN_DUE_TIMER_MS = 250
8
+ const DEFAULT_MAX_DUE_TIMER_MS = 2_147_000_000
9
+ const DEFAULT_HEARTBEAT_MS = 2_500
10
+ const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000
11
+
12
+ export function jobDueAt(job, current = now()) {
13
+ if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
14
+ if (!job.enabled || job.paused) return Infinity
15
+ if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
16
+ if (Number(job.runNowRequestedAt || 0) > 0) return current
17
+ if (job.watchPaths?.length) return Infinity
18
+ const created = Date.parse(job.createdAt || "")
19
+ if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs) return current
20
+ if (job.intervalMs === 0) return current
21
+ if (!job.lastRunAt) {
22
+ if (job.immediate === false) return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0)
23
+ return current
24
+ }
25
+ return job.lastRunAt + (job.intervalMs || 0)
26
+ }
27
+
28
+ export function nextDueDelay(state, current = now()) {
29
+ let soonest = Infinity
30
+ for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
31
+ if (!Number.isFinite(soonest)) return Infinity
32
+ return Math.max(0, soonest - current)
33
+ }
34
+
35
+ export function createSchedulerRuntime(options = {}) {
36
+ const idleTimers = new Map()
37
+ const dueTimers = new Map()
38
+ const watchdogTimers = new Map()
39
+ const knownSessions = new Map()
40
+ let heartbeatTimer
41
+
42
+ const clock = options.now || now
43
+ const readStateFn = options.readState || readState
44
+ const setTimeoutFn = options.setTimeout || setTimeout
45
+ const clearTimeoutFn = options.clearTimeout || clearTimeout
46
+ const setIntervalFn = options.setInterval || setInterval
47
+ const clearIntervalFn = options.clearInterval || clearInterval
48
+ const idleDebounceMs = options.idleDebounceMs ?? DEFAULT_IDLE_DEBOUNCE_MS
49
+ const busyRetryMs = options.busyRetryMs ?? DEFAULT_BUSY_RETRY_MS
50
+ const minDueTimerMs = options.minDueTimerMs ?? DEFAULT_MIN_DUE_TIMER_MS
51
+ const maxDueTimerMs = options.maxDueTimerMs ?? DEFAULT_MAX_DUE_TIMER_MS
52
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS
53
+ const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS
54
+
55
+ const errorMessage = (error) => options.errorMessage ? options.errorMessage(error) : (error instanceof Error ? error.message : String(error || "unknown error"))
56
+ const appendLog = async (directory, event, extra) => { if (options.appendLoopLog) await options.appendLoopLog(directory, event, extra) }
57
+ const toast = async (client, message, level) => { if (options.toast) await options.toast(client, message, level) }
58
+
59
+ function stopHeartbeatIfIdle() {
60
+ if (!knownSessions.size && heartbeatTimer) {
61
+ clearIntervalFn(heartbeatTimer)
62
+ heartbeatTimer = undefined
63
+ }
64
+ }
65
+
66
+ function startHeartbeat() {
67
+ if (heartbeatTimer) return
68
+ heartbeatTimer = setIntervalFn(() => {
69
+ for (const [sessionID, info] of [...knownSessions.entries()]) {
70
+ if (!info || clock() - (info.seenAt || 0) > sessionTtlMs) {
71
+ knownSessions.delete(sessionID)
72
+ continue
73
+ }
74
+ Promise.resolve()
75
+ .then(async () => {
76
+ await options.finalizeActiveRun?.(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
77
+ await options.maybeRunDueJobs?.(info.directory, info.client, sessionID, { heartbeat: true })
78
+ })
79
+ .catch((error) => appendLog(info.directory, "heartbeat-error", { sessionID, error: errorMessage(error) }).catch(() => {}))
80
+ }
81
+ stopHeartbeatIfIdle()
82
+ }, heartbeatMs)
83
+ }
84
+
85
+ function rememberSession(directory, client, sessionID) {
86
+ if (!sessionID) return
87
+ knownSessions.set(sessionID, { directory, client, seenAt: clock() })
88
+ startHeartbeat()
89
+ }
90
+
91
+ function cancelIdleWork(sessionID) {
92
+ const timer = idleTimers.get(sessionID)
93
+ if (timer) clearTimeoutFn(timer)
94
+ idleTimers.delete(sessionID)
95
+ }
96
+
97
+ function cancelDueWork(sessionID) {
98
+ const timer = dueTimers.get(sessionID)
99
+ if (timer) clearTimeoutFn(timer)
100
+ dueTimers.delete(sessionID)
101
+ }
102
+
103
+ function scheduleIdleWork(directory, client, sessionID) {
104
+ cancelIdleWork(sessionID)
105
+ const timer = setTimeoutFn(() => {
106
+ idleTimers.delete(sessionID)
107
+ Promise.resolve()
108
+ .then(async () => {
109
+ if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
110
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
111
+ return
112
+ }
113
+ await options.finalizeActiveRun?.(directory, client, sessionID)
114
+ if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
115
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
116
+ return
117
+ }
118
+ await options.maybeRunDueJobs?.(directory, client, sessionID)
119
+ })
120
+ .catch((error) => {
121
+ toast(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {})
122
+ appendLog(directory, "idle-error", { sessionID, error: errorMessage(error) }).catch(() => {})
123
+ })
124
+ }, idleDebounceMs)
125
+ idleTimers.set(sessionID, timer)
126
+ }
127
+
128
+ async function startWatchdog(directory, client, sessionID) {
129
+ if (watchdogTimers.has(sessionID)) return
130
+ const timer = setIntervalFn(() => {
131
+ Promise.resolve()
132
+ .then(async () => {
133
+ const state = await readStateFn(directory, sessionID)
134
+ const delay = nextDueDelay(state, clock())
135
+ const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
136
+ if (!hasJobs || !Number.isFinite(delay)) {
137
+ stopWatchdog(sessionID)
138
+ return
139
+ }
140
+ if (delay <= 0) await options.maybeRunDueJobs?.(directory, client, sessionID)
141
+ else await scheduleDueWork(directory, client, sessionID)
142
+ })
143
+ .catch((error) => appendLog(directory, "watchdog-error", { sessionID, error: errorMessage(error) }).catch(() => {}))
144
+ }, Math.max(1_000, busyRetryMs))
145
+ watchdogTimers.set(sessionID, timer)
146
+ }
147
+
148
+ function stopWatchdog(sessionID) {
149
+ const timer = watchdogTimers.get(sessionID)
150
+ if (timer) clearIntervalFn(timer)
151
+ watchdogTimers.delete(sessionID)
152
+ }
153
+
154
+ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
155
+ cancelDueWork(sessionID)
156
+
157
+ const state = await readStateFn(directory, sessionID)
158
+ const delay = nextDueDelay(state, clock())
159
+ if (!Number.isFinite(delay)) return
160
+
161
+ const wait = Math.min(Math.max(delay, minDelayMs, minDueTimerMs), maxDueTimerMs)
162
+ const timer = setTimeoutFn(() => {
163
+ dueTimers.delete(sessionID)
164
+ Promise.resolve()
165
+ .then(async () => {
166
+ if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
167
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
168
+ return
169
+ }
170
+ await options.finalizeActiveRun?.(directory, client, sessionID)
171
+ if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
172
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
173
+ return
174
+ }
175
+ await options.maybeRunDueJobs?.(directory, client, sessionID)
176
+ })
177
+ .catch((error) => {
178
+ toast(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {})
179
+ appendLog(directory, "due-timer-error", { sessionID, error: errorMessage(error) }).catch(() => {})
180
+ })
181
+ }, wait)
182
+ dueTimers.set(sessionID, timer)
183
+ await startWatchdog(directory, client, sessionID)
184
+ }
185
+
186
+ function sessionIDsForHost(directory, client) {
187
+ return [...knownSessions.entries()]
188
+ .filter(([, info]) => info?.directory === directory && info?.client === client)
189
+ .map(([sessionID]) => sessionID)
190
+ }
191
+
192
+ function clearSessionScheduling(sessionID) {
193
+ cancelIdleWork(sessionID)
194
+ cancelDueWork(sessionID)
195
+ stopWatchdog(sessionID)
196
+ knownSessions.delete(sessionID)
197
+ stopHeartbeatIfIdle()
198
+ }
199
+
200
+ return {
201
+ rememberSession,
202
+ scheduleIdleWork,
203
+ scheduleDueWork,
204
+ startWatchdog,
205
+ stopWatchdog,
206
+ cancelIdleWork,
207
+ cancelDueWork,
208
+ sessionIDsForHost,
209
+ clearSessionScheduling,
210
+ knownSessionCount: () => knownSessions.size,
211
+ }
212
+ }
@@ -0,0 +1,56 @@
1
+ export function createRuntimeScope() {
2
+ const controller = new AbortController()
3
+ const cleanups = new Set()
4
+ let disposed = false
5
+
6
+ function track(cleanup) {
7
+ if (typeof cleanup !== "function") throw new TypeError("runtime scope cleanup must be a function")
8
+ if (disposed) {
9
+ cleanup()
10
+ return () => false
11
+ }
12
+
13
+ const entry = { cleanup }
14
+ cleanups.add(entry)
15
+ let tracked = true
16
+ return () => {
17
+ if (!tracked) return false
18
+ tracked = false
19
+ return cleanups.delete(entry)
20
+ }
21
+ }
22
+
23
+ function guard(callback) {
24
+ if (typeof callback !== "function") throw new TypeError("runtime scope guard requires a function")
25
+ return function (...args) {
26
+ if (disposed) return undefined
27
+ return callback.apply(this, args)
28
+ }
29
+ }
30
+
31
+ function dispose(reason) {
32
+ if (disposed) return false
33
+ disposed = true
34
+ controller.abort(reason)
35
+
36
+ const errors = []
37
+ for (const entry of [...cleanups].reverse()) {
38
+ cleanups.delete(entry)
39
+ try {
40
+ entry.cleanup()
41
+ } catch (error) {
42
+ errors.push(error)
43
+ }
44
+ }
45
+ if (errors.length) throw new AggregateError(errors, "runtime scope cleanup failed")
46
+ return true
47
+ }
48
+
49
+ return Object.freeze({
50
+ signal: controller.signal,
51
+ isActive: () => !disposed,
52
+ track,
53
+ guard,
54
+ dispose,
55
+ })
56
+ }
@@ -0,0 +1,112 @@
1
+ import { now } from "../core/args.js"
2
+ import { sdkCall } from "../opencode/sdk.js"
3
+ import { updateSessionExecutionContext, deleteSessionExecutionContext } from "../opencode/session-context.js"
4
+
5
+ export const activeToolCalls = new Map()
6
+ export const sessionParents = new Map()
7
+ export const sessionStatuses = new Map()
8
+ export const sessionStatusSeenAt = new Map()
9
+
10
+ export function hasActiveToolCalls(sessionID) {
11
+ return (activeToolCalls.get(sessionID)?.size || 0) > 0
12
+ }
13
+
14
+ export function markToolCallActive(input) {
15
+ const sessionID = input?.sessionID
16
+ const callID = input?.callID
17
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
18
+ const calls = activeToolCalls.get(sessionID) || new Set()
19
+ calls.add(callID)
20
+ activeToolCalls.set(sessionID, calls)
21
+ sessionStatuses.set(sessionID, "busy")
22
+ sessionStatusSeenAt.set(sessionID, now())
23
+ }
24
+
25
+ export function markToolCallFinished(input) {
26
+ const sessionID = input?.sessionID
27
+ const callID = input?.callID
28
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
29
+ const calls = activeToolCalls.get(sessionID)
30
+ if (!calls) return
31
+ calls.delete(callID)
32
+ if (!calls.size) activeToolCalls.delete(sessionID)
33
+ }
34
+
35
+ export function updateSessionRelationship(info, removed = false) {
36
+ const sessionID = info?.id
37
+ if (typeof sessionID !== "string") return
38
+ if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
39
+ else sessionParents.set(sessionID, info.parentID)
40
+ if (!removed) updateSessionExecutionContext(info)
41
+ else deleteSessionExecutionContext(sessionID)
42
+ }
43
+
44
+ export function updateSessionRelationshipFromEvent(event) {
45
+ if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
46
+ updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
47
+ }
48
+
49
+ export function isDescendantSession(sessionID, ancestorID) {
50
+ const visited = new Set()
51
+ let current = sessionID
52
+ while (sessionParents.has(current) && !visited.has(current)) {
53
+ visited.add(current)
54
+ current = sessionParents.get(current)
55
+ if (current === ancestorID) return true
56
+ }
57
+ return false
58
+ }
59
+
60
+ export function hasBusyDescendant(sessionID) {
61
+ for (const childID of sessionParents.keys()) {
62
+ if (!isDescendantSession(childID, sessionID)) continue
63
+ const status = sessionStatuses.get(childID)
64
+ if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
65
+ }
66
+ return false
67
+ }
68
+
69
+ export async function refreshSessionRelationships(client, directory) {
70
+ if (!client?.session?.list) return
71
+ try {
72
+ const sessions = await sdkCall(
73
+ client.session.list.bind(client.session),
74
+ { query: { directory } },
75
+ { directory },
76
+ {},
77
+ )
78
+ if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
79
+ } catch {}
80
+ }
81
+
82
+ export function updateToolActivityFromEvent(event) {
83
+ const props = event?.properties || {}
84
+ if (event?.type === "message.part.updated") {
85
+ const part = props.part
86
+ if (part?.type !== "tool") return
87
+ const sessionID = part.sessionID || props.sessionID
88
+ if (["pending", "running"].includes(part.state?.status)) {
89
+ markToolCallActive({ sessionID, callID: part.callID })
90
+ }
91
+ if (["completed", "error"].includes(part.state?.status)) {
92
+ // Task/subagent hooks have used the part id as their hook call id in some
93
+ // OpenCode versions, while normal tools use part.callID. Clear either.
94
+ const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
95
+ for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
96
+ }
97
+ return
98
+ }
99
+
100
+ const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
101
+ const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
102
+ if (started) markToolCallActive(props)
103
+ if (finished) markToolCallFinished(props)
104
+ }
105
+
106
+ export function clearSessionActivity(sessionID) {
107
+ activeToolCalls.delete(sessionID)
108
+ sessionParents.delete(sessionID)
109
+ sessionStatuses.delete(sessionID)
110
+ sessionStatusSeenAt.delete(sessionID)
111
+ deleteSessionExecutionContext(sessionID)
112
+ }
@@ -0,0 +1,100 @@
1
+ import { DEFAULT_SESSION_STALE_MS, createSessionRegistry } from "./session-registry.js"
2
+ import { createRuntimeScope } from "./scope.js"
3
+ import { createRuntimeTimers } from "./timers.js"
4
+
5
+ function defaultRuntimeFactory({ sessionID, timerAPI }) {
6
+ const scope = createRuntimeScope()
7
+ return Object.freeze({
8
+ sessionID,
9
+ scope,
10
+ timers: createRuntimeTimers(scope, timerAPI),
11
+ dispose: (reason) => scope.dispose(reason),
12
+ })
13
+ }
14
+
15
+ function validateRuntime(runtime, sessionID) {
16
+ if (!runtime || runtime.sessionID !== sessionID) throw new TypeError("session runtime factory must preserve the session ID")
17
+ if (typeof runtime?.scope?.isActive !== "function") throw new TypeError("session runtime factory must provide an active scope")
18
+ if (typeof runtime.dispose !== "function") throw new TypeError("session runtime factory must provide dispose()")
19
+ return runtime
20
+ }
21
+
22
+ export function createSessionRuntimeManager({
23
+ now = Date.now,
24
+ staleAfterMs = DEFAULT_SESSION_STALE_MS,
25
+ timerAPI = globalThis,
26
+ runtimeFactory = defaultRuntimeFactory,
27
+ } = {}) {
28
+ if (typeof runtimeFactory !== "function") throw new TypeError("session runtime manager requires a runtime factory")
29
+ const registry = createSessionRegistry({ now, staleAfterMs })
30
+ let disposed = false
31
+
32
+ function observeExternal(sessionID) {
33
+ if (disposed) throw new Error("session runtime manager is disposed")
34
+ const key = String(sessionID || "").trim()
35
+ if (!key) throw new TypeError("session runtime manager requires a session ID")
36
+ const current = registry.peek(key)
37
+ const runtime = current?.runtime?.scope?.isActive?.()
38
+ ? current.runtime
39
+ : validateRuntime(runtimeFactory({ sessionID: key, timerAPI }), key)
40
+ registry.observeExternal(key, runtime)
41
+ return runtime
42
+ }
43
+
44
+ function peek(sessionID) {
45
+ return registry.peek(sessionID)?.runtime
46
+ }
47
+
48
+ function entries() {
49
+ return registry.entries()
50
+ }
51
+
52
+ function remove(sessionID, { expectedRuntime, reason } = {}) {
53
+ const current = registry.peek(sessionID)
54
+ if (!current) return false
55
+ if (expectedRuntime !== undefined && current.runtime !== expectedRuntime) return false
56
+ if (!registry.remove(sessionID, current.runtime)) return false
57
+ current.runtime.dispose(reason)
58
+ return true
59
+ }
60
+
61
+ function pruneStale(at = now()) {
62
+ const before = new Map(registry.entries().map((entry) => [entry.sessionID, entry.runtime]))
63
+ const removed = registry.pruneStale(at)
64
+ const errors = []
65
+ for (const sessionID of removed) {
66
+ try {
67
+ before.get(sessionID)?.dispose("stale-session")
68
+ } catch (error) {
69
+ errors.push(error)
70
+ }
71
+ }
72
+ if (errors.length) throw new AggregateError(errors, "stale session cleanup failed")
73
+ return removed
74
+ }
75
+
76
+ function dispose(reason) {
77
+ if (disposed) return false
78
+ disposed = true
79
+ const errors = []
80
+ for (const entry of registry.entries()) {
81
+ registry.remove(entry.sessionID, entry.runtime)
82
+ try {
83
+ entry.runtime.dispose(reason)
84
+ } catch (error) {
85
+ errors.push(error)
86
+ }
87
+ }
88
+ if (errors.length) throw new AggregateError(errors, "session runtime manager cleanup failed")
89
+ return true
90
+ }
91
+
92
+ return Object.freeze({
93
+ observeExternal,
94
+ peek,
95
+ entries,
96
+ remove,
97
+ pruneStale,
98
+ dispose,
99
+ })
100
+ }
@@ -0,0 +1,58 @@
1
+ export const DEFAULT_SESSION_STALE_MS = 12 * 60 * 60 * 1000
2
+
3
+ function sessionKey(sessionID) {
4
+ return String(sessionID || "").trim()
5
+ }
6
+
7
+ export function createSessionRegistry({ now = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
8
+ if (typeof now !== "function") throw new TypeError("session registry requires a clock function")
9
+ if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) throw new TypeError("session registry requires a non-negative staleAfterMs")
10
+
11
+ const sessions = new Map()
12
+
13
+ function observeExternal(sessionID, runtime) {
14
+ const key = sessionKey(sessionID)
15
+ if (!key) throw new TypeError("session registry requires a session ID")
16
+ const seenAt = Number(now())
17
+ if (!Number.isFinite(seenAt)) throw new TypeError("session registry clock must return a finite number")
18
+ const entry = Object.freeze({ sessionID: key, runtime, seenAt })
19
+ sessions.set(key, entry)
20
+ return entry
21
+ }
22
+
23
+ function peek(sessionID) {
24
+ return sessions.get(sessionKey(sessionID))
25
+ }
26
+
27
+ function remove(sessionID, expectedRuntime) {
28
+ const key = sessionKey(sessionID)
29
+ const current = sessions.get(key)
30
+ if (!current) return false
31
+ if (arguments.length > 1 && current.runtime !== expectedRuntime) return false
32
+ return sessions.delete(key)
33
+ }
34
+
35
+ function pruneStale(at = now()) {
36
+ const timestamp = Number(at)
37
+ if (!Number.isFinite(timestamp)) throw new TypeError("session registry prune time must be finite")
38
+ const removed = []
39
+ for (const [key, entry] of sessions) {
40
+ if (timestamp - entry.seenAt < staleAfterMs) continue
41
+ sessions.delete(key)
42
+ removed.push(key)
43
+ }
44
+ return removed
45
+ }
46
+
47
+ function entries() {
48
+ return [...sessions.values()]
49
+ }
50
+
51
+ return Object.freeze({
52
+ observeExternal,
53
+ peek,
54
+ remove,
55
+ pruneStale,
56
+ entries,
57
+ })
58
+ }