@deepwatch/dsh-contracts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/lib/approval.d.ts +114 -0
- package/lib/approval.js +108 -0
- package/lib/bindings.d.ts +248 -0
- package/lib/bindings.js +333 -0
- package/lib/digests.d.ts +77 -0
- package/lib/digests.js +103 -0
- package/lib/execution.d.ts +259 -0
- package/lib/execution.js +213 -0
- package/lib/failures.d.ts +145 -0
- package/lib/failures.js +204 -0
- package/lib/identity.d.ts +54 -0
- package/lib/identity.js +74 -0
- package/lib/index.d.ts +382 -0
- package/lib/index.js +79 -0
- package/lib/language.d.ts +158 -0
- package/lib/language.js +202 -0
- package/lib/paths.d.ts +194 -0
- package/lib/paths.js +286 -0
- package/lib/presentation.d.ts +90 -0
- package/lib/presentation.js +158 -0
- package/lib/query/validate.d.ts +74 -0
- package/lib/query/validate.js +243 -0
- package/lib/query/wire.d.ts +409 -0
- package/lib/query/wire.js +37 -0
- package/lib/query.d.ts +362 -0
- package/lib/query.js +431 -0
- package/lib/readiness.d.ts +198 -0
- package/lib/readiness.js +209 -0
- package/lib/workspace.d.ts +141 -0
- package/lib/workspace.js +158 -0
- package/package.json +60 -0
package/lib/bindings.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a person actually chose: which provider, which model, for which role.
|
|
3
|
+
*
|
|
4
|
+
* {@link module:@deepwatch/dsh-contracts/readiness} answers "can this run?"
|
|
5
|
+
* from four separate facts. This module is where three of those facts are
|
|
6
|
+
* *kept* — a durable document recording the decisions, so that reopening the
|
|
7
|
+
* product finds the same bindings rather than an empty screen and a composer
|
|
8
|
+
* pointed at somebody else's default.
|
|
9
|
+
*
|
|
10
|
+
* **A binding is a reference, never a credential.** The document below can be
|
|
11
|
+
* read by anything: it is written to the Harness's own settings file, it rides
|
|
12
|
+
* the settings RPC, it appears in Diagnostics, and it is included in a session
|
|
13
|
+
* export. None of that is safe unless the rule is absolute, so it is: the only
|
|
14
|
+
* credential-shaped field here is {@link RoleBindingRecord.credentialRef}, an
|
|
15
|
+
* opaque handle the Host resolves against its own store. No value, no prefix,
|
|
16
|
+
* no suffix, no length, no hash. {@link assertNoSecretMaterial} is the test
|
|
17
|
+
* this file is held to.
|
|
18
|
+
*
|
|
19
|
+
* **Nothing is bound implicitly.** There is no "default role", no inheritance
|
|
20
|
+
* from one role to another, and no provider that becomes bound because it was
|
|
21
|
+
* the only one configured. A role with no entry in {@link WatchBindings.roles}
|
|
22
|
+
* is unbound, and unbound means the composer refuses. That is the whole point
|
|
23
|
+
* of the module: the failure it exists to prevent was a product that treated a
|
|
24
|
+
* saved credential as a decision the person never made.
|
|
25
|
+
*
|
|
26
|
+
* @module @deepwatch/dsh-contracts/bindings
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* The settings namespace this document lives in.
|
|
30
|
+
*
|
|
31
|
+
* A DeepWatch-owned section of the Harness's own user-settings document, which
|
|
32
|
+
* is what makes the binding durable, hot-reloaded and editable by hand without
|
|
33
|
+
* DeepWatch inventing a second configuration store beside the one the product
|
|
34
|
+
* already has.
|
|
35
|
+
*/
|
|
36
|
+
export const BINDINGS_NAMESPACE = 'watch-bindings';
|
|
37
|
+
/**
|
|
38
|
+
* The document revision this build writes.
|
|
39
|
+
*
|
|
40
|
+
* Read forward, never rewritten in place: an older document is migrated on
|
|
41
|
+
* read and a newer one is refused rather than silently reinterpreted, because
|
|
42
|
+
* misreading a binding routes somebody's prompt somewhere they did not choose.
|
|
43
|
+
*/
|
|
44
|
+
export const BINDINGS_VERSION = 1;
|
|
45
|
+
/**
|
|
46
|
+
* The roles a person can bind to a provider, in the order setup presents them.
|
|
47
|
+
*
|
|
48
|
+
* These ids are `RoleId`s from `@deepwatch/dsh-technology`, spelled here rather
|
|
49
|
+
* than imported because `contracts` is the package everything else depends on
|
|
50
|
+
* and must not depend on anything. `tests/bindings-store.test.mjs` asserts the
|
|
51
|
+
* two lists agree, so the duplication is checked rather than trusted -- one
|
|
52
|
+
* role vocabulary with a gate on it, instead of two that drift.
|
|
53
|
+
*
|
|
54
|
+
* It is a *subset*. `verifier`, `ocr_layout`, `reranking` and
|
|
55
|
+
* `speaker_diarization` are served by local engines rather than chosen from a
|
|
56
|
+
* provider catalogue, so offering them here would offer a choice that is not
|
|
57
|
+
* there.
|
|
58
|
+
*
|
|
59
|
+
* `agent_model` is first and is the only one the first conversation needs. The
|
|
60
|
+
* rest are progressive: a product that demanded five bindings before the first
|
|
61
|
+
* message would be a product nobody finished configuring.
|
|
62
|
+
*/
|
|
63
|
+
export const BINDABLE_ROLES = [
|
|
64
|
+
'agent_model', 'visual_perception', 'asr', 'audio_understanding', 'embeddings',
|
|
65
|
+
];
|
|
66
|
+
/** The role the first conversation needs, named once so nothing spells it twice. */
|
|
67
|
+
export const PRIMARY_ROLE = 'agent_model';
|
|
68
|
+
/**
|
|
69
|
+
* What each role is called on screen.
|
|
70
|
+
*
|
|
71
|
+
* `agent_model` is labelled **Chat**, and the difference is not cosmetic. A
|
|
72
|
+
* person configuring this product is not choosing an "agent model" -- they are
|
|
73
|
+
* choosing what answers them in the surface the Harness calls Chat, and every
|
|
74
|
+
* blocked-composer message and setup step has to name the thing they are
|
|
75
|
+
* looking at. The id stays `agent_model` because that is the vocabulary the
|
|
76
|
+
* descriptors and the routing rules already use.
|
|
77
|
+
*/
|
|
78
|
+
export const ROLE_LABEL = {
|
|
79
|
+
agent_model: 'Chat',
|
|
80
|
+
visual_perception: 'Visual perception',
|
|
81
|
+
asr: 'Speech to text',
|
|
82
|
+
audio_understanding: 'Audio understanding',
|
|
83
|
+
embeddings: 'Embeddings and retrieval',
|
|
84
|
+
};
|
|
85
|
+
/** Whether a string is a role this product binds. */
|
|
86
|
+
export function isBindableRole(value) {
|
|
87
|
+
return BINDABLE_ROLES.includes(value);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* What each role is for, in a person's words.
|
|
91
|
+
*
|
|
92
|
+
* Here rather than in a component because the setup flow, the Role Bindings
|
|
93
|
+
* screen and the blocked-composer card all name the same role, and three
|
|
94
|
+
* copies of this sentence would eventually be three different sentences.
|
|
95
|
+
*/
|
|
96
|
+
export const ROLE_PURPOSE = {
|
|
97
|
+
agent_model: 'Plans, reasons and writes. This is what answers you in a conversation.',
|
|
98
|
+
visual_perception: 'Reads what is on screen or in a frame.',
|
|
99
|
+
asr: 'Speech to text, with timings a citation can point at.',
|
|
100
|
+
audio_understanding: 'Non-speech audio: events, tone, music.',
|
|
101
|
+
embeddings: 'Search over the library and over memory.',
|
|
102
|
+
};
|
|
103
|
+
/** The modalities each role's work actually needs a route to support. */
|
|
104
|
+
export const ROLE_MODALITIES = {
|
|
105
|
+
agent_model: ['text'],
|
|
106
|
+
visual_perception: ['vision'],
|
|
107
|
+
asr: ['audio'],
|
|
108
|
+
audio_understanding: ['audio'],
|
|
109
|
+
embeddings: ['embedding'],
|
|
110
|
+
};
|
|
111
|
+
/** The actors a stored document may name. */
|
|
112
|
+
export const BINDING_ACTORS = ['person', 'setup', 'unknown'];
|
|
113
|
+
/** Whether a stored value is an actor this build understands. */
|
|
114
|
+
export function isBindingActor(value) {
|
|
115
|
+
return typeof value === 'string' && BINDING_ACTORS.includes(value);
|
|
116
|
+
}
|
|
117
|
+
/** The document a profile that has never been configured has. */
|
|
118
|
+
export const EMPTY_BINDINGS = { version: BINDINGS_VERSION, roles: {} };
|
|
119
|
+
/**
|
|
120
|
+
* A model id worth storing.
|
|
121
|
+
*
|
|
122
|
+
* Deliberately permissive about shape — provider model ids are provider-owned
|
|
123
|
+
* and this product does not get to decide that `openai/gpt-4o` is malformed —
|
|
124
|
+
* and deliberately strict about the things that make a stored value dangerous:
|
|
125
|
+
* control characters, newlines and absurd length, all of which arrive from a
|
|
126
|
+
* hand-edited settings file rather than from the picker.
|
|
127
|
+
*/
|
|
128
|
+
export function isStorableId(value) {
|
|
129
|
+
return typeof value === 'string'
|
|
130
|
+
&& value !== ''
|
|
131
|
+
&& value.length <= 200
|
|
132
|
+
// eslint-disable-next-line no-control-regex -- rejecting these is the point.
|
|
133
|
+
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Read a stored document, keeping only what is well-formed.
|
|
137
|
+
*
|
|
138
|
+
* A hand-edited settings file is a supported way to configure this product, so
|
|
139
|
+
* a malformed entry must not take the whole document with it: the bad role is
|
|
140
|
+
* dropped and the rest survive. Dropping is the safe direction — an unbound
|
|
141
|
+
* role refuses at the composer, where a person is told what to fix, whereas a
|
|
142
|
+
* half-read binding would route a prompt somewhere nobody chose.
|
|
143
|
+
*
|
|
144
|
+
* @param raw - whatever the settings document held.
|
|
145
|
+
* @returns a document this build can act on.
|
|
146
|
+
*/
|
|
147
|
+
export function readBindings(raw) {
|
|
148
|
+
if (typeof raw !== 'object' || raw === null)
|
|
149
|
+
return EMPTY_BINDINGS;
|
|
150
|
+
const record = raw;
|
|
151
|
+
// A document from a future build is not merged, not guessed at, and not
|
|
152
|
+
// partially honoured: the shape it uses is one this build has never seen.
|
|
153
|
+
if (typeof record.version === 'number' && record.version > BINDINGS_VERSION) {
|
|
154
|
+
return EMPTY_BINDINGS;
|
|
155
|
+
}
|
|
156
|
+
if (typeof record.roles !== 'object' || record.roles === null)
|
|
157
|
+
return EMPTY_BINDINGS;
|
|
158
|
+
const roles = {};
|
|
159
|
+
for (const [role, value] of Object.entries(record.roles)) {
|
|
160
|
+
if (!isBindableRole(role))
|
|
161
|
+
continue;
|
|
162
|
+
const entry = readBindingRecord(value);
|
|
163
|
+
if (entry !== null)
|
|
164
|
+
roles[role] = entry;
|
|
165
|
+
}
|
|
166
|
+
return { version: BINDINGS_VERSION, roles };
|
|
167
|
+
}
|
|
168
|
+
/** One entry, or null when it is not something this build can honour. */
|
|
169
|
+
function readBindingRecord(value) {
|
|
170
|
+
if (typeof value !== 'object' || value === null)
|
|
171
|
+
return null;
|
|
172
|
+
const entry = value;
|
|
173
|
+
if (!isStorableId(entry['provider']) || !isStorableId(entry['model']))
|
|
174
|
+
return null;
|
|
175
|
+
const ref = entry['credentialRef'];
|
|
176
|
+
// A non-string reference is read as "no reference" rather than refused: the
|
|
177
|
+
// binding's provider and model are still the person's choice, and the Host
|
|
178
|
+
// reports an unresolvable credential as a blocker they can act on.
|
|
179
|
+
const credentialRef = typeof ref === 'string' && ref !== '' ? ref : null;
|
|
180
|
+
const boundAt = typeof entry['boundAt'] === 'string' ? entry['boundAt'] : '';
|
|
181
|
+
// Anything this build does not recognise reads as `unknown`, including the
|
|
182
|
+
// absence of the field. A document from an earlier version is unattributed,
|
|
183
|
+
// which is true, rather than attributed to a person who may not have written
|
|
184
|
+
// it.
|
|
185
|
+
const boundBy = isBindingActor(entry['boundBy']) ? entry['boundBy'] : 'unknown';
|
|
186
|
+
return {
|
|
187
|
+
provider: entry['provider'], model: entry['model'], credentialRef, boundAt, boundBy,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The document with one role bound, as a new value.
|
|
192
|
+
*
|
|
193
|
+
* Never mutates: the caller holds a snapshot it may still be rendering from,
|
|
194
|
+
* and a document edited underneath a React tree is a stale-render bug that
|
|
195
|
+
* shows somebody the binding they had a moment ago.
|
|
196
|
+
*/
|
|
197
|
+
export function withBinding(current, role, record) {
|
|
198
|
+
return { version: BINDINGS_VERSION, roles: { ...current.roles, [role]: record } };
|
|
199
|
+
}
|
|
200
|
+
/** The document with one role unbound. */
|
|
201
|
+
export function withoutBinding(current, role) {
|
|
202
|
+
const roles = Object.fromEntries(Object.entries(current.roles).filter(([key]) => key !== role));
|
|
203
|
+
return { version: BINDINGS_VERSION, roles };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The readiness-shaped view of one stored role, or null when it is unbound.
|
|
207
|
+
*
|
|
208
|
+
* The join between this module and `readiness`: storage keeps records, the
|
|
209
|
+
* gate takes {@link RoleBinding}s, and this is the only place that converts
|
|
210
|
+
* one into the other — so the modalities a role is checked against always come
|
|
211
|
+
* from {@link ROLE_MODALITIES} rather than from whatever a call site guessed.
|
|
212
|
+
*/
|
|
213
|
+
export function bindingFor(bindings, role) {
|
|
214
|
+
const record = bindings.roles[role];
|
|
215
|
+
if (record === undefined)
|
|
216
|
+
return null;
|
|
217
|
+
return {
|
|
218
|
+
role,
|
|
219
|
+
provider: record.provider,
|
|
220
|
+
model: record.model,
|
|
221
|
+
credentialRef: record.credentialRef,
|
|
222
|
+
modalities: ROLE_MODALITIES[role],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Whether a role has a stored decision at all. Not whether it can run. */
|
|
226
|
+
export function isBound(bindings, role) {
|
|
227
|
+
return bindings.roles[role] !== undefined;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Every provider a stored binding names, once each.
|
|
231
|
+
*
|
|
232
|
+
* What Settings uses to decide which providers to show credential state for:
|
|
233
|
+
* the ones a person actually pointed something at, rather than all
|
|
234
|
+
* thirty-seven routes the catalogue carries.
|
|
235
|
+
*/
|
|
236
|
+
export function boundProviders(bindings) {
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
for (const record of Object.values(bindings.roles))
|
|
239
|
+
seen.add(record.provider);
|
|
240
|
+
return [...seen].sort();
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Whether a provider/model pair is one this profile actually bound.
|
|
244
|
+
*
|
|
245
|
+
* The authoritative question, and deliberately the *narrow* one. It does not
|
|
246
|
+
* ask whether the route exists, whether a credential is stored, or whether the
|
|
247
|
+
* provider is reachable — those are the Host's to answer at the moment of the
|
|
248
|
+
* request. It asks the only thing a stored document can answer: did somebody
|
|
249
|
+
* choose this pair for something.
|
|
250
|
+
*
|
|
251
|
+
* That is what makes it usable as a gate at a routing boundary. A request for
|
|
252
|
+
* a pair nobody bound is a request nobody authorised, whatever the client that
|
|
253
|
+
* produced it believed — a stale tab holding a selection that has since been
|
|
254
|
+
* changed, or a caller that set one directly and skipped the screens.
|
|
255
|
+
*
|
|
256
|
+
* Any bound role counts, not only the one being served: a person who bound
|
|
257
|
+
* OpenRouter to Chat has authorised that route, and the title and compaction
|
|
258
|
+
* calls that ride the same selection are the same authorisation, not new ones.
|
|
259
|
+
*
|
|
260
|
+
* @param bindings - the stored document.
|
|
261
|
+
* @param provider - the route the request names.
|
|
262
|
+
* @param model - the model the request names.
|
|
263
|
+
* @returns whether some role in this profile is bound to exactly that pair.
|
|
264
|
+
*/
|
|
265
|
+
export function isRoutePermitted(bindings, provider, model) {
|
|
266
|
+
if (provider === '' || model === '')
|
|
267
|
+
return false;
|
|
268
|
+
return Object.values(bindings.roles).some(record => record.provider === provider && record.model === model);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Every distinct provider/model pair this profile bound, for a diagnostic.
|
|
272
|
+
*
|
|
273
|
+
* A refusal that says "this route is not bound" is not much use without the
|
|
274
|
+
* ability to say what *is*, and that list is a set of choices rather than
|
|
275
|
+
* anything sensitive — no credential, no reference, no host path.
|
|
276
|
+
*/
|
|
277
|
+
export function permittedRoutes(bindings) {
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
for (const record of Object.values(bindings.roles)) {
|
|
280
|
+
seen.add(`${record.provider}/${record.model}`);
|
|
281
|
+
}
|
|
282
|
+
return [...seen].sort();
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Patterns that must never appear in a stored binding.
|
|
286
|
+
*
|
|
287
|
+
* Not an exhaustive secret detector — there is no such thing — but a guard on
|
|
288
|
+
* the shapes a credential takes when somebody pastes one into a field meant to
|
|
289
|
+
* hold a reference.
|
|
290
|
+
*
|
|
291
|
+
* `OPENROUTER_API_KEY` is deliberately *not* one of them, and that distinction
|
|
292
|
+
* is the design rather than an omission: it is a reference — the name of a
|
|
293
|
+
* place the Host looks — and naming a place is exactly what `credentialRef` is
|
|
294
|
+
* for. A guard that refused it would refuse every legitimate binding, be
|
|
295
|
+
* switched off within a day, and stop catching what it was written for. What
|
|
296
|
+
* is refused is a *value*: the shapes a key has when the key itself is pasted.
|
|
297
|
+
*/
|
|
298
|
+
const SECRET_SHAPES = [
|
|
299
|
+
/\bsk-[A-Za-z0-9_-]{8,}/,
|
|
300
|
+
/\bsk_live_[A-Za-z0-9]{8,}/,
|
|
301
|
+
/\bBearer\s+[A-Za-z0-9._-]{8,}/i,
|
|
302
|
+
// A long run of mixed-case token characters. A reference is a name a person
|
|
303
|
+
// could read aloud; anything this long that is not one is a value somebody
|
|
304
|
+
// pasted where a name belongs.
|
|
305
|
+
/(?=[A-Za-z0-9+/_-]{32,})(?=[^\n]*[a-z])(?=[^\n]*[A-Z])(?=[^\n]*\d)[A-Za-z0-9+/_-]{32,}/,
|
|
306
|
+
];
|
|
307
|
+
/**
|
|
308
|
+
* Throw when a document about to be stored or shown carries secret material.
|
|
309
|
+
*
|
|
310
|
+
* A programming error rather than a runtime condition: every write path builds
|
|
311
|
+
* this document from a picker, so a value matching one of these means a code
|
|
312
|
+
* path has started copying a credential into a place that is read back in
|
|
313
|
+
* plain text. Failing loudly at the write is the only point where that is
|
|
314
|
+
* still cheap to fix.
|
|
315
|
+
*
|
|
316
|
+
* @param where - the surface being guarded, for a message that can be acted on.
|
|
317
|
+
* @param bindings - the document about to leave a trusted boundary.
|
|
318
|
+
*/
|
|
319
|
+
export function assertNoSecretMaterial(where, bindings) {
|
|
320
|
+
for (const [role, record] of Object.entries(bindings.roles)) {
|
|
321
|
+
for (const [field, value] of Object.entries(record)) {
|
|
322
|
+
if (typeof value !== 'string')
|
|
323
|
+
continue;
|
|
324
|
+
for (const shape of SECRET_SHAPES) {
|
|
325
|
+
if (!shape.test(value))
|
|
326
|
+
continue;
|
|
327
|
+
throw new Error(`${where}: the ${role} binding's ${field} looks like credential material. `
|
|
328
|
+
+ 'A binding stores a reference the Host resolves, never a value.');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
//# sourceMappingURL=bindings.js.map
|
package/lib/digests.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Watch Core contract digests this build was written against.
|
|
3
|
+
*
|
|
4
|
+
* ADR-004 makes the Pydantic models in `watch-skill` the semantic source of
|
|
5
|
+
* truth for the wire, and the TypeScript types in this package a face over
|
|
6
|
+
* them. The risk in that arrangement is drift: the engine adds a field, the
|
|
7
|
+
* Workspace keeps compiling, and the mismatch first shows up as a value that
|
|
8
|
+
* is quietly `undefined` in production.
|
|
9
|
+
*
|
|
10
|
+
* These digests close that. Watch Core reports the same values in its
|
|
11
|
+
* handshake, computed from the models themselves, and the Bridge compares them
|
|
12
|
+
* on connect. A family that disagrees disables the Watch features that depend
|
|
13
|
+
* on it and names both sides, instead of failing later somewhere unrelated.
|
|
14
|
+
*
|
|
15
|
+
* Regenerate with `python scripts/gen_bridge_schemas.py` in `watch-skill`, then
|
|
16
|
+
* copy the `families` block from `schemas/bridge/manifest.json`.
|
|
17
|
+
*
|
|
18
|
+
* @module @deepwatch/dsh-contracts/digests
|
|
19
|
+
*/
|
|
20
|
+
/** One contract family: a group of fields consumers break on together. */
|
|
21
|
+
export type SchemaFamily = 'handshake' | 'evidence' | 'verification' | 'answer' | 'library' | 'error';
|
|
22
|
+
/**
|
|
23
|
+
* Digests generated from Watch Core's Bridge wire models.
|
|
24
|
+
*
|
|
25
|
+
* Source: `schemas/bridge/manifest.json`, written by
|
|
26
|
+
* `python scripts/gen_bridge_schemas.py` from
|
|
27
|
+
* `watch_skill.surfaces.bridge.wire`. Core computes the same values at import
|
|
28
|
+
* and reports them in the handshake, so these two are one artifact seen from
|
|
29
|
+
* two sides rather than two lists that have to be kept in step by hand.
|
|
30
|
+
*
|
|
31
|
+
* A digest changes when a family's schema changes in a way that matters:
|
|
32
|
+
* hashing is over the canonical, key-sorted document, so a reordered field
|
|
33
|
+
* does not read as a breaking change and a renamed one does.
|
|
34
|
+
*/
|
|
35
|
+
export declare const EXPECTED_SCHEMA_DIGESTS: Readonly<Record<SchemaFamily, string>>;
|
|
36
|
+
/**
|
|
37
|
+
* Which Watch capability each contract family is load-bearing for.
|
|
38
|
+
*
|
|
39
|
+
* Used to disable *only* the affected features on a mismatch. A changed
|
|
40
|
+
* `library` schema should not take verification offline, and vice versa.
|
|
41
|
+
*/
|
|
42
|
+
export declare const FAMILY_CAPABILITIES: Readonly<Record<SchemaFamily, readonly string[]>>;
|
|
43
|
+
/** One family whose digest did not match. */
|
|
44
|
+
export interface SchemaDrift {
|
|
45
|
+
readonly family: SchemaFamily;
|
|
46
|
+
/** What this build was written against. */
|
|
47
|
+
readonly expected: string;
|
|
48
|
+
/** What Watch Core reported, or null when it reported nothing for it. */
|
|
49
|
+
readonly actual: string | null;
|
|
50
|
+
/** Capabilities that must be treated as unavailable because of it. */
|
|
51
|
+
readonly affects: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Compare the handshake's digests against this build's.
|
|
55
|
+
*
|
|
56
|
+
* A family Watch Core does not report at all counts as drift with a null
|
|
57
|
+
* `actual`: an engine that has stopped publishing a digest is exactly as
|
|
58
|
+
* unverifiable as one publishing a different value, and treating silence as
|
|
59
|
+
* agreement is how this check would come to mean nothing.
|
|
60
|
+
*
|
|
61
|
+
* @param reported - the `schemaDigests` map from the handshake.
|
|
62
|
+
* @returns every family that disagrees, empty when the contract matches.
|
|
63
|
+
*/
|
|
64
|
+
export declare function detectSchemaDrift(reported: Readonly<Record<string, string>>): readonly SchemaDrift[];
|
|
65
|
+
/**
|
|
66
|
+
* Whether an engine reporting no digests at all should be treated as drift.
|
|
67
|
+
*
|
|
68
|
+
* It should not. A Watch Core older than the schema manifest reports an empty
|
|
69
|
+
* map, and refusing to talk to it would break a working setup to enforce a
|
|
70
|
+
* check it predates. The Bridge surfaces that as "unverified contract" — a
|
|
71
|
+
* degraded, visible state — rather than as a mismatch it cannot substantiate.
|
|
72
|
+
*
|
|
73
|
+
* @param reported - the `schemaDigests` map from the handshake.
|
|
74
|
+
* @returns true when the engine published nothing to compare.
|
|
75
|
+
*/
|
|
76
|
+
export declare function isContractUnverified(reported: Readonly<Record<string, string>> | null | undefined): boolean;
|
|
77
|
+
//# sourceMappingURL=digests.d.ts.map
|
package/lib/digests.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Watch Core contract digests this build was written against.
|
|
3
|
+
*
|
|
4
|
+
* ADR-004 makes the Pydantic models in `watch-skill` the semantic source of
|
|
5
|
+
* truth for the wire, and the TypeScript types in this package a face over
|
|
6
|
+
* them. The risk in that arrangement is drift: the engine adds a field, the
|
|
7
|
+
* Workspace keeps compiling, and the mismatch first shows up as a value that
|
|
8
|
+
* is quietly `undefined` in production.
|
|
9
|
+
*
|
|
10
|
+
* These digests close that. Watch Core reports the same values in its
|
|
11
|
+
* handshake, computed from the models themselves, and the Bridge compares them
|
|
12
|
+
* on connect. A family that disagrees disables the Watch features that depend
|
|
13
|
+
* on it and names both sides, instead of failing later somewhere unrelated.
|
|
14
|
+
*
|
|
15
|
+
* Regenerate with `python scripts/gen_bridge_schemas.py` in `watch-skill`, then
|
|
16
|
+
* copy the `families` block from `schemas/bridge/manifest.json`.
|
|
17
|
+
*
|
|
18
|
+
* @module @deepwatch/dsh-contracts/digests
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Digests generated from Watch Core's Bridge wire models.
|
|
22
|
+
*
|
|
23
|
+
* Source: `schemas/bridge/manifest.json`, written by
|
|
24
|
+
* `python scripts/gen_bridge_schemas.py` from
|
|
25
|
+
* `watch_skill.surfaces.bridge.wire`. Core computes the same values at import
|
|
26
|
+
* and reports them in the handshake, so these two are one artifact seen from
|
|
27
|
+
* two sides rather than two lists that have to be kept in step by hand.
|
|
28
|
+
*
|
|
29
|
+
* A digest changes when a family's schema changes in a way that matters:
|
|
30
|
+
* hashing is over the canonical, key-sorted document, so a reordered field
|
|
31
|
+
* does not read as a breaking change and a renamed one does.
|
|
32
|
+
*/
|
|
33
|
+
export const EXPECTED_SCHEMA_DIGESTS = {
|
|
34
|
+
answer: 'sha256:2abc33bc76abe07486446bae94c70211',
|
|
35
|
+
error: 'sha256:f83b04be5dffc2dfab6963f071c7455a',
|
|
36
|
+
evidence: 'sha256:3facf1e0c00ffb61e9724d0a17b5d589',
|
|
37
|
+
handshake: 'sha256:215466e1a4d3ea526e71c5a162bf861b',
|
|
38
|
+
library: 'sha256:cb1a1721b33ccca2e25ea000fe41c7f1',
|
|
39
|
+
verification: 'sha256:0f381733ba98849b8335ad0fa1534534',
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Which Watch capability each contract family is load-bearing for.
|
|
43
|
+
*
|
|
44
|
+
* Used to disable *only* the affected features on a mismatch. A changed
|
|
45
|
+
* `library` schema should not take verification offline, and vice versa.
|
|
46
|
+
*/
|
|
47
|
+
export const FAMILY_CAPABILITIES = {
|
|
48
|
+
handshake: ['watch.video.query', 'watch.library.search', 'watch.verification.run'],
|
|
49
|
+
evidence: ['watch.video.query', 'watch.evidence.resolve'],
|
|
50
|
+
verification: ['watch.verification.run'],
|
|
51
|
+
answer: ['watch.video.query'],
|
|
52
|
+
library: ['watch.library.search'],
|
|
53
|
+
error: [],
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Compare the handshake's digests against this build's.
|
|
57
|
+
*
|
|
58
|
+
* A family Watch Core does not report at all counts as drift with a null
|
|
59
|
+
* `actual`: an engine that has stopped publishing a digest is exactly as
|
|
60
|
+
* unverifiable as one publishing a different value, and treating silence as
|
|
61
|
+
* agreement is how this check would come to mean nothing.
|
|
62
|
+
*
|
|
63
|
+
* @param reported - the `schemaDigests` map from the handshake.
|
|
64
|
+
* @returns every family that disagrees, empty when the contract matches.
|
|
65
|
+
*/
|
|
66
|
+
export function detectSchemaDrift(reported) {
|
|
67
|
+
const drift = [];
|
|
68
|
+
for (const [family, expected] of Object.entries(EXPECTED_SCHEMA_DIGESTS)) {
|
|
69
|
+
const actual = reported[family];
|
|
70
|
+
if (actual === expected)
|
|
71
|
+
continue;
|
|
72
|
+
drift.push({
|
|
73
|
+
family: family,
|
|
74
|
+
expected,
|
|
75
|
+
actual: actual ?? null,
|
|
76
|
+
affects: FAMILY_CAPABILITIES[family],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return drift;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether an engine reporting no digests at all should be treated as drift.
|
|
83
|
+
*
|
|
84
|
+
* It should not. A Watch Core older than the schema manifest reports an empty
|
|
85
|
+
* map, and refusing to talk to it would break a working setup to enforce a
|
|
86
|
+
* check it predates. The Bridge surfaces that as "unverified contract" — a
|
|
87
|
+
* degraded, visible state — rather than as a mismatch it cannot substantiate.
|
|
88
|
+
*
|
|
89
|
+
* @param reported - the `schemaDigests` map from the handshake.
|
|
90
|
+
* @returns true when the engine published nothing to compare.
|
|
91
|
+
*/
|
|
92
|
+
export function isContractUnverified(reported) {
|
|
93
|
+
// Absent and empty are the same answer: nothing was published to compare.
|
|
94
|
+
// The type says this map is always present, and the type does not survive
|
|
95
|
+
// the wire — this value came out of `JSON.parse` of whatever the engine
|
|
96
|
+
// actually sent. An engine that omits the field used to take `Object.keys`
|
|
97
|
+
// straight into a TypeError, which escaped `connect()` as an unhandled
|
|
98
|
+
// throw and lost the degraded state this function exists to produce.
|
|
99
|
+
if (reported === null || reported === undefined || typeof reported !== 'object')
|
|
100
|
+
return true;
|
|
101
|
+
return Object.keys(reported).length === 0;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=digests.js.map
|