dsh-vision-router 2.2.1 → 2.2.3
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 +6 -6
- package/README.zh.md +6 -6
- package/cordis.patch.yml +11 -0
- package/docs/architecture/dsh-compatibility-matrix.md +6 -6
- package/docs/architecture/dsh-support-window.md +7 -7
- package/docs/architecture/host-first-proxy-convergence.md +1 -1
- package/docs/releases/v2.2.2.md +21 -0
- package/docs/releases/v2.2.3.md +22 -0
- package/index.js +28 -16
- package/lib/client.js +165 -31
- package/lib/core-primitives.js +118 -8
- package/lib/dsh-host-capabilities.js +10 -4
- package/lib/dsh-settings-017-compat.js +416 -0
- package/lib/dsh-support-window.js +4 -4
- package/lib/public-entry.js +21 -1
- package/lib/remote-settings-bridge.js +1 -0
- package/lib/runtime-i18n-boundary.js +4 -0
- package/lib/session-message-source-compat.js +92 -0
- package/lib/session-surface-compat.js +3 -2
- package/lib/session-vision-index.js +50 -0
- package/lib/settings-client-017-compat.js +331 -0
- package/lib/settings-client-rc8-lifecycle.js +13 -0
- package/lib/settings-ia-client-prelude.js +6 -6
- package/lib/settings-native-card-layout.js +1 -1
- package/lib/web/dsh-settings-017-root-transport.js +299 -0
- package/lib/web/remote-settings-client.js +200 -0
- package/package.json +4 -4
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
const SETTINGS_NS = 'vision-router'
|
|
2
|
+
export const LOCAL_SETTINGS_PATH = '/_dsh/vision-router/local-settings'
|
|
3
|
+
export const DSH_017_SETTINGS_COMPAT_MARK = '__visionRouterDsh017ConfigEditorCompat'
|
|
4
|
+
const BODY_LIMIT_BYTES = 256 * 1024
|
|
5
|
+
const REVISION_REGISTRY_KEY = Symbol.for('dsh-vision-router.settings-017-revisions')
|
|
6
|
+
|
|
7
|
+
function revisionRegistry() {
|
|
8
|
+
let registry = globalThis[REVISION_REGISTRY_KEY]
|
|
9
|
+
if (!(registry instanceof Map)) {
|
|
10
|
+
registry = new Map()
|
|
11
|
+
Object.defineProperty(globalThis, REVISION_REGISTRY_KEY, {
|
|
12
|
+
value: registry,
|
|
13
|
+
configurable: true,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
return registry
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function objectLike(value) {
|
|
20
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function plainObject(value) {
|
|
24
|
+
if (!objectLike(value)) return false
|
|
25
|
+
const proto = Object.getPrototypeOf(value)
|
|
26
|
+
return proto === Object.prototype || proto === null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clone(value) {
|
|
30
|
+
return value === undefined ? undefined : structuredClone(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mergeResolved(base, overlay) {
|
|
34
|
+
if (!plainObject(base) || !plainObject(overlay)) return clone(overlay)
|
|
35
|
+
const next = clone(base)
|
|
36
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
37
|
+
next[key] = plainObject(value) && plainObject(next[key])
|
|
38
|
+
? mergeResolved(next[key], value)
|
|
39
|
+
: clone(value)
|
|
40
|
+
}
|
|
41
|
+
return next
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function jsonEqual(left, right) {
|
|
45
|
+
if (left === right) return true
|
|
46
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
47
|
+
return Array.isArray(left) && Array.isArray(right)
|
|
48
|
+
&& left.length === right.length
|
|
49
|
+
&& left.every((value, index) => jsonEqual(value, right[index]))
|
|
50
|
+
}
|
|
51
|
+
if (!plainObject(left) || !plainObject(right)) return false
|
|
52
|
+
const leftKeys = Object.keys(left)
|
|
53
|
+
const rightKeys = Object.keys(right)
|
|
54
|
+
return leftKeys.length === rightKeys.length
|
|
55
|
+
&& leftKeys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function logicalUserLayer(current, inherited) {
|
|
59
|
+
const user = Object.create(null)
|
|
60
|
+
const keys = new Set([
|
|
61
|
+
...Object.keys(objectLike(current) ? current : {}),
|
|
62
|
+
...Object.keys(objectLike(inherited) ? inherited : {}),
|
|
63
|
+
])
|
|
64
|
+
for (const key of keys) {
|
|
65
|
+
const currentHas = objectLike(current) && Object.hasOwn(current, key)
|
|
66
|
+
const inheritedHas = objectLike(inherited) && Object.hasOwn(inherited, key)
|
|
67
|
+
if (currentHas === inheritedHas && (!currentHas || jsonEqual(current[key], inherited[key]))) continue
|
|
68
|
+
if (currentHas) user[key] = clone(current[key])
|
|
69
|
+
}
|
|
70
|
+
return user
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stableValue(value) {
|
|
74
|
+
if (Array.isArray(value)) return value.map(stableValue)
|
|
75
|
+
if (!plainObject(value)) return value
|
|
76
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fingerprint(value) {
|
|
80
|
+
return JSON.stringify(stableValue(value))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function revisionKey(editor, namespace) {
|
|
84
|
+
let documentPath
|
|
85
|
+
try { documentPath = editor?.documentPath } catch {}
|
|
86
|
+
return `${String(documentPath ?? 'process-local-profile')}\u0000${namespace}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Ordinary Config writes reload DVR, so a plugin-instance counter is unsafe.
|
|
91
|
+
* Keep one process-level monotonic counter per profile document + namespace.
|
|
92
|
+
* The registry survives DVR HMR and advances whenever the effective persisted
|
|
93
|
+
* layers observed at the compatibility boundary change. A process restart also
|
|
94
|
+
* resets every browser connection, which forces a fresh descriptor read.
|
|
95
|
+
*/
|
|
96
|
+
function revisionFor(editor, namespace, current, inherited) {
|
|
97
|
+
const key = revisionKey(editor, namespace)
|
|
98
|
+
const valueFingerprint = fingerprint({ current, inherited })
|
|
99
|
+
const registry = revisionRegistry()
|
|
100
|
+
const previous = registry.get(key)
|
|
101
|
+
if (!previous) {
|
|
102
|
+
const state = { fingerprint: valueFingerprint, revision: 0 }
|
|
103
|
+
registry.set(key, state)
|
|
104
|
+
return state.revision
|
|
105
|
+
}
|
|
106
|
+
if (previous.fingerprint !== valueFingerprint) {
|
|
107
|
+
previous.fingerprint = valueFingerprint
|
|
108
|
+
previous.revision += 1
|
|
109
|
+
}
|
|
110
|
+
return previous.revision
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function serviceOf(ctx, name) {
|
|
114
|
+
try {
|
|
115
|
+
const value = typeof ctx?.get === 'function' ? ctx.get(name) : undefined
|
|
116
|
+
if (value !== undefined && value !== null) return value
|
|
117
|
+
} catch {}
|
|
118
|
+
try {
|
|
119
|
+
const value = ctx?.[name]
|
|
120
|
+
return value === undefined || value === null ? undefined : value
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function configEditorRow(editor, namespace) {
|
|
127
|
+
const rows = typeof editor?.configuration === 'function' ? editor.configuration() : []
|
|
128
|
+
if (!Array.isArray(rows)) return undefined
|
|
129
|
+
return rows.find((row) => row?.entry?.options?.id === namespace)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function settingsConflict(expected, actual) {
|
|
133
|
+
const error = new Error(`Vision Router settings changed before this write landed (expected ${expected}, actual ${actual})`)
|
|
134
|
+
error.code = 'SETTINGS_CONFLICT'
|
|
135
|
+
error.expected = expected
|
|
136
|
+
error.actual = actual
|
|
137
|
+
return error
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function admissionError(message) {
|
|
141
|
+
const error = new Error(message)
|
|
142
|
+
error.code = 'SETTINGS_INVALID_MUTATION'
|
|
143
|
+
return error
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateOperations(ops) {
|
|
147
|
+
if (!Array.isArray(ops) || ops.length === 0) throw admissionError('settings operations must be a non-empty array')
|
|
148
|
+
return ops.map((op) => {
|
|
149
|
+
if (!objectLike(op) || (op.op !== 'set' && op.op !== 'unset')) {
|
|
150
|
+
throw admissionError('settings operation must be set or unset')
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(op.path) || op.path.length !== 1 || typeof op.path[0] !== 'string' || op.path[0] === '') {
|
|
153
|
+
throw admissionError('Vision Router settings compatibility accepts one top-level field per operation')
|
|
154
|
+
}
|
|
155
|
+
const key = op.path[0]
|
|
156
|
+
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
|
157
|
+
throw admissionError('unsafe settings field name')
|
|
158
|
+
}
|
|
159
|
+
return op.op === 'set'
|
|
160
|
+
? { op: 'set', path: [key], value: clone(op.value) }
|
|
161
|
+
: { op: 'unset', path: [key] }
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function applyOperations(current, inherited, ops) {
|
|
166
|
+
const next = clone(objectLike(current) ? current : {})
|
|
167
|
+
for (const op of ops) {
|
|
168
|
+
const key = op.path[0]
|
|
169
|
+
if (op.op === 'set') {
|
|
170
|
+
next[key] = clone(op.value)
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (objectLike(inherited) && Object.hasOwn(inherited, key)) next[key] = clone(inherited[key])
|
|
174
|
+
else delete next[key]
|
|
175
|
+
}
|
|
176
|
+
return next
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
|
|
180
|
+
const listeners = new Set()
|
|
181
|
+
|
|
182
|
+
function state() {
|
|
183
|
+
const row = configEditorRow(editor, namespace)
|
|
184
|
+
if (!row) return undefined
|
|
185
|
+
const current = objectLike(row.entry?.options?.config) ? row.entry.options.config : {}
|
|
186
|
+
const inherited = objectLike(row.inherited) ? row.inherited : {}
|
|
187
|
+
const base = mergeResolved(entryConfig, inherited)
|
|
188
|
+
const value = mergeResolved(entryConfig, current)
|
|
189
|
+
return {
|
|
190
|
+
row,
|
|
191
|
+
descriptor: {
|
|
192
|
+
ns: namespace,
|
|
193
|
+
value,
|
|
194
|
+
base,
|
|
195
|
+
user: logicalUserLayer(current, inherited),
|
|
196
|
+
revision: revisionFor(editor, namespace, current, inherited),
|
|
197
|
+
applies: true,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function notify(value) {
|
|
203
|
+
for (const listener of [...listeners]) {
|
|
204
|
+
try { listener(value, undefined) } catch {}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const scope = {
|
|
209
|
+
get() {
|
|
210
|
+
return state()?.descriptor.value ?? entryConfig
|
|
211
|
+
},
|
|
212
|
+
watch(listener) {
|
|
213
|
+
if (typeof listener !== 'function') return () => {}
|
|
214
|
+
listeners.add(listener)
|
|
215
|
+
return () => listeners.delete(listener)
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
[DSH_017_SETTINGS_COMPAT_MARK]: true,
|
|
221
|
+
get writable() {
|
|
222
|
+
return state() !== undefined
|
|
223
|
+
},
|
|
224
|
+
get(namespaceName) {
|
|
225
|
+
return namespaceName === namespace ? scope.get() : undefined
|
|
226
|
+
},
|
|
227
|
+
register(namespaceName) {
|
|
228
|
+
if (namespaceName !== namespace) {
|
|
229
|
+
throw new Error(`vision-router: unexpected settings namespace ${String(namespaceName)}`)
|
|
230
|
+
}
|
|
231
|
+
return scope
|
|
232
|
+
},
|
|
233
|
+
describe() {
|
|
234
|
+
const current = state()
|
|
235
|
+
return current ? [clone(current.descriptor)] : []
|
|
236
|
+
},
|
|
237
|
+
async mutate(namespaceName, operations, expectedRevision) {
|
|
238
|
+
if (namespaceName !== namespace) throw new Error(`No configurable plugin entry ${JSON.stringify(namespaceName)}`)
|
|
239
|
+
const before = state()
|
|
240
|
+
if (!before) throw new Error(`No configurable plugin entry ${JSON.stringify(namespace)}`)
|
|
241
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
242
|
+
throw admissionError('expectedRevision must be a non-negative safe integer')
|
|
243
|
+
}
|
|
244
|
+
if (expectedRevision !== before.descriptor.revision) {
|
|
245
|
+
throw settingsConflict(expectedRevision, before.descriptor.revision)
|
|
246
|
+
}
|
|
247
|
+
const ops = validateOperations(operations)
|
|
248
|
+
const entry = before.row.entry
|
|
249
|
+
await editor.edit(entry, (current, inherited) => applyOperations(current, inherited, ops))
|
|
250
|
+
const after = state()
|
|
251
|
+
if (!after) throw new Error('Vision Router configuration entry disappeared after the write')
|
|
252
|
+
notify(after.descriptor.value)
|
|
253
|
+
return clone(after.descriptor)
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* DSH 0.1.7 services may mount after DVR's apply() starts. Capture ConfigEditor
|
|
260
|
+
* through its own lifecycle injection and resolve the settings generation when
|
|
261
|
+
* each consumer actually accesses it. This avoids both startup-order races and
|
|
262
|
+
* undeclared child-context service reads.
|
|
263
|
+
*/
|
|
264
|
+
function contextWithLazySettingsCompatibility(ctx, entryConfig, namespace) {
|
|
265
|
+
const wrappers = new WeakMap()
|
|
266
|
+
const facades = new WeakMap()
|
|
267
|
+
let injectedEditor
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
if (typeof ctx?.inject === 'function') {
|
|
271
|
+
ctx.inject(['configEditor'], (editorCtx) => {
|
|
272
|
+
injectedEditor = serviceOf(editorCtx, 'configEditor')
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
// Older Hosts may not expose ConfigEditor; their native SettingsProvider stays authoritative.
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function compatibleSettings(target) {
|
|
280
|
+
const settings = serviceOf(target, 'settings') ?? serviceOf(ctx, 'settings')
|
|
281
|
+
if (!settings || typeof settings.register === 'function') return settings
|
|
282
|
+
const editor = serviceOf(target, 'configEditor') ?? injectedEditor ?? serviceOf(ctx, 'configEditor')
|
|
283
|
+
if (typeof settings.describe !== 'function'
|
|
284
|
+
|| !editor || typeof editor.edit !== 'function' || typeof editor.configuration !== 'function') {
|
|
285
|
+
return settings
|
|
286
|
+
}
|
|
287
|
+
let facade = facades.get(editor)
|
|
288
|
+
if (!facade) {
|
|
289
|
+
facade = createConfigEditorSettingsFacade(editor, entryConfig, namespace)
|
|
290
|
+
facades.set(editor, facade)
|
|
291
|
+
}
|
|
292
|
+
return facade
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function wrap(target) {
|
|
296
|
+
if (!target || typeof target !== 'object') return target
|
|
297
|
+
const held = wrappers.get(target)
|
|
298
|
+
if (held) return held
|
|
299
|
+
const wrapped = new Proxy(target, {
|
|
300
|
+
get(object, property) {
|
|
301
|
+
if (property === 'settings') return compatibleSettings(object)
|
|
302
|
+
if (property === 'get') {
|
|
303
|
+
const get = Reflect.get(object, property, object)
|
|
304
|
+
if (typeof get !== 'function') return get
|
|
305
|
+
return (name, ...rest) => name === 'settings'
|
|
306
|
+
? compatibleSettings(object)
|
|
307
|
+
: get.call(object, name, ...rest)
|
|
308
|
+
}
|
|
309
|
+
if (property === 'inject') {
|
|
310
|
+
const inject = Reflect.get(object, property, object)
|
|
311
|
+
if (typeof inject !== 'function') return inject
|
|
312
|
+
return (dependencies, callback, ...rest) => inject.call(
|
|
313
|
+
object,
|
|
314
|
+
dependencies,
|
|
315
|
+
typeof callback === 'function' && Array.isArray(dependencies) && dependencies.includes('settings')
|
|
316
|
+
? (child) => callback(wrap(child))
|
|
317
|
+
: callback,
|
|
318
|
+
...rest,
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
const value = Reflect.get(object, property, object)
|
|
322
|
+
return typeof value === 'function' ? value.bind(object) : value
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
wrappers.set(target, wrapped)
|
|
326
|
+
return wrapped
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return wrap(ctx)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* DSH 0.1.7 removes the legacy SettingsProvider.register() namespace API and
|
|
334
|
+
* makes ordinary plugin configuration profile-owned through ConfigEditor.
|
|
335
|
+
* Preserve DVR's mature settings consumers behind the old semantic face while
|
|
336
|
+
* leaving older Hosts on their native SettingsProvider whenever it is present.
|
|
337
|
+
*/
|
|
338
|
+
export function installDsh017SettingsCompatibility(ctx, entryConfig = {}, options = {}) {
|
|
339
|
+
const namespace = typeof options.namespace === 'string' && options.namespace !== '' ? options.namespace : SETTINGS_NS
|
|
340
|
+
return contextWithLazySettingsCompatibility(ctx, entryConfig, namespace)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function sendJson(res, status, body) {
|
|
344
|
+
res.writeHead(status, {
|
|
345
|
+
'content-type': 'application/json; charset=utf-8',
|
|
346
|
+
'cache-control': 'no-store',
|
|
347
|
+
})
|
|
348
|
+
res.end(JSON.stringify(body))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function readJson(req) {
|
|
352
|
+
let size = 0
|
|
353
|
+
const chunks = []
|
|
354
|
+
for await (const chunk of req) {
|
|
355
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
356
|
+
size += bytes.length
|
|
357
|
+
if (size > BODY_LIMIT_BYTES) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
|
|
358
|
+
chunks.push(bytes)
|
|
359
|
+
}
|
|
360
|
+
if (chunks.length === 0) return {}
|
|
361
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function namespaceDescriptor(settings) {
|
|
365
|
+
const descriptors = settings.describe({ redactSecrets: true })
|
|
366
|
+
return Array.isArray(descriptors) ? descriptors.find((entry) => entry?.ns === SETTINGS_NS) : undefined
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Local-only transport for the 0.1.7 browser, which no longer exposes settingsScope. */
|
|
370
|
+
export function installDsh017LocalSettingsTransport(ctx) {
|
|
371
|
+
if (!ctx || typeof ctx.inject !== 'function') return
|
|
372
|
+
ctx.inject(['settings', 'webServer'], (webCtx) => {
|
|
373
|
+
if (webCtx.settings?.[DSH_017_SETTINGS_COMPAT_MARK] !== true) return
|
|
374
|
+
if (typeof webCtx.settings.describe !== 'function' || typeof webCtx.settings.mutate !== 'function') return
|
|
375
|
+
webCtx.effect(
|
|
376
|
+
() => webCtx.webServer.register({
|
|
377
|
+
kind: 'exact',
|
|
378
|
+
path: LOCAL_SETTINGS_PATH,
|
|
379
|
+
async handler(req, res) {
|
|
380
|
+
if (req.method === 'GET') {
|
|
381
|
+
const descriptor = namespaceDescriptor(webCtx.settings)
|
|
382
|
+
if (!descriptor) {
|
|
383
|
+
sendJson(res, 404, { ok: false, error: { code: 'settings-unavailable', message: 'Vision Router settings are unavailable' } })
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
sendJson(res, 200, { ok: true, value: { ...descriptor, writable: webCtx.settings.writable === true } })
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (req.method !== 'POST') {
|
|
390
|
+
res.setHeader('Allow', 'GET, POST')
|
|
391
|
+
sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const payload = await readJson(req)
|
|
396
|
+
await webCtx.settings.mutate(SETTINGS_NS, payload?.ops, payload?.expectedRevision)
|
|
397
|
+
const descriptor = namespaceDescriptor(webCtx.settings)
|
|
398
|
+
if (!descriptor) throw new Error('Vision Router settings disappeared after the write')
|
|
399
|
+
sendJson(res, 200, { ok: true, value: { ...descriptor, writable: webCtx.settings.writable === true } })
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const conflict = error?.code === 'SETTINGS_CONFLICT'
|
|
402
|
+
sendJson(res, error?.statusCode ?? (conflict ? 409 : 400), {
|
|
403
|
+
ok: false,
|
|
404
|
+
error: {
|
|
405
|
+
code: conflict ? 'settings-conflict' : 'settings-rejected',
|
|
406
|
+
message: error?.message ?? String(error),
|
|
407
|
+
...(conflict ? { details: { expected: error.expected, actual: error.actual } } : {}),
|
|
408
|
+
},
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
}),
|
|
413
|
+
'vision-router: DSH 0.1.7 local settings transport',
|
|
414
|
+
)
|
|
415
|
+
})
|
|
416
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const DSH_SUPPORT_WINDOW = Object.freeze({
|
|
2
|
-
dvrTrain: '2.
|
|
2
|
+
dvrTrain: '2.2.x',
|
|
3
3
|
minimum: '0.1.0-rc.8',
|
|
4
|
-
currentStable: '0.1.5-rc.
|
|
4
|
+
currentStable: '0.1.5-rc.3',
|
|
5
5
|
})
|
|
6
6
|
|
|
7
7
|
export const DSH_VERIFICATION_EVIDENCE = Object.freeze({
|
|
8
|
-
exactStable: '0.1.5-rc.
|
|
9
|
-
exactPreview: '0.1.
|
|
8
|
+
exactStable: '0.1.5-rc.3',
|
|
9
|
+
exactPreview: '0.1.7-rc.2',
|
|
10
10
|
stableCanaryDistTag: 'latest',
|
|
11
11
|
previewCanaryDistTag: 'alpha',
|
|
12
12
|
})
|
package/lib/public-entry.js
CHANGED
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
installVisionProviderTransport,
|
|
8
8
|
} from './vision-provider-transport.js'
|
|
9
9
|
import { installLegacyGlobalProxyBoundary } from './legacy-global-proxy-boundary.js'
|
|
10
|
+
import { installDsh017SettingsCompatibility } from './dsh-settings-017-compat.js'
|
|
11
|
+
import { installDsh017RootLocalSettingsTransport } from './web/dsh-settings-017-root-transport.js'
|
|
12
|
+
import { installSettings017ClientCompatibility } from './settings-client-017-compat.js'
|
|
13
|
+
import { installVisionRouterMessageSourceBoundary } from './session-message-source-compat.js'
|
|
10
14
|
|
|
11
15
|
export * from '../entry.js'
|
|
12
16
|
|
|
@@ -33,7 +37,23 @@ function liveVisionConfig(ctx, fallback) {
|
|
|
33
37
|
* settings therefore leave DSH/Host as the sole network authority.
|
|
34
38
|
*/
|
|
35
39
|
export function apply(ctx, config = {}) {
|
|
36
|
-
|
|
40
|
+
// DSH 0.1.7 replaces SettingsProvider.register() with profile-owned
|
|
41
|
+
// ConfigEditor writes for ordinary fields. Feature-detect that exact contract
|
|
42
|
+
// and present DVR's mature settings face without changing older Hosts.
|
|
43
|
+
const settingsCtx = installDsh017SettingsCompatibility(ctx, config, {
|
|
44
|
+
namespace: 'vision-router',
|
|
45
|
+
Config: base.Config,
|
|
46
|
+
})
|
|
47
|
+
// The 0.1.7 browser no longer exposes settingsScope. Keep the server route on
|
|
48
|
+
// the application root so ConfigEditor reconciliation cannot dispose an
|
|
49
|
+
// in-flight Settings POST, while each DVR generation supplies the live facade.
|
|
50
|
+
installDsh017RootLocalSettingsTransport(settingsCtx)
|
|
51
|
+
installSettings017ClientCompatibility(settingsCtx)
|
|
52
|
+
// Session V3 and V4 disagree on plugin message attribution. Normalize only
|
|
53
|
+
// DVR-authored contexts at the final hook-publication seam using the Session's
|
|
54
|
+
// own durable header version, never a DSH package-version guess.
|
|
55
|
+
const sessionSourceCtx = installVisionRouterMessageSourceBoundary(settingsCtx)
|
|
56
|
+
const hardening = createVisionToggleRootHardening(sessionSourceCtx, config)
|
|
37
57
|
const runtimeCtx = contextWithVisionRoutingTopologyRefresh(hardening.ctx)
|
|
38
58
|
const transport = createVisionProviderTransport({
|
|
39
59
|
ctx: hardening.ctx,
|
|
@@ -183,6 +183,10 @@ function translateLegacyRuntimeText(value, i18n) {
|
|
|
183
183
|
'In structured mode, vision_ocr with an omitted engine or engine=auto uses vision-model OCR (engine=vision) directly instead of accepting the first non-empty local Tesseract result; explicit engine=tesseract or engine=vision is always preserved.',
|
|
184
184
|
'For vision_ocr, engine=auto always tries local Tesseract first and falls back to the vision model only when local OCR fails or returns no text; structured mode does not change this order. Use explicit engine=vision to force vision-model OCR.',
|
|
185
185
|
)
|
|
186
|
+
.replace(
|
|
187
|
+
'不要默认把 OCR 当第二步;仅在需要逐字保真时用 vision_ocr,并把结果当作需要结合上下文验证的证据。UI/截图语义通常用 vision_describe 或 vision_detect,精确定位用 vision_ground。vision_ocr 未显式指定 engine 时遵循设置中的 OCR 默认引擎;单次显式 engine=tesseract/vision 始终优先。完成至少 1 次后续证据调用后,证据充分就直接作答,不要为了流程继续调用。',
|
|
188
|
+
'Do not default to OCR as the second step. Use vision_ocr only for verbatim evidence. If it returns uncertain:true, verify the ambiguous text against context when possible; if uncertain:false and the text is sufficient, answer without redundant verification. For UI/screenshot semantics use vision_describe or vision_detect; use vision_ground for precise localization. When vision_ocr has no explicit engine, it follows the configured Default OCR engine; per-call engine=tesseract/vision always wins. After at least 1 follow-up evidence call, answer once the evidence is sufficient instead of calling more tools just for the workflow.',
|
|
189
|
+
)
|
|
186
190
|
.replace(
|
|
187
191
|
'不要默认把 OCR 当第二步;仅在需要逐字保真时用 vision_ocr,并把结果当作需要结合上下文验证的证据。UI/截图语义通常用 vision_describe 或 vision_detect,精确定位用 vision_ground。vision_ocr 的 engine=auto 始终先尝试本地 Tesseract,失败或空结果时再回退视觉模型;结构化模式不会改变这一顺序。完成至少 1 次后续证据调用后,证据充分就直接作答,不要为了流程继续调用。',
|
|
188
192
|
'Do not default to OCR as the second step. Use vision_ocr only for verbatim evidence. If it returns uncertain:true, verify the ambiguous text against context when possible; if uncertain:false and the text is sufficient, answer without redundant verification. For UI/screenshot semantics use vision_describe or vision_detect; use vision_ground for precise localization. For vision_ocr, engine=auto always tries local Tesseract first and falls back to the vision model only when local OCR fails or returns no text; structured mode does not change this order. After at least 1 follow-up evidence call, answer once the evidence is sufficient instead of calling more tools just for the workflow.',
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const LEGACY_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'dsh-vision-router' })
|
|
2
|
+
const PRODUCER_SOURCE = Object.freeze({ kind: 'plugin:dsh-vision-router' })
|
|
3
|
+
|
|
4
|
+
function sourceKindForSession(session) {
|
|
5
|
+
const version = Number(session?.header?.version)
|
|
6
|
+
return Number.isInteger(version) && version >= 4 ? PRODUCER_SOURCE : LEGACY_SOURCE
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isVisionRouterSource(source) {
|
|
10
|
+
return !!source && typeof source === 'object' && !Array.isArray(source) && (
|
|
11
|
+
(source.kind === 'plugin' && source.plugin === 'dsh-vision-router') ||
|
|
12
|
+
source.kind === 'plugin:dsh-vision-router'
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Return the canonical DVR message source for one live Session format. */
|
|
17
|
+
export function visionRouterMessageSource(session) {
|
|
18
|
+
return { ...sourceKindForSession(session) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Normalize one DVR-authored message without touching user/model/tool sources. */
|
|
22
|
+
export function normalizeVisionRouterMessageSource(message, session) {
|
|
23
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) return message
|
|
24
|
+
if (!isVisionRouterSource(message.source)) return message
|
|
25
|
+
const source = sourceKindForSession(session)
|
|
26
|
+
if (message.source.kind === source.kind
|
|
27
|
+
&& (source.kind !== 'plugin' || message.source.plugin === source.plugin)
|
|
28
|
+
&& Object.keys(message.source).length === Object.keys(source).length) {
|
|
29
|
+
return message
|
|
30
|
+
}
|
|
31
|
+
return { ...message, source: { ...source } }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeMessageList(list, session) {
|
|
35
|
+
if (!Array.isArray(list)) return list
|
|
36
|
+
let changed = false
|
|
37
|
+
const next = list.map((message) => {
|
|
38
|
+
const normalized = normalizeVisionRouterMessageSource(message, session)
|
|
39
|
+
if (normalized !== message) changed = true
|
|
40
|
+
return normalized
|
|
41
|
+
})
|
|
42
|
+
return changed ? next : list
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizePreStepResult(result, payload) {
|
|
46
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return result
|
|
47
|
+
const messages = normalizeMessageList(result.messages, payload?.agent?.session)
|
|
48
|
+
return messages === result.messages ? result : { ...result, messages }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizePostExecuteResult(result, exec) {
|
|
52
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return result
|
|
53
|
+
const additionalContexts = normalizeMessageList(result.additionalContexts, exec?.agent?.session)
|
|
54
|
+
return additionalContexts === result.additionalContexts ? result : { ...result, additionalContexts }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeHookResult(event, args, result) {
|
|
58
|
+
if (event === 'agent/pre-step') return normalizePreStepResult(result, args[0])
|
|
59
|
+
if (event === 'tools/post-execute') return normalizePostExecuteResult(result, args[0])
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* DSH Session V3 requires the historical {kind:'plugin', plugin:...} source,
|
|
65
|
+
* while V4 rejects that wrapper in favor of producer-owned source kinds. Keep
|
|
66
|
+
* every existing DVR message producer unchanged and normalize at the final
|
|
67
|
+
* hook publication boundary using the Session's own durable header version.
|
|
68
|
+
*/
|
|
69
|
+
export function installVisionRouterMessageSourceBoundary(ctx) {
|
|
70
|
+
if (!ctx || typeof ctx !== 'object') return ctx
|
|
71
|
+
return new Proxy(ctx, {
|
|
72
|
+
get(target, property) {
|
|
73
|
+
if (property !== 'on') {
|
|
74
|
+
const value = Reflect.get(target, property, target)
|
|
75
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
76
|
+
}
|
|
77
|
+
const on = Reflect.get(target, property, target)
|
|
78
|
+
if (typeof on !== 'function') return on
|
|
79
|
+
return (event, handler, ...rest) => {
|
|
80
|
+
if ((event !== 'agent/pre-step' && event !== 'tools/post-execute') || typeof handler !== 'function') {
|
|
81
|
+
return on.call(target, event, handler, ...rest)
|
|
82
|
+
}
|
|
83
|
+
return on.call(target, event, function sourceCompatibleHook(...args) {
|
|
84
|
+
const output = handler.apply(this, args)
|
|
85
|
+
return output && typeof output.then === 'function'
|
|
86
|
+
? output.then((value) => normalizeHookResult(event, args, value))
|
|
87
|
+
: normalizeHookResult(event, args, output)
|
|
88
|
+
}, ...rest)
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|
|
@@ -6,7 +6,8 @@ function nonNegativeSafeInteger(value) {
|
|
|
6
6
|
* Build the Host-owned Session surface replacement intent for one exact node.
|
|
7
7
|
*
|
|
8
8
|
* DSH Session format v3 renamed replacement endpoints from start/end to
|
|
9
|
-
* startSeq/endSeq
|
|
9
|
+
* startSeq/endSeq, and reviewed format v4 keeps that replacement shape. The
|
|
10
|
+
* field change belongs to the Session format contract,
|
|
10
11
|
* not to a DSH package-version heuristic, so dispatch on session.header.version
|
|
11
12
|
* and refuse unknown future formats instead of guessing forward compatibility.
|
|
12
13
|
*
|
|
@@ -29,7 +30,7 @@ export function sessionSurfaceReplacementIntent(session, seq) {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
if (version === 3) {
|
|
33
|
+
if (version === 3 || version === 4) {
|
|
33
34
|
return {
|
|
34
35
|
surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq },
|
|
35
36
|
sourceEventSeqs: [seq],
|
|
@@ -91,6 +91,7 @@ export function createSessionVisionIndex({
|
|
|
91
91
|
const repairReadWarnings = new WeakMap()
|
|
92
92
|
const repairFeedOverflowWarnings = new WeakSet()
|
|
93
93
|
const repairFeedObserverWarnings = new WeakMap()
|
|
94
|
+
const surfaceFeedBackfills = new WeakMap()
|
|
94
95
|
const attachmentRecoveryWarnings = new WeakMap()
|
|
95
96
|
let surfaceEventFeedActive = false
|
|
96
97
|
|
|
@@ -357,6 +358,54 @@ export function createSessionVisionIndex({
|
|
|
357
358
|
surfaceEventFeedActive = true
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
const backfillSurfaceEventFeed = async (session) => {
|
|
362
|
+
let events
|
|
363
|
+
if (typeof readSessionLog === 'function') {
|
|
364
|
+
let result
|
|
365
|
+
try {
|
|
366
|
+
result = await readSessionLog(session)
|
|
367
|
+
} catch (error) {
|
|
368
|
+
warnRepairReadFailure(session, surfaceNodes(session)?.[0] ?? 0, error)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if (result?.supported === true) {
|
|
372
|
+
if (!Array.isArray(result.events)) {
|
|
373
|
+
warnRepairReadFailure(session, surfaceNodes(session)?.[0] ?? 0, new Error('Session log reader returned no event log'))
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
events = result.events
|
|
377
|
+
} else if (result?.supported !== false) {
|
|
378
|
+
warnRepairReadFailure(session, surfaceNodes(session)?.[0] ?? 0, new Error('Session log reader returned an invalid capability result'))
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Event-feed activation can happen after a live Session already contains
|
|
384
|
+
// committed events (enable/re-enable/HMR). Backfill exactly once from one
|
|
385
|
+
// snapshot, then return to the O(new events) feed path. Older partial Hosts
|
|
386
|
+
// without SessionQuery retain the Session-local compatibility snapshot.
|
|
387
|
+
if (events === undefined) events = legacySessionEvents(session)
|
|
388
|
+
if (!Array.isArray(events)) return
|
|
389
|
+
|
|
390
|
+
const nodes = surfaceNodes(session)
|
|
391
|
+
if (!nodes || nodes.length === 0) return
|
|
392
|
+
for (const seq of nodes) {
|
|
393
|
+
const event = events[seq]
|
|
394
|
+
if (!event || typeof event !== 'object' || (event.seq !== undefined && event.seq !== seq)) continue
|
|
395
|
+
recordSessionEvent(session, event)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const ensureSurfaceEventFeedBackfill = async (session) => {
|
|
400
|
+
if (!surfaceEventFeedActive || !session) return
|
|
401
|
+
let task = surfaceFeedBackfills.get(session)
|
|
402
|
+
if (!task) {
|
|
403
|
+
task = backfillSurfaceEventFeed(session)
|
|
404
|
+
surfaceFeedBackfills.set(session, task)
|
|
405
|
+
}
|
|
406
|
+
await task
|
|
407
|
+
}
|
|
408
|
+
|
|
360
409
|
const consumePendingRepairEvents = (pendingBySession, session) => {
|
|
361
410
|
const pending = pendingBySession.get(session)
|
|
362
411
|
if (!pending || pending.size === 0) return []
|
|
@@ -532,6 +581,7 @@ export function createSessionVisionIndex({
|
|
|
532
581
|
store.recordAttachments(session, found.attachments)
|
|
533
582
|
}
|
|
534
583
|
}
|
|
584
|
+
await ensureSurfaceEventFeedBackfill(session)
|
|
535
585
|
await repairToolResultSurface(session)
|
|
536
586
|
await repairGuardStopSurface(session)
|
|
537
587
|
return decision
|