@goodandready/dsh-model-sync 0.2.7 → 0.2.10
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 +27 -0
- package/lib/adapter-registry.js +147 -27
- package/lib/client.js +87 -14
- package/lib/credentials.js +86 -5
- package/lib/generic-adapter.js +37 -5
- package/lib/history.js +138 -0
- package/lib/http.js +121 -4
- package/lib/index.js +62 -1
- package/lib/lifecycle.js +130 -0
- package/lib/models.js +203 -11
- package/lib/policy.js +71 -0
- package/lib/reconcile.js +8 -2
- package/lib/reliability.js +112 -0
- package/lib/reporting.js +155 -0
- package/lib/scheduler.js +127 -21
- package/lib/synchronizer.js +427 -36
- package/package.json +1 -1
package/lib/history.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { normalizeModels } from './models.js'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_HISTORY_LIMIT = 50
|
|
4
|
+
export const MAX_HISTORY_LIMIT = 200
|
|
5
|
+
export const MAX_HISTORY_MODELS = 5000
|
|
6
|
+
const MAX_MESSAGE_LENGTH = 512
|
|
7
|
+
|
|
8
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
9
|
+
const number = Number(value)
|
|
10
|
+
if (!Number.isFinite(number)) return fallback
|
|
11
|
+
return Math.min(maximum, Math.max(minimum, Math.floor(number)))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function historyLimit(value) {
|
|
15
|
+
return boundedInteger(value, DEFAULT_HISTORY_LIMIT, 1, MAX_HISTORY_LIMIT)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cloneModel(provider, model) {
|
|
19
|
+
return structuredClone(normalizeModels(provider, [model])[0] ?? null)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function catalog(provider, models) {
|
|
23
|
+
return normalizeModels(provider, models).slice(0, MAX_HISTORY_MODELS)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function message(value) {
|
|
27
|
+
if (value === undefined || value === null) return undefined
|
|
28
|
+
return String(value).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, MAX_MESSAGE_LENGTH)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function diffFor(provider, diff) {
|
|
32
|
+
const source = diff && typeof diff === 'object' ? diff : {}
|
|
33
|
+
return {
|
|
34
|
+
added: (source.added ?? []).map((row) => cloneModel(provider, row)).filter(Boolean),
|
|
35
|
+
removed: (source.removed ?? []).map((row) => cloneModel(provider, row)).filter(Boolean),
|
|
36
|
+
renamed: (source.renamed ?? []).map((row) => ({
|
|
37
|
+
before: cloneModel(provider, row?.before),
|
|
38
|
+
after: cloneModel(provider, row?.after),
|
|
39
|
+
})).filter((row) => row.before && row.after),
|
|
40
|
+
changed: (source.changed ?? []).map((row) => ({
|
|
41
|
+
before: cloneModel(provider, row?.before),
|
|
42
|
+
after: cloneModel(provider, row?.after),
|
|
43
|
+
})).filter((row) => row.before && row.after),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createHistoryEntry(result, { version = 1, maxModels = MAX_HISTORY_MODELS } = {}) {
|
|
48
|
+
const rows = Array.isArray(result?.results) ? result.results : []
|
|
49
|
+
const providers = rows.map((row) => {
|
|
50
|
+
const provider = String(row?.provider ?? '')
|
|
51
|
+
const diff = diffFor(provider, row?.diff)
|
|
52
|
+
const before = catalog(provider, row?.before).slice(0, maxModels)
|
|
53
|
+
const after = catalog(provider, row?.next).slice(0, maxModels)
|
|
54
|
+
return {
|
|
55
|
+
provider,
|
|
56
|
+
status: String(row?.status ?? 'unknown'),
|
|
57
|
+
changed: Boolean(row?.changed),
|
|
58
|
+
...(Number.isFinite(row?.retries) ? { retries: row.retries } : {}),
|
|
59
|
+
...(message(row?.message) ? { message: message(row.message) } : {}),
|
|
60
|
+
counts: {
|
|
61
|
+
added: diff.added.length,
|
|
62
|
+
removed: diff.removed.length,
|
|
63
|
+
renamed: diff.renamed.length,
|
|
64
|
+
changed: diff.changed.length,
|
|
65
|
+
stale: Array.isArray(row?.stale) ? row.stale.length : 0,
|
|
66
|
+
},
|
|
67
|
+
diff,
|
|
68
|
+
before,
|
|
69
|
+
after,
|
|
70
|
+
}
|
|
71
|
+
}).sort((left, right) => left.provider.localeCompare(right.provider))
|
|
72
|
+
const startedAt = Number.isFinite(result?.startedAt) ? result.startedAt : Date.now()
|
|
73
|
+
const finishedAt = Number.isFinite(result?.finishedAt) ? result.finishedAt : startedAt
|
|
74
|
+
return {
|
|
75
|
+
id: `sync-${version}-${finishedAt}`,
|
|
76
|
+
version,
|
|
77
|
+
startedAt,
|
|
78
|
+
finishedAt,
|
|
79
|
+
providers,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function appendHistory(history, entry, limit = DEFAULT_HISTORY_LIMIT) {
|
|
84
|
+
const rows = Array.isArray(history) ? history.filter((row) => row && typeof row === 'object') : []
|
|
85
|
+
const next = [...rows, structuredClone(entry)]
|
|
86
|
+
next.sort((left, right) => Number(left.version ?? 0) - Number(right.version ?? 0))
|
|
87
|
+
return next.slice(-historyLimit(limit))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function briefModel(model) {
|
|
91
|
+
if (!model || typeof model !== 'object') return null
|
|
92
|
+
return { id: model.id, name: model.name }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function briefDiff(diff) {
|
|
96
|
+
const source = diff ?? {}
|
|
97
|
+
return {
|
|
98
|
+
added: (source.added ?? []).map(briefModel).filter(Boolean),
|
|
99
|
+
removed: (source.removed ?? []).map(briefModel).filter(Boolean),
|
|
100
|
+
renamed: (source.renamed ?? []).map((pair) => ({ before: briefModel(pair.before), after: briefModel(pair.after) })).filter((pair) => pair.before && pair.after),
|
|
101
|
+
changed: (source.changed ?? []).map((pair) => ({ before: briefModel(pair.before), after: briefModel(pair.after) })).filter((pair) => pair.before && pair.after),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function summaryProvider(row) {
|
|
106
|
+
return {
|
|
107
|
+
provider: row.provider,
|
|
108
|
+
status: row.status,
|
|
109
|
+
changed: Boolean(row.changed),
|
|
110
|
+
...(row.retries === undefined ? {} : { retries: row.retries }),
|
|
111
|
+
...(row.message ? { message: row.message } : {}),
|
|
112
|
+
counts: structuredClone(row.counts ?? { added: 0, removed: 0, renamed: 0, changed: 0, stale: 0 }),
|
|
113
|
+
diff: briefDiff(row.diff),
|
|
114
|
+
hasRollback: Array.isArray(row.before),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function historySummary(entry) {
|
|
119
|
+
return {
|
|
120
|
+
id: entry.id,
|
|
121
|
+
version: entry.version,
|
|
122
|
+
startedAt: entry.startedAt,
|
|
123
|
+
finishedAt: entry.finishedAt,
|
|
124
|
+
providers: (entry.providers ?? []).map(summaryProvider),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function listHistory(history, { limit = DEFAULT_HISTORY_LIMIT, provider, historyId, details = false } = {}) {
|
|
129
|
+
let rows = Array.isArray(history) ? history : []
|
|
130
|
+
if (historyId) rows = rows.filter((entry) => entry.id === historyId)
|
|
131
|
+
if (provider) rows = rows.map((entry) => ({ ...entry, providers: (entry.providers ?? []).filter((row) => row.provider === provider) })).filter((entry) => entry.providers.length > 0)
|
|
132
|
+
rows = rows.slice().sort((left, right) => Number(right.version ?? 0) - Number(left.version ?? 0)).slice(0, historyLimit(limit))
|
|
133
|
+
return rows.map((entry) => details ? structuredClone(entry) : historySummary(entry))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function historyEntry(history, id) {
|
|
137
|
+
return (Array.isArray(history) ? history : []).find((entry) => entry?.id === id) ?? null
|
|
138
|
+
}
|
package/lib/http.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validatePolicy } from './policy.js'
|
|
2
|
+
|
|
1
3
|
const BASE = '/dsh-model-sync'
|
|
2
4
|
|
|
3
5
|
function json(res, status, body) {
|
|
@@ -40,6 +42,77 @@ export function normalizeRunRequest(body) {
|
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
export function normalizeCredentialRequest(url = '') {
|
|
46
|
+
const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
|
|
47
|
+
const provider = parsed.searchParams.get('provider') || undefined
|
|
48
|
+
if (provider && (provider.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(provider))) throw new Error('provider must be a short string')
|
|
49
|
+
return provider ? { provider } : {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeNotificationRequest(url = '') {
|
|
53
|
+
const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
|
|
54
|
+
return { includeAcknowledged: parsed.searchParams.get('includeAcknowledged') !== 'false' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeNotificationAction(body) {
|
|
58
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
59
|
+
if (typeof body.id !== 'string' || !body.id || body.id.length > 128) throw new Error('notification id is invalid')
|
|
60
|
+
return { id: body.id.trim() }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizeCredentialCheckRequest(body) {
|
|
64
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
65
|
+
if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
|
|
66
|
+
return body.provider ? { provider: body.provider.trim() } : {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normalizeHealthRequest(body) {
|
|
70
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
71
|
+
if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
|
|
72
|
+
return body.provider ? { provider: body.provider } : {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizePolicyRequest(body) {
|
|
76
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
77
|
+
if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
|
|
78
|
+
for (const field of ['include', 'exclude']) {
|
|
79
|
+
if (body[field] !== undefined && (!Array.isArray(body[field]) || body[field].length > 100)) throw new Error(`${field} must be an array`)
|
|
80
|
+
if (Array.isArray(body[field]) && body[field].some((value) => typeof value !== 'string' || value.length > 256)) throw new Error(`${field} patterns must be short strings`)
|
|
81
|
+
}
|
|
82
|
+
for (const field of ['requireCapabilities', 'denyCapabilities']) {
|
|
83
|
+
if (body[field] !== undefined && (!body[field] || typeof body[field] !== 'object' || Array.isArray(body[field]))) throw new Error(`${field} must be an object`)
|
|
84
|
+
if (body[field] && Object.keys(body[field]).length > 4) throw new Error(`${field} has too many capabilities`)
|
|
85
|
+
if (body[field] && Object.values(body[field]).some((value) => typeof value !== 'boolean')) throw new Error(`${field} values must be boolean`)
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
provider: body.provider.trim(),
|
|
89
|
+
policy: validatePolicy({
|
|
90
|
+
include: body.include,
|
|
91
|
+
exclude: body.exclude,
|
|
92
|
+
requireCapabilities: body.requireCapabilities,
|
|
93
|
+
denyCapabilities: body.denyCapabilities,
|
|
94
|
+
}),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function normalizeHistoryRequest(url = '') {
|
|
99
|
+
const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
|
|
100
|
+
const limitValue = Number(parsed.searchParams.get('limit') ?? 50)
|
|
101
|
+
const limit = Number.isFinite(limitValue) ? Math.min(100, Math.max(1, Math.floor(limitValue))) : 50
|
|
102
|
+
const provider = parsed.searchParams.get('provider') || undefined
|
|
103
|
+
const historyId = parsed.searchParams.get('historyId') || undefined
|
|
104
|
+
if (provider && provider.length > 128) throw new Error('provider must be a short string')
|
|
105
|
+
if (historyId && historyId.length > 128) throw new Error('historyId must be a short string')
|
|
106
|
+
return { limit, ...(provider ? { provider } : {}), ...(historyId ? { historyId } : {}), details: parsed.searchParams.get('details') === 'true' }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizeHistoryRollbackRequest(body) {
|
|
110
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
111
|
+
if (typeof body.historyId !== 'string' || !body.historyId || body.historyId.length > 128) throw new Error('historyId must be a short string')
|
|
112
|
+
if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
|
|
113
|
+
return { historyId: body.historyId.trim(), provider: body.provider.trim() }
|
|
114
|
+
}
|
|
115
|
+
|
|
43
116
|
export function normalizeSelectionRequest(body) {
|
|
44
117
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
45
118
|
if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
|
|
@@ -57,31 +130,75 @@ export function normalizeSelectionRequest(body) {
|
|
|
57
130
|
return { provider: body.provider, models }
|
|
58
131
|
}
|
|
59
132
|
|
|
60
|
-
export function registerHttpApi(ctx, synchronizer) {
|
|
133
|
+
export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () => null } = {}) {
|
|
61
134
|
const makeHandler = (route) => async (req, res) => {
|
|
62
135
|
if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
|
|
63
136
|
if (route === 'status') {
|
|
64
137
|
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
65
|
-
return json(res, 200, { ...synchronizer.status(), providers: synchronizer.listProviders() })
|
|
138
|
+
return json(res, 200, { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() })
|
|
139
|
+
}
|
|
140
|
+
if (route === 'history') {
|
|
141
|
+
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
142
|
+
try { return json(res, 200, { history: synchronizer.history(normalizeHistoryRequest(req.url)) }) }
|
|
143
|
+
catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
|
|
144
|
+
}
|
|
145
|
+
if (route === 'credentials') {
|
|
146
|
+
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
147
|
+
try { return json(res, 200, await synchronizer.credentialDiagnostics(normalizeCredentialRequest(req.url))) }
|
|
148
|
+
catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
|
|
149
|
+
}
|
|
150
|
+
if (route === 'report') {
|
|
151
|
+
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
152
|
+
return json(res, 200, { report: synchronizer.report() })
|
|
153
|
+
}
|
|
154
|
+
if (route === 'notifications') {
|
|
155
|
+
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
156
|
+
return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
|
|
66
157
|
}
|
|
67
158
|
if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
|
|
68
159
|
let options
|
|
69
|
-
try {
|
|
160
|
+
try {
|
|
161
|
+
const body = await readJson(req)
|
|
162
|
+
options = route === 'selection'
|
|
163
|
+
? normalizeSelectionRequest(body)
|
|
164
|
+
: route === 'policy' ? normalizePolicyRequest(body)
|
|
165
|
+
: route === 'health' ? normalizeHealthRequest(body)
|
|
166
|
+
: route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
|
|
167
|
+
: route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
|
|
168
|
+
: route === 'rollback' ? normalizeHistoryRollbackRequest(body)
|
|
169
|
+
: normalizeRunRequest(body)
|
|
170
|
+
}
|
|
70
171
|
catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
|
|
71
172
|
try {
|
|
72
173
|
const result = route === 'selection'
|
|
73
174
|
? await synchronizer.setModelSelection(options.provider, options.models)
|
|
175
|
+
: route === 'policy' ? await synchronizer.setModelPolicy(options.provider, options.policy)
|
|
176
|
+
: route === 'health' ? await synchronizer.health(options)
|
|
177
|
+
: route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
|
|
178
|
+
: route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
|
|
179
|
+
: route === 'notification-acknowledge' ? await synchronizer.updateNotification(options.id, 'acknowledged')
|
|
180
|
+
: route === 'rollback' ? await synchronizer.rollbackHistory(options)
|
|
74
181
|
: await synchronizer.run(options)
|
|
75
182
|
return json(res, 200, { ...result, providers: synchronizer.listProviders() })
|
|
76
183
|
}
|
|
77
184
|
catch (error) {
|
|
78
|
-
const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' || error?.code === 'CONFIG_UNAVAILABLE' ? 503 : error?.code === 'PROVIDER_NOT_CONFIGURED' || error?.code === 'UNKNOWN_MODEL' ? 400 : 500
|
|
185
|
+
const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' || error?.code === 'CONFIG_UNAVAILABLE' ? 503 : error?.code === 'PROVIDER_NOT_CONFIGURED' || error?.code === 'UNKNOWN_MODEL' || error?.code === 'INVALID_POLICY' || error?.code === 'INVALID_HISTORY' || error?.code === 'HISTORY_NOT_FOUND' || error?.code === 'INVALID_NOTIFICATION' || error?.code === 'NOTIFICATION_NOT_FOUND' ? 400 : 500
|
|
79
186
|
return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
|
|
80
187
|
}
|
|
81
188
|
}
|
|
82
189
|
return [
|
|
83
190
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
|
|
84
191
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
|
|
192
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
|
|
85
193
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
|
|
194
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
|
|
195
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/history`, handler: makeHandler('history') }),
|
|
196
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/history/rollback`, handler: makeHandler('rollback') }),
|
|
197
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials`, handler: makeHandler('credentials') }),
|
|
198
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials/check`, handler: makeHandler('credentials-check') }),
|
|
199
|
+
ctx.webServer.register({ kind: 'exact', path: BASE + '/report', handler: makeHandler('report') }),
|
|
200
|
+
ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications', handler: makeHandler('notifications') }),
|
|
201
|
+
ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
|
|
202
|
+
ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
|
|
86
203
|
]
|
|
87
204
|
}
|
package/lib/index.js
CHANGED
|
@@ -12,12 +12,73 @@ const NS = 'dsh-model-sync'
|
|
|
12
12
|
export const Config = Schema.object({
|
|
13
13
|
enabled: Schema.boolean().default(true),
|
|
14
14
|
intervalMinutes: Schema.number().default(60),
|
|
15
|
+
scheduleEnabled: Schema.boolean().default(false),
|
|
16
|
+
jitterMinutes: Schema.number().default(0),
|
|
17
|
+
ttlMinutes: Schema.number().default(0),
|
|
15
18
|
autoApply: Schema.boolean().default(false),
|
|
19
|
+
requestTimeoutMs: Schema.number().default(15000),
|
|
20
|
+
retryAttempts: Schema.number().default(3),
|
|
21
|
+
retryBaseDelayMs: Schema.number().default(250),
|
|
22
|
+
retryMaxDelayMs: Schema.number().default(4000),
|
|
23
|
+
concurrency: Schema.number().default(4),
|
|
24
|
+
circuitBreakerFailures: Schema.number().default(3),
|
|
25
|
+
circuitBreakerCooldownMinutes: Schema.number().default(5),
|
|
26
|
+
historyLimit: Schema.number().default(50),
|
|
27
|
+
notificationLimit: Schema.number().default(50),
|
|
28
|
+
notifications: Schema.array(Schema.object({
|
|
29
|
+
id: Schema.string().required(),
|
|
30
|
+
fingerprint: Schema.string().required(),
|
|
31
|
+
createdAt: Schema.number().required(),
|
|
32
|
+
updatedAt: Schema.number().required(),
|
|
33
|
+
occurrences: Schema.number().default(1),
|
|
34
|
+
readAt: Schema.any().default(null),
|
|
35
|
+
acknowledgedAt: Schema.any().default(null),
|
|
36
|
+
severity: Schema.string().required(),
|
|
37
|
+
title: Schema.string().required(),
|
|
38
|
+
message: Schema.string().required(),
|
|
39
|
+
providers: Schema.array(Schema.object({}).loose()).default([]),
|
|
40
|
+
}).loose()).default([]),
|
|
41
|
+
staleGraceRuns: Schema.number().default(2),
|
|
42
|
+
lifecycleRetentionRuns: Schema.number().default(20),
|
|
43
|
+
lifecycleRevision: Schema.number().default(0),
|
|
44
|
+
modelLifecycle: Schema.dict(Schema.dict(Schema.object({
|
|
45
|
+
status: Schema.string().required(),
|
|
46
|
+
consecutiveMissing: Schema.number().default(0),
|
|
47
|
+
firstMissingAt: Schema.number().default(0),
|
|
48
|
+
lastSeenAt: Schema.number().default(0),
|
|
49
|
+
lastObservedRun: Schema.number().default(0),
|
|
50
|
+
}).loose())).default({}),
|
|
51
|
+
history: Schema.array(Schema.object({
|
|
52
|
+
id: Schema.string().required(),
|
|
53
|
+
version: Schema.number().required(),
|
|
54
|
+
startedAt: Schema.number().required(),
|
|
55
|
+
finishedAt: Schema.number().required(),
|
|
56
|
+
providers: Schema.array(Schema.object({}).loose()).default([]),
|
|
57
|
+
}).loose()).default([]),
|
|
16
58
|
providers: Schema.array(Schema.object({
|
|
17
59
|
provider: Schema.string().required(),
|
|
18
60
|
enabled: Schema.boolean().default(true),
|
|
61
|
+
intervalMinutes: Schema.number().default(0),
|
|
62
|
+
jitterMinutes: Schema.number().default(0),
|
|
63
|
+
ttlMinutes: Schema.number().default(0),
|
|
19
64
|
})).default([]),
|
|
20
65
|
modelSelections: Schema.dict(Schema.array(Schema.string())).default({}),
|
|
66
|
+
adapterRegistry: Schema.dict(Schema.object({
|
|
67
|
+
enabled: Schema.boolean().default(true),
|
|
68
|
+
endpoint: Schema.string().default(''),
|
|
69
|
+
auth: Schema.string().default('bearer'),
|
|
70
|
+
parser: Schema.string().default('openai'),
|
|
71
|
+
modelsPath: Schema.string().default('data'),
|
|
72
|
+
fields: Schema.any().default({}),
|
|
73
|
+
capabilityMap: Schema.any().default({}),
|
|
74
|
+
headers: Schema.dict(Schema.string()).default({}),
|
|
75
|
+
}).loose()).default({}),
|
|
76
|
+
modelPolicies: Schema.dict(Schema.object({
|
|
77
|
+
include: Schema.array(Schema.string()).default([]),
|
|
78
|
+
exclude: Schema.array(Schema.string()).default([]),
|
|
79
|
+
requireCapabilities: Schema.dict(Schema.boolean()).default({}),
|
|
80
|
+
denyCapabilities: Schema.dict(Schema.boolean()).default({}),
|
|
81
|
+
}).loose()).default({}),
|
|
21
82
|
// Full catalogs are cached separately from the allowlist applied to DSH.
|
|
22
83
|
// This keeps the model picker useful after a restart without widening the
|
|
23
84
|
// active llm-pi-ai catalog.
|
|
@@ -81,7 +142,7 @@ export function apply(ctx, config) {
|
|
|
81
142
|
reconfigure = () => scheduler.reconfigure()
|
|
82
143
|
scheduler.start()
|
|
83
144
|
ctx.effect(() => {
|
|
84
|
-
const registrations = registerHttpApi(ctx, synchronizer)
|
|
145
|
+
const registrations = registerHttpApi(ctx, synchronizer, { getSchedulerStatus: () => scheduler.status() })
|
|
85
146
|
ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
|
|
86
147
|
return () => {
|
|
87
148
|
for (const dispose of registrations) if (typeof dispose === 'function') dispose()
|
package/lib/lifecycle.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export const LIFECYCLE_STATES = Object.freeze(['active', 'stale', 'deprecated', 'removed'])
|
|
2
|
+
export const DEFAULT_STALE_GRACE_RUNS = 2
|
|
3
|
+
export const DEFAULT_LIFECYCLE_RETENTION_RUNS = 20
|
|
4
|
+
|
|
5
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
6
|
+
const number = Number(value)
|
|
7
|
+
if (!Number.isFinite(number)) return fallback
|
|
8
|
+
return Math.min(maximum, Math.max(minimum, Math.floor(number)))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function lifecycleOptions(config = {}) {
|
|
12
|
+
return {
|
|
13
|
+
staleGraceRuns: boundedInteger(config.staleGraceRuns, DEFAULT_STALE_GRACE_RUNS, 1, 20),
|
|
14
|
+
lifecycleRetentionRuns: boundedInteger(config.lifecycleRetentionRuns, DEFAULT_LIFECYCLE_RETENTION_RUNS, 1, 200),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function dateValue(value) {
|
|
19
|
+
if (typeof value !== 'string' || !value.trim()) return undefined
|
|
20
|
+
const timestamp = Date.parse(value)
|
|
21
|
+
return Number.isFinite(timestamp) ? timestamp : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function explicitLifecycle(model, now) {
|
|
25
|
+
const source = model?.lifecycle && typeof model.lifecycle === 'object' ? model.lifecycle : {}
|
|
26
|
+
const deprecated = source.deprecated === true || model?.deprecated === true || model?.isDeprecated === true
|
|
27
|
+
const deprecationDate = source.deprecationDate ?? source.deprecation_date ?? source.expirationDate ?? source.expiration_date ?? source.expiresAt ?? source.expires_at
|
|
28
|
+
const deprecationAt = dateValue(deprecationDate)
|
|
29
|
+
return {
|
|
30
|
+
deprecated: deprecated || deprecationAt !== undefined && deprecationAt <= now,
|
|
31
|
+
...(deprecationAt === undefined ? {} : { deprecationAt }),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function recordOf(previous, runNumber, now) {
|
|
36
|
+
const record = previous && typeof previous === 'object' ? structuredClone(previous) : {}
|
|
37
|
+
return {
|
|
38
|
+
status: LIFECYCLE_STATES.includes(record.status) ? record.status : 'active',
|
|
39
|
+
consecutiveMissing: Number.isFinite(record.consecutiveMissing) ? Math.max(0, record.consecutiveMissing) : 0,
|
|
40
|
+
firstMissingAt: Number.isFinite(record.firstMissingAt) ? record.firstMissingAt : 0,
|
|
41
|
+
lastSeenAt: Number.isFinite(record.lastSeenAt) ? record.lastSeenAt : 0,
|
|
42
|
+
lastObservedRun: Number.isFinite(record.lastObservedRun) ? record.lastObservedRun : runNumber,
|
|
43
|
+
...(Number.isFinite(record.deprecationAt) ? { deprecationAt: record.deprecationAt } : {}),
|
|
44
|
+
...(Number.isFinite(record.removedAt) ? { removedAt: record.removedAt } : {}),
|
|
45
|
+
...(typeof record.reason === 'string' ? { reason: record.reason.slice(0, 256) } : {}),
|
|
46
|
+
observedAt: now,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function publicRecord(id, record) {
|
|
51
|
+
return { id, ...record }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function updateLifecycle(previous, beforeModels, advertisedModels, {
|
|
55
|
+
runNumber = 1,
|
|
56
|
+
now = Date.now(),
|
|
57
|
+
removeMissing = false,
|
|
58
|
+
staleGraceRuns = DEFAULT_STALE_GRACE_RUNS,
|
|
59
|
+
lifecycleRetentionRuns = DEFAULT_LIFECYCLE_RETENTION_RUNS,
|
|
60
|
+
} = {}) {
|
|
61
|
+
const before = Array.isArray(beforeModels) ? beforeModels : []
|
|
62
|
+
const advertised = Array.isArray(advertisedModels) ? advertisedModels : []
|
|
63
|
+
const advertisedIds = new Set(advertised.map((row) => row?.id).filter(Boolean))
|
|
64
|
+
const result = structuredClone(previous && typeof previous === 'object' ? previous : {})
|
|
65
|
+
const changes = []
|
|
66
|
+
const removedIds = []
|
|
67
|
+
for (const model of advertised) {
|
|
68
|
+
const id = model?.id
|
|
69
|
+
if (!id) continue
|
|
70
|
+
const previousRecord = result[id]
|
|
71
|
+
const record = recordOf(previousRecord, runNumber, now)
|
|
72
|
+
const signal = explicitLifecycle(model, now)
|
|
73
|
+
record.status = signal.deprecated ? 'deprecated' : 'active'
|
|
74
|
+
record.consecutiveMissing = 0
|
|
75
|
+
record.firstMissingAt = 0
|
|
76
|
+
record.lastSeenAt = now
|
|
77
|
+
record.lastObservedRun = runNumber
|
|
78
|
+
if (signal.deprecationAt !== undefined) record.deprecationAt = signal.deprecationAt
|
|
79
|
+
else delete record.deprecationAt
|
|
80
|
+
delete record.removedAt
|
|
81
|
+
delete record.reason
|
|
82
|
+
if (previousRecord?.status !== record.status) changes.push({ id, before: previousRecord?.status ?? 'active', after: record.status })
|
|
83
|
+
result[id] = record
|
|
84
|
+
}
|
|
85
|
+
for (const model of before) {
|
|
86
|
+
const id = model?.id
|
|
87
|
+
if (!id || advertisedIds.has(id)) continue
|
|
88
|
+
const previousRecord = result[id]
|
|
89
|
+
const record = recordOf(previousRecord, runNumber, now)
|
|
90
|
+
if (record.status === 'removed') continue
|
|
91
|
+
record.consecutiveMissing += 1
|
|
92
|
+
record.firstMissingAt ||= now
|
|
93
|
+
record.lastObservedRun = runNumber
|
|
94
|
+
if (record.status !== 'deprecated') record.status = 'stale'
|
|
95
|
+
if (removeMissing && record.status === 'stale' && record.consecutiveMissing >= staleGraceRuns) {
|
|
96
|
+
record.status = 'removed'
|
|
97
|
+
record.removedAt = now
|
|
98
|
+
record.reason = `missing for ${record.consecutiveMissing} discovery runs`
|
|
99
|
+
removedIds.push(id)
|
|
100
|
+
}
|
|
101
|
+
if (previousRecord?.status !== record.status || previousRecord?.consecutiveMissing !== record.consecutiveMissing) {
|
|
102
|
+
changes.push({ id, before: previousRecord?.status ?? 'active', after: record.status, consecutiveMissing: record.consecutiveMissing })
|
|
103
|
+
}
|
|
104
|
+
result[id] = record
|
|
105
|
+
}
|
|
106
|
+
for (const [id, record] of Object.entries(result)) {
|
|
107
|
+
if (record.status === 'removed' && Number.isFinite(record.removedAt) && runNumber - (record.lastObservedRun ?? runNumber) >= lifecycleRetentionRuns) {
|
|
108
|
+
delete result[id]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
records: result,
|
|
113
|
+
changes,
|
|
114
|
+
removedIds,
|
|
115
|
+
statuses: Object.entries(result).map(([id, record]) => publicRecord(id, record)).sort((left, right) => left.id.localeCompare(right.id)),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function lifecycleStatus(records, provider, models) {
|
|
120
|
+
const map = records?.[provider] && typeof records[provider] === 'object' ? records[provider] : {}
|
|
121
|
+
return (models ?? []).map((model) => ({
|
|
122
|
+
...model,
|
|
123
|
+
lifecycleStatus: map[model.id]?.status ?? 'active',
|
|
124
|
+
lifecycleMissingRuns: map[model.id]?.consecutiveMissing ?? 0,
|
|
125
|
+
}))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function selectableModels(records, provider, models) {
|
|
129
|
+
return lifecycleStatus(records, provider, models).filter((model) => model.lifecycleStatus === 'active')
|
|
130
|
+
}
|