@qadanm/opencode-smart-router 0.0.0-stage → 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/CHANGELOG.md +118 -0
- package/CONTRIBUTING.md +96 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/SECURITY.md +174 -0
- package/config.example.json +108 -0
- package/dist/allowance.js +487 -0
- package/dist/gui-experimental.cjs +1 -0
- package/dist/index.js +11111 -0
- package/dist/npm/index.js +40 -0
- package/dist/tui.js +587 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +120 -0
- package/docs/LIMITATIONS.md +213 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +72 -0
- package/install +75 -0
- package/package.json +92 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1030 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +203 -0
- package/src/core/models/seed.ts +771 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +481 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +578 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +628 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1273 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +919 -0
- package/src/core/types.ts +825 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1670 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +48 -0
- package/src/rpc.ts +204 -0
- package/src/server/controller.ts +3281 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +671 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import type { LiveModel } from "../core/models/registry.js"
|
|
2
|
+
import { readOpenCodeHistory } from "../core/history/opencode.js"
|
|
3
|
+
import { LedgerStore } from "../core/quota/ledger.js"
|
|
4
|
+
import { QuotaService } from "../core/quota/service.js"
|
|
5
|
+
import { discoverOpenDesignModels, toLiveModels } from "../core/providers/opendesign.js"
|
|
6
|
+
import type { ProviderQuotaSnapshot, RouterSettings, UsageEvent } from "../core/types.js"
|
|
7
|
+
import { safeError } from "../core/util.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One shared core per OpenCode server process. The SDK probe confirmed that
|
|
11
|
+
* globalThis is shared across every project-location plugin instance in one
|
|
12
|
+
* server process. Commands/RPC remain location-scoped, so each location gets a
|
|
13
|
+
* light bridge that calls this core.
|
|
14
|
+
*/
|
|
15
|
+
export interface RuntimeOverrides {
|
|
16
|
+
mode?: RouterSettings["mode"]
|
|
17
|
+
paused?: boolean
|
|
18
|
+
dryRun?: boolean
|
|
19
|
+
premiumBudgetUsd?: number
|
|
20
|
+
reserves?: Record<string, { monthlyPct?: number; weeklyPct?: number; fiveHourPct?: number }>
|
|
21
|
+
excluded?: string[]
|
|
22
|
+
approved?: string[]
|
|
23
|
+
titleIndicator?: boolean
|
|
24
|
+
compactMode?: boolean
|
|
25
|
+
compact?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CoreDiagnostics {
|
|
29
|
+
coreID: string
|
|
30
|
+
pid: number
|
|
31
|
+
createdAt: number
|
|
32
|
+
uptimeMs: number
|
|
33
|
+
bridges: Array<{ id: string; directory: string; createdAt: number }>
|
|
34
|
+
bridgeCount: number
|
|
35
|
+
setups: number
|
|
36
|
+
disposes: number
|
|
37
|
+
ledgerEvents: number
|
|
38
|
+
decisions: number
|
|
39
|
+
sessions: number
|
|
40
|
+
quotaSnapshots: number
|
|
41
|
+
quotaRefreshes: number
|
|
42
|
+
ledgerWrites: number
|
|
43
|
+
quotaWrites: number
|
|
44
|
+
lastLedgerWriteAt?: number
|
|
45
|
+
lastQuotaWriteAt?: number
|
|
46
|
+
quotaInitialized: boolean
|
|
47
|
+
bootstrapped: boolean
|
|
48
|
+
eventsHandled: number
|
|
49
|
+
eventsIgnored: number
|
|
50
|
+
eventStreams: number
|
|
51
|
+
errors: string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface InitInput {
|
|
55
|
+
readonly ctx: any
|
|
56
|
+
readonly getSettings: () => RouterSettings
|
|
57
|
+
readonly getCredentials: () => Promise<{
|
|
58
|
+
goAccessToken?: string
|
|
59
|
+
commandCodeIntegrationKey?: string
|
|
60
|
+
commandCodeProviderKey?: string
|
|
61
|
+
openDesignApiKey?: string
|
|
62
|
+
openDesignControlKey?: string
|
|
63
|
+
}>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const KEY = Symbol.for("opencode-smart-router.shared-core.v1")
|
|
67
|
+
const BRIDGE_GRACE_MS = 10_000
|
|
68
|
+
const MODEL_TTL_MS = 5 * 60_000
|
|
69
|
+
const UNOWNED_TTL_MS = 30_000
|
|
70
|
+
const UNOWNED_MAX = 2_000
|
|
71
|
+
const STORAGE_LEDGER = "ledger-v1"
|
|
72
|
+
const STORAGE_RUNTIME = "runtime-v1"
|
|
73
|
+
const STORAGE_QUOTA = "quota-cache-v1"
|
|
74
|
+
|
|
75
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
76
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
77
|
+
|
|
78
|
+
const safe = <T>(value: T): any => JSON.parse(JSON.stringify(value))
|
|
79
|
+
|
|
80
|
+
class SharedCore {
|
|
81
|
+
readonly coreID = Math.random().toString(36).slice(2, 10)
|
|
82
|
+
readonly pid = process.pid
|
|
83
|
+
readonly createdAt = Date.now()
|
|
84
|
+
ledger = new LedgerStore()
|
|
85
|
+
runtime: RuntimeOverrides = {}
|
|
86
|
+
quota?: QuotaService
|
|
87
|
+
private readonly bridges = new Map<string, { id: string; directory: string; createdAt: number; controller?: any }>()
|
|
88
|
+
private eventAbort?: AbortController
|
|
89
|
+
private eventsStarted = false
|
|
90
|
+
private readonly owners = new Map<string, string>()
|
|
91
|
+
private readonly unowned = new Map<string, number>()
|
|
92
|
+
private liveModels: LiveModel[] = []
|
|
93
|
+
private modelsLoadedAt = 0
|
|
94
|
+
private modelsLoading?: Promise<LiveModel[]>
|
|
95
|
+
private openDesignModels: LiveModel[] = []
|
|
96
|
+
private openDesignLoadedAt = 0
|
|
97
|
+
private openDesignLoading?: Promise<LiveModel[]>
|
|
98
|
+
private eventsHandled = 0
|
|
99
|
+
private eventsIgnored = 0
|
|
100
|
+
private ownerCtx?: any
|
|
101
|
+
private getSettings?: () => RouterSettings
|
|
102
|
+
private getCredentials?: InitInput["getCredentials"]
|
|
103
|
+
private ready?: Promise<void>
|
|
104
|
+
private loading?: Promise<void>
|
|
105
|
+
private disposing?: Promise<void>
|
|
106
|
+
private quotaInitial: ProviderQuotaSnapshot[] = []
|
|
107
|
+
private ledgerTimer?: ReturnType<typeof setTimeout>
|
|
108
|
+
private quotaTimer?: ReturnType<typeof setTimeout>
|
|
109
|
+
private runtimeTimer?: ReturnType<typeof setTimeout>
|
|
110
|
+
private bootstrapTimer?: ReturnType<typeof setTimeout>
|
|
111
|
+
private quotaStartTimer?: ReturnType<typeof setTimeout>
|
|
112
|
+
private shutdownTimer?: ReturnType<typeof setTimeout>
|
|
113
|
+
private started = false
|
|
114
|
+
private quotaInitialized = false
|
|
115
|
+
private bootstrapped = false
|
|
116
|
+
private ledgerWrites = 0
|
|
117
|
+
private quotaWrites = 0
|
|
118
|
+
private quotaRefreshes = 0
|
|
119
|
+
private lastLedgerWriteAt?: number
|
|
120
|
+
private lastQuotaWriteAt?: number
|
|
121
|
+
private readonly errors: string[] = []
|
|
122
|
+
private pushError(message: string) {
|
|
123
|
+
this.errors.push(message)
|
|
124
|
+
if (this.errors.length > 50) this.errors.splice(0, this.errors.length - 50)
|
|
125
|
+
}
|
|
126
|
+
private sequence = 0
|
|
127
|
+
private setups = 0
|
|
128
|
+
private disposes = 0
|
|
129
|
+
|
|
130
|
+
async init(input: InitInput): Promise<void> {
|
|
131
|
+
this.setups += 1
|
|
132
|
+
if (this.disposing) await this.disposing
|
|
133
|
+
if (this.ready && this.ownerCtx && this.started) return this.ready
|
|
134
|
+
// Several locations can initialize in the same tick when a server restores
|
|
135
|
+
// its session list. Share one load; a second load would replace the ledger
|
|
136
|
+
// and quota service under a bridge that already captured the first ones.
|
|
137
|
+
if (this.loading) return this.loading
|
|
138
|
+
this.ownerCtx = input.ctx
|
|
139
|
+
this.getSettings = input.getSettings
|
|
140
|
+
this.getCredentials = input.getCredentials
|
|
141
|
+
this.loading = this.load().finally(() => {
|
|
142
|
+
this.loading = undefined
|
|
143
|
+
})
|
|
144
|
+
this.ready = this.loading
|
|
145
|
+
return this.loading
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async load() {
|
|
149
|
+
try {
|
|
150
|
+
const [savedLedger, savedRuntime, savedQuota] = await Promise.all([
|
|
151
|
+
this.ownerCtx.storage.get(STORAGE_LEDGER).catch(() => undefined),
|
|
152
|
+
this.ownerCtx.storage.get(STORAGE_RUNTIME).catch(() => undefined),
|
|
153
|
+
this.ownerCtx.storage.get(STORAGE_QUOTA).catch(() => undefined),
|
|
154
|
+
])
|
|
155
|
+
if (isRecord(savedRuntime)) this.runtime = savedRuntime as RuntimeOverrides
|
|
156
|
+
const hydrated = LedgerStore.hydrate(savedLedger)
|
|
157
|
+
this.ledger = new LedgerStore(hydrated.ledger)
|
|
158
|
+
for (const warning of hydrated.warnings.slice(0, 5)) this.pushError(warning)
|
|
159
|
+
this.quotaInitial = Array.isArray(savedQuota) ? (savedQuota as ProviderQuotaSnapshot[]) : []
|
|
160
|
+
this.quota = new QuotaService({
|
|
161
|
+
getSettings: () => (this.getSettings?.().providers ?? {}) as any,
|
|
162
|
+
getCredentials: () => this.getCredentials?.() ?? Promise.resolve({}),
|
|
163
|
+
getUsageEvents: (window) => this.ledger.usageEvents(window),
|
|
164
|
+
probeIntervalMinutes: this.getSettings?.().quota.probeIntervalMinutes ?? 5,
|
|
165
|
+
initial: this.quotaInitial,
|
|
166
|
+
onSnapshot: (snapshot) => {
|
|
167
|
+
this.quotaRefreshes += 1
|
|
168
|
+
this.persistQuotaSoon()
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
this.started = true
|
|
172
|
+
// Never touch the network or enumerate sessions on the plugin-load path.
|
|
173
|
+
this.bootstrapTimer = setTimeout(() => {
|
|
174
|
+
void this.bootstrap()
|
|
175
|
+
}, 1_000)
|
|
176
|
+
this.quotaStartTimer = setTimeout(() => {
|
|
177
|
+
void this.startQuota()
|
|
178
|
+
}, 1_500)
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.pushError(safeError(error))
|
|
181
|
+
}
|
|
182
|
+
if (!this.quota) {
|
|
183
|
+
this.quota = new QuotaService({
|
|
184
|
+
getSettings: () => (this.getSettings?.().providers ?? {}) as any,
|
|
185
|
+
getCredentials: () => this.getCredentials?.() ?? Promise.resolve({}),
|
|
186
|
+
getUsageEvents: (window) => this.ledger.usageEvents(window),
|
|
187
|
+
probeIntervalMinutes: this.getSettings?.().quota.probeIntervalMinutes ?? 5,
|
|
188
|
+
initial: this.quotaInitial,
|
|
189
|
+
onSnapshot: () => {
|
|
190
|
+
this.quotaRefreshes += 1
|
|
191
|
+
this.persistQuotaSoon()
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
if (!this.started) {
|
|
196
|
+
this.started = true
|
|
197
|
+
this.bootstrapTimer = setTimeout(() => {
|
|
198
|
+
void this.bootstrap()
|
|
199
|
+
}, 1_000)
|
|
200
|
+
this.quotaStartTimer = setTimeout(() => {
|
|
201
|
+
void this.startQuota()
|
|
202
|
+
}, 1_500)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async bootstrap() {
|
|
207
|
+
if (this.bootstrapped) return
|
|
208
|
+
this.bootstrapped = true
|
|
209
|
+
try {
|
|
210
|
+
const settings = this.getSettings?.()
|
|
211
|
+
if (!settings) return
|
|
212
|
+
const limit = settings.quota.bootstrapSessions
|
|
213
|
+
if (limit <= 0) return
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Prefer OpenCode's own message history.
|
|
217
|
+
*
|
|
218
|
+
* It is strictly better than session summaries: every message carries its
|
|
219
|
+
* own timestamp, which is what a fixed seven-day window needs, and the
|
|
220
|
+
* model that actually served it rather than whatever the session has
|
|
221
|
+
* selected now. When it yields anything at all, the session-level import
|
|
222
|
+
* is skipped — mixing the two would double-count every session.
|
|
223
|
+
*/
|
|
224
|
+
const history = readOpenCodeHistory({
|
|
225
|
+
sinceMs: Date.now() - settings.quota.bootstrapDays * 24 * 60 * 60 * 1000,
|
|
226
|
+
limit: Math.max(limit, 1) * 100,
|
|
227
|
+
})
|
|
228
|
+
if (history.entries.length > 0) {
|
|
229
|
+
const imported = this.ledger.importHistory(history.entries)
|
|
230
|
+
if (imported.added > 0) {
|
|
231
|
+
this.persistSoon()
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
} else if (history.reason) {
|
|
235
|
+
// Silence here would leave a user wondering why day one started at zero.
|
|
236
|
+
this.pushError(`history: ${history.reason}`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = await this.ownerCtx.session.list({ limit })
|
|
240
|
+
const sessions = Array.isArray(result) ? result : (result?.data ?? [])
|
|
241
|
+
const rows = sessions.map((session: any) => ({
|
|
242
|
+
id: String(session.id),
|
|
243
|
+
title: typeof session.title === "string" ? session.title : undefined,
|
|
244
|
+
model: session.model
|
|
245
|
+
? { providerID: session.model.providerID, id: session.model.id, ...(session.model.variant ? { variant: session.model.variant } : {}) }
|
|
246
|
+
: undefined,
|
|
247
|
+
cost: typeof session.cost === "number" ? session.cost : 0,
|
|
248
|
+
tokens: session.tokens
|
|
249
|
+
? {
|
|
250
|
+
input: Number(session.tokens.input) || 0,
|
|
251
|
+
output: Number(session.tokens.output) || 0,
|
|
252
|
+
cache: {
|
|
253
|
+
read: Number(session.tokens.cache?.read) || 0,
|
|
254
|
+
write: Number(session.tokens.cache?.write) || 0,
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
: undefined,
|
|
258
|
+
updatedAt: typeof session.time?.updated === "number" ? session.time.updated : Date.now(),
|
|
259
|
+
}))
|
|
260
|
+
this.ledger.bootstrap(rows, {
|
|
261
|
+
days: settings.quota.bootstrapDays,
|
|
262
|
+
limit: settings.quota.bootstrapSessions,
|
|
263
|
+
})
|
|
264
|
+
this.persistSoon()
|
|
265
|
+
} catch (error) {
|
|
266
|
+
this.pushError(`bootstrap: ${safeError(error)}`)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async startQuota() {
|
|
271
|
+
if (this.quotaInitialized || !this.quota) return
|
|
272
|
+
this.quotaInitialized = true
|
|
273
|
+
try {
|
|
274
|
+
await this.quota.initialize()
|
|
275
|
+
} catch (error) {
|
|
276
|
+
this.pushError(`quota: ${safeError(error)}`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
claimSession(sessionID: string, bridgeID: string) {
|
|
281
|
+
if (!bridgeID) return
|
|
282
|
+
this.unowned.delete(sessionID)
|
|
283
|
+
if (!this.owners.has(sessionID) && this.owners.size >= 2_000) {
|
|
284
|
+
const oldest = this.owners.keys().next().value
|
|
285
|
+
if (oldest) this.owners.delete(oldest)
|
|
286
|
+
}
|
|
287
|
+
this.owners.set(sessionID, bridgeID)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
releaseSession(sessionID: string) {
|
|
291
|
+
this.owners.delete(sessionID)
|
|
292
|
+
this.unowned.delete(sessionID)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
recordError(message: string) {
|
|
296
|
+
this.pushError(message)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private noteUnowned(sessionID: string) {
|
|
300
|
+
if (this.unowned.size >= UNOWNED_MAX) {
|
|
301
|
+
const oldest = this.unowned.keys().next().value
|
|
302
|
+
if (oldest) this.unowned.delete(oldest)
|
|
303
|
+
}
|
|
304
|
+
this.unowned.set(sessionID, Date.now())
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** One catalog fetch per process; bridges rebuild their own registries from it. */
|
|
308
|
+
async getLiveModels(force = false): Promise<LiveModel[]> {
|
|
309
|
+
const fresh = this.modelsLoadedAt > 0 && Date.now() - this.modelsLoadedAt < MODEL_TTL_MS
|
|
310
|
+
if ((!fresh || force) && !this.modelsLoading && this.ownerCtx?.model?.list) {
|
|
311
|
+
this.modelsLoading = Promise.resolve(this.ownerCtx.model.list())
|
|
312
|
+
.then((result: any) => {
|
|
313
|
+
const data = Array.isArray(result) ? result : (result?.data ?? [])
|
|
314
|
+
this.liveModels = Array.isArray(data) ? (data as LiveModel[]) : []
|
|
315
|
+
this.modelsLoadedAt = Date.now()
|
|
316
|
+
return this.liveModels
|
|
317
|
+
})
|
|
318
|
+
.catch((error) => {
|
|
319
|
+
this.pushError(`models: ${safeError(error)}`)
|
|
320
|
+
return this.liveModels
|
|
321
|
+
})
|
|
322
|
+
.finally(() => {
|
|
323
|
+
this.modelsLoading = undefined
|
|
324
|
+
})
|
|
325
|
+
await this.modelsLoading
|
|
326
|
+
}
|
|
327
|
+
// OpenDesign models come from the provider's own gateway rather than from
|
|
328
|
+
// OpenCode's catalog, so they are merged in here. The merge is one
|
|
329
|
+
// shared, single-flight discovery per process.
|
|
330
|
+
const openDesign = await this.getOpenDesignModels(force)
|
|
331
|
+
return openDesign.length > 0 ? [...this.liveModels, ...openDesign] : this.liveModels
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Read the OpenDesign gateway's entitled model list.
|
|
336
|
+
*
|
|
337
|
+
* This is a read-only metadata call. It makes no inference request and
|
|
338
|
+
* consumes no plan credits. It runs at most once per catalog TTL per
|
|
339
|
+
* process, no matter how many project bridges are open.
|
|
340
|
+
*/
|
|
341
|
+
async getOpenDesignModels(force = false): Promise<LiveModel[]> {
|
|
342
|
+
const settings = this.getSettings?.()
|
|
343
|
+
const provider = settings?.providers?.opendesign
|
|
344
|
+
if (!provider?.enabled) {
|
|
345
|
+
this.openDesignModels = []
|
|
346
|
+
this.openDesignLoadedAt = Date.now()
|
|
347
|
+
return []
|
|
348
|
+
}
|
|
349
|
+
const fresh = this.openDesignLoadedAt > 0 && Date.now() - this.openDesignLoadedAt < MODEL_TTL_MS
|
|
350
|
+
if (!force && fresh) return this.openDesignModels
|
|
351
|
+
if (this.openDesignLoading) return this.openDesignLoading
|
|
352
|
+
this.openDesignLoading = (async () => {
|
|
353
|
+
try {
|
|
354
|
+
const credentials = (await this.getCredentials?.()) ?? {}
|
|
355
|
+
if (!credentials.openDesignApiKey) {
|
|
356
|
+
this.openDesignModels = []
|
|
357
|
+
this.openDesignLoadedAt = Date.now()
|
|
358
|
+
return []
|
|
359
|
+
}
|
|
360
|
+
const result = await discoverOpenDesignModels({
|
|
361
|
+
apiKey: credentials.openDesignApiKey,
|
|
362
|
+
...(provider.baseURL ? { baseURL: provider.baseURL } : {}),
|
|
363
|
+
})
|
|
364
|
+
if (!result.ok) {
|
|
365
|
+
this.pushError(`opendesign discovery: ${result.error ?? "unknown error"}`)
|
|
366
|
+
// Keep the last good catalog rather than dropping every OpenDesign
|
|
367
|
+
// route because one read-only probe failed.
|
|
368
|
+
this.openDesignLoadedAt = Date.now()
|
|
369
|
+
return this.openDesignModels
|
|
370
|
+
}
|
|
371
|
+
this.openDesignModels = toLiveModels(result.models) as LiveModel[]
|
|
372
|
+
this.openDesignLoadedAt = Date.now()
|
|
373
|
+
return this.openDesignModels
|
|
374
|
+
} catch (error) {
|
|
375
|
+
this.pushError(`opendesign discovery: ${safeError(error)}`)
|
|
376
|
+
return this.openDesignModels
|
|
377
|
+
} finally {
|
|
378
|
+
this.openDesignLoading = undefined
|
|
379
|
+
}
|
|
380
|
+
})()
|
|
381
|
+
return this.openDesignLoading
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Local usage events, used by providers whose quota must be modelled locally. */
|
|
385
|
+
usageEvents(window?: { since: number; until?: number }): ReadonlyArray<UsageEvent> {
|
|
386
|
+
return this.ledger.usageEvents(window)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
ownerOf(sessionID: string): string | undefined {
|
|
390
|
+
return this.owners.get(sessionID)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
noteEvent(handled: boolean) {
|
|
394
|
+
if (handled) this.eventsHandled += 1
|
|
395
|
+
else this.eventsIgnored += 1
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
addBridge(directory: string, controller?: any): string {
|
|
399
|
+
// A newly loaded location may own sessions previously seen without an owner.
|
|
400
|
+
this.unowned.clear()
|
|
401
|
+
const id = `bridge-${this.pid}-${++this.sequence}`
|
|
402
|
+
this.bridges.set(id, { id, directory, createdAt: Date.now(), controller })
|
|
403
|
+
if (this.shutdownTimer) {
|
|
404
|
+
clearTimeout(this.shutdownTimer)
|
|
405
|
+
this.shutdownTimer = undefined
|
|
406
|
+
}
|
|
407
|
+
this.startEvents()
|
|
408
|
+
return id
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private startEvents() {
|
|
412
|
+
if (this.eventsStarted || !this.ownerCtx?.event) return
|
|
413
|
+
this.eventsStarted = true
|
|
414
|
+
const abort = new AbortController()
|
|
415
|
+
this.eventAbort = abort
|
|
416
|
+
void (async () => {
|
|
417
|
+
try {
|
|
418
|
+
for await (const event of this.ownerCtx.event.subscribe({ signal: abort.signal })) {
|
|
419
|
+
await this.dispatchEvent(event)
|
|
420
|
+
}
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (!abort.signal.aborted) this.pushError(`events: ${safeError(error)}`)
|
|
423
|
+
} finally {
|
|
424
|
+
if (this.eventAbort === abort) {
|
|
425
|
+
this.eventAbort = undefined
|
|
426
|
+
this.eventsStarted = false
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
})()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async dispatchEvent(event: any) {
|
|
433
|
+
const data = event?.data ?? {}
|
|
434
|
+
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined
|
|
435
|
+
let target: any
|
|
436
|
+
let targetID: string | undefined
|
|
437
|
+
if (sessionID) {
|
|
438
|
+
targetID = this.owners.get(sessionID)
|
|
439
|
+
if (targetID) target = this.bridges.get(targetID)?.controller
|
|
440
|
+
if (!target && event.type === "session.created" && typeof data.projectID === "string") {
|
|
441
|
+
for (const bridge of this.bridges.values()) {
|
|
442
|
+
if (bridge.controller?.projectMatches?.(data.projectID, data.location?.directory)) {
|
|
443
|
+
target = bridge.controller
|
|
444
|
+
targetID = bridge.id
|
|
445
|
+
break
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (targetID) this.claimSession(sessionID, targetID)
|
|
449
|
+
}
|
|
450
|
+
if (!target && this.ownerCtx?.session) {
|
|
451
|
+
const seen = this.unowned.get(sessionID)
|
|
452
|
+
if (seen !== undefined && Date.now() - seen < UNOWNED_TTL_MS) {
|
|
453
|
+
this.noteEvent(false)
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const info = await this.ownerCtx.session.get({ sessionID })
|
|
458
|
+
if (info?.projectID) {
|
|
459
|
+
for (const bridge of this.bridges.values()) {
|
|
460
|
+
if (bridge.controller?.projectMatches?.(info.projectID, info.location?.directory)) {
|
|
461
|
+
target = bridge.controller
|
|
462
|
+
targetID = bridge.id
|
|
463
|
+
this.claimSession(sessionID, bridge.id)
|
|
464
|
+
break
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
// Leave unowned events ignored rather than crossing projects.
|
|
470
|
+
}
|
|
471
|
+
if (!target) this.noteUnowned(sessionID)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (!target) {
|
|
475
|
+
this.noteEvent(false)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
await target.handleEvent(event)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
removeBridge(id: string) {
|
|
482
|
+
this.bridges.delete(id)
|
|
483
|
+
this.disposes += 1
|
|
484
|
+
if (this.bridges.size === 0 && this.started) {
|
|
485
|
+
this.shutdownTimer = setTimeout(() => {
|
|
486
|
+
if (this.bridges.size === 0) void this.dispose()
|
|
487
|
+
}, BRIDGE_GRACE_MS)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
persistSoon() {
|
|
492
|
+
if (this.ledgerTimer) return
|
|
493
|
+
this.ledgerTimer = setTimeout(() => {
|
|
494
|
+
this.ledgerTimer = undefined
|
|
495
|
+
void this.persistNow()
|
|
496
|
+
}, 750)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async persistNow() {
|
|
500
|
+
if (!this.ownerCtx?.storage) return
|
|
501
|
+
try {
|
|
502
|
+
const snapshot = safe(this.ledger.export())
|
|
503
|
+
this.ledger.markClean()
|
|
504
|
+
await this.ownerCtx.storage.set(STORAGE_LEDGER, snapshot)
|
|
505
|
+
this.ledgerWrites += 1
|
|
506
|
+
this.lastLedgerWriteAt = Date.now()
|
|
507
|
+
if (this.ledger.dirtyFlag) this.persistSoon()
|
|
508
|
+
} catch (error) {
|
|
509
|
+
this.pushError(`persistLedger: ${safeError(error)}`)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
persistRuntimeSoon() {
|
|
514
|
+
if (this.runtimeTimer) return
|
|
515
|
+
this.runtimeTimer = setTimeout(() => {
|
|
516
|
+
this.runtimeTimer = undefined
|
|
517
|
+
void this.persistRuntimeNow()
|
|
518
|
+
}, 250)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async persistRuntimeNow() {
|
|
522
|
+
if (!this.ownerCtx?.storage) return
|
|
523
|
+
try {
|
|
524
|
+
await this.ownerCtx.storage.set(STORAGE_RUNTIME, safe(this.runtime))
|
|
525
|
+
} catch (error) {
|
|
526
|
+
this.pushError(`persistRuntime: ${safeError(error)}`)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
persistQuotaSoon() {
|
|
531
|
+
if (this.quotaTimer) return
|
|
532
|
+
this.quotaTimer = setTimeout(() => {
|
|
533
|
+
this.quotaTimer = undefined
|
|
534
|
+
void this.persistQuotaNow()
|
|
535
|
+
}, 1_000)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async persistQuotaNow() {
|
|
539
|
+
if (!this.ownerCtx?.storage || !this.quota) return
|
|
540
|
+
try {
|
|
541
|
+
await this.ownerCtx.storage.set(STORAGE_QUOTA, safe(this.quota.read()))
|
|
542
|
+
this.quotaWrites += 1
|
|
543
|
+
this.lastQuotaWriteAt = Date.now()
|
|
544
|
+
} catch (error) {
|
|
545
|
+
this.pushError(`persistQuota: ${safeError(error)}`)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async resetLedger() {
|
|
550
|
+
this.ledger.reset()
|
|
551
|
+
this.bootstrapped = false
|
|
552
|
+
this.persistSoon()
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Test-only: clear in-process state between isolated test cases. */
|
|
556
|
+
resetForTests() {
|
|
557
|
+
this.eventAbort?.abort()
|
|
558
|
+
this.eventAbort = undefined
|
|
559
|
+
this.eventsStarted = false
|
|
560
|
+
clearTimeout(this.ledgerTimer)
|
|
561
|
+
clearTimeout(this.quotaTimer)
|
|
562
|
+
clearTimeout(this.runtimeTimer)
|
|
563
|
+
clearTimeout(this.bootstrapTimer)
|
|
564
|
+
clearTimeout(this.quotaStartTimer)
|
|
565
|
+
clearTimeout(this.shutdownTimer)
|
|
566
|
+
this.ledgerTimer = undefined
|
|
567
|
+
this.quotaTimer = undefined
|
|
568
|
+
this.runtimeTimer = undefined
|
|
569
|
+
this.bootstrapTimer = undefined
|
|
570
|
+
this.quotaStartTimer = undefined
|
|
571
|
+
this.shutdownTimer = undefined
|
|
572
|
+
this.ledger = new LedgerStore()
|
|
573
|
+
this.runtime = {}
|
|
574
|
+
this.quota = undefined
|
|
575
|
+
this.ownerCtx = undefined
|
|
576
|
+
this.ready = undefined
|
|
577
|
+
this.loading = undefined
|
|
578
|
+
this.disposing = undefined
|
|
579
|
+
this.started = false
|
|
580
|
+
this.quotaInitialized = false
|
|
581
|
+
this.bootstrapped = false
|
|
582
|
+
this.bridges.clear()
|
|
583
|
+
this.owners.clear()
|
|
584
|
+
this.unowned.clear()
|
|
585
|
+
this.liveModels = []
|
|
586
|
+
this.modelsLoadedAt = 0
|
|
587
|
+
this.modelsLoading = undefined
|
|
588
|
+
this.openDesignModels = []
|
|
589
|
+
this.openDesignLoadedAt = 0
|
|
590
|
+
this.openDesignLoading = undefined
|
|
591
|
+
this.errors.length = 0
|
|
592
|
+
this.eventsHandled = 0
|
|
593
|
+
this.eventsIgnored = 0
|
|
594
|
+
this.sequence = 0
|
|
595
|
+
this.setups = 0
|
|
596
|
+
this.disposes = 0
|
|
597
|
+
this.ledgerWrites = 0
|
|
598
|
+
this.quotaWrites = 0
|
|
599
|
+
this.quotaRefreshes = 0
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
diagnostics(): CoreDiagnostics {
|
|
603
|
+
return {
|
|
604
|
+
coreID: this.coreID,
|
|
605
|
+
pid: this.pid,
|
|
606
|
+
createdAt: this.createdAt,
|
|
607
|
+
uptimeMs: Date.now() - this.createdAt,
|
|
608
|
+
bridges: [...this.bridges.values()].map(({ id, directory, createdAt }) => ({ id, directory, createdAt })),
|
|
609
|
+
bridgeCount: this.bridges.size,
|
|
610
|
+
setups: this.setups,
|
|
611
|
+
disposes: this.disposes,
|
|
612
|
+
ledgerEvents: this.ledger.data.usage.length,
|
|
613
|
+
decisions: this.ledger.data.decisions.length,
|
|
614
|
+
sessions: Object.keys(this.ledger.data.sessions).length,
|
|
615
|
+
quotaSnapshots: this.quota?.read().length ?? 0,
|
|
616
|
+
quotaRefreshes: this.quotaRefreshes,
|
|
617
|
+
ledgerWrites: this.ledgerWrites,
|
|
618
|
+
quotaWrites: this.quotaWrites,
|
|
619
|
+
lastLedgerWriteAt: this.lastLedgerWriteAt,
|
|
620
|
+
lastQuotaWriteAt: this.lastQuotaWriteAt,
|
|
621
|
+
quotaInitialized: this.quotaInitialized,
|
|
622
|
+
bootstrapped: this.bootstrapped,
|
|
623
|
+
eventsHandled: this.eventsHandled,
|
|
624
|
+
eventsIgnored: this.eventsIgnored,
|
|
625
|
+
eventStreams: this.eventsStarted ? 1 : 0,
|
|
626
|
+
errors: this.errors.slice(-20),
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private dispose(): Promise<void> {
|
|
631
|
+
if (this.disposing) return this.disposing
|
|
632
|
+
const running = this.teardown()
|
|
633
|
+
const tracked = running.finally(() => {
|
|
634
|
+
if (this.disposing === tracked) this.disposing = undefined
|
|
635
|
+
})
|
|
636
|
+
this.disposing = tracked
|
|
637
|
+
return tracked
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private async teardown() {
|
|
641
|
+
if (this.shutdownTimer) clearTimeout(this.shutdownTimer)
|
|
642
|
+
this.shutdownTimer = undefined
|
|
643
|
+
this.eventAbort?.abort()
|
|
644
|
+
this.eventAbort = undefined
|
|
645
|
+
this.eventsStarted = false
|
|
646
|
+
clearTimeout(this.ledgerTimer)
|
|
647
|
+
clearTimeout(this.quotaTimer)
|
|
648
|
+
clearTimeout(this.runtimeTimer)
|
|
649
|
+
clearTimeout(this.bootstrapTimer)
|
|
650
|
+
clearTimeout(this.quotaStartTimer)
|
|
651
|
+
this.ledgerTimer = undefined
|
|
652
|
+
this.quotaTimer = undefined
|
|
653
|
+
this.runtimeTimer = undefined
|
|
654
|
+
await this.persistNow()
|
|
655
|
+
await this.persistRuntimeNow()
|
|
656
|
+
await this.persistQuotaNow()
|
|
657
|
+
this.started = false
|
|
658
|
+
this.ready = undefined
|
|
659
|
+
this.ownerCtx = undefined
|
|
660
|
+
this.quota = undefined
|
|
661
|
+
this.quotaInitialized = false
|
|
662
|
+
this.quotaInitial = []
|
|
663
|
+
// Keep the core object bound to the process. A later setup revives it via
|
|
664
|
+
// init() (started=false) instead of creating a second core and racing on
|
|
665
|
+
// the same plugin storage.
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export const sharedCore: SharedCore =
|
|
670
|
+
((globalThis as any)[KEY] as SharedCore | undefined) ??
|
|
671
|
+
(((globalThis as any)[KEY] = new SharedCore()) as SharedCore)
|