@deepwatch/dsh-tools 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 +106 -0
- package/lib/browser.d.ts +38 -0
- package/lib/browser.js +299 -0
- package/lib/index.d.ts +129 -0
- package/lib/index.js +710 -0
- package/lib/library-generations.d.ts +107 -0
- package/lib/library-generations.js +227 -0
- package/lib/library-search.d.ts +143 -0
- package/lib/library-search.js +407 -0
- package/lib/memory.d.ts +23 -0
- package/lib/memory.js +96 -0
- package/lib/read-plane.d.ts +237 -0
- package/lib/read-plane.js +688 -0
- package/lib/receipt-journal.d.ts +101 -0
- package/lib/receipt-journal.js +246 -0
- package/lib/sensory.d.ts +48 -0
- package/lib/sensory.js +277 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +649 -0
- package/lib/typert.remote-client.d.ts +32 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +405 -0
- package/package.json +83 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read plane, host side: what a Watch mode asks, answered.
|
|
3
|
+
*
|
|
4
|
+
* A `conversation.view` entry is handed `{ inspect, onInspectDone }` and
|
|
5
|
+
* nothing else, so Live, Memory, Library and Compare had no way to obtain
|
|
6
|
+
* their own data. This is the other end of the seam that fixes that, and it is
|
|
7
|
+
* DSH's own: a Typert Remote, dispatched through the Gateway that already
|
|
8
|
+
* owns request correlation, abort signals and structured failure. The client
|
|
9
|
+
* calls `ctx.remote.watchQuery.librarySearch(request, signal)` or
|
|
10
|
+
* `.libraryGet(request, signal)` and awaits a `RemoteResult` carrying one of
|
|
11
|
+
* the concrete outcomes in `@deepwatch/dsh-contracts/query/wire`.
|
|
12
|
+
*
|
|
13
|
+
* One method per read, rather than one `read` over a discriminated union. DSH
|
|
14
|
+
* already routes by method, so a union inside a single entry point would be a
|
|
15
|
+
* second router with its own schema to generate.
|
|
16
|
+
*
|
|
17
|
+
* It reads the same `LibraryIndex` the `watch_library_search` tool reads. One
|
|
18
|
+
* index, one set of semantics, one place where "every term must match" is
|
|
19
|
+
* decided -- two would drift inside a release and disagree about what the
|
|
20
|
+
* library contains, and the disagreement would surface as a person searching
|
|
21
|
+
* the UI and the agent searching the tool getting different answers to the
|
|
22
|
+
* same question.
|
|
23
|
+
*
|
|
24
|
+
* Four things it will not do.
|
|
25
|
+
*
|
|
26
|
+
* It performs no write. Every operation answers a question, and the request
|
|
27
|
+
* union has no member that changes anything, so a surface cannot acquire a
|
|
28
|
+
* side effect and captured or model-generated content reaching these fields
|
|
29
|
+
* cannot become an action.
|
|
30
|
+
*
|
|
31
|
+
* It reads nothing the caller names. Parameters are identifiers from a charset
|
|
32
|
+
* with no separator or colon; the roots come from configuration. A caller
|
|
33
|
+
* cannot point this at a path.
|
|
34
|
+
*
|
|
35
|
+
* It answers within the deadline it was given, or refuses. A slow host must
|
|
36
|
+
* not become a hung surface, and the timer is cleared on every exit so a
|
|
37
|
+
* completed read leaves nothing behind.
|
|
38
|
+
*
|
|
39
|
+
* And it never reports a partial answer as a whole one. A rebuilding or stale
|
|
40
|
+
* index answers `complete: false` with what it has, because a search that
|
|
41
|
+
* quietly returns less than it should is worse than one that says it is
|
|
42
|
+
* behind.
|
|
43
|
+
*
|
|
44
|
+
* @module @deepwatch/dsh-tools/read-plane
|
|
45
|
+
*/
|
|
46
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
47
|
+
var useValue = arguments.length > 2;
|
|
48
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
49
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
50
|
+
}
|
|
51
|
+
return useValue ? value : void 0;
|
|
52
|
+
};
|
|
53
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
54
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
55
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
56
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
57
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
58
|
+
var _, done = false;
|
|
59
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
60
|
+
var context = {};
|
|
61
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
62
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
63
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
64
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
65
|
+
if (kind === "accessor") {
|
|
66
|
+
if (result === void 0) continue;
|
|
67
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
68
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
69
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
70
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
71
|
+
}
|
|
72
|
+
else if (_ = accept(result)) {
|
|
73
|
+
if (kind === "field") initializers.unshift(_);
|
|
74
|
+
else descriptor[key] = _;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
78
|
+
done = true;
|
|
79
|
+
};
|
|
80
|
+
import {} from '@deepseek-ai/cordis';
|
|
81
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
82
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
83
|
+
import { WATCH_QUERY_PROTOCOL_VERSION } from '@deepwatch/dsh-contracts/query';
|
|
84
|
+
import { parseCoreHealthRequest, parseLibraryGetRequest, parseLibraryRefreshRequest, parseLibrarySearchRequest, } from '@deepwatch/dsh-contracts/query/validate';
|
|
85
|
+
/**
|
|
86
|
+
* A revision that advances whenever the answer could have changed.
|
|
87
|
+
*
|
|
88
|
+
* Two things move it, and it needs both. `size` catches a record added or
|
|
89
|
+
* removed. A rebuild that happens to produce the same count would not move
|
|
90
|
+
* that, so each distinct index instance also gets a generation -- the host
|
|
91
|
+
* builds a new `LibraryIndex` when it rebuilds, so a new object is exactly the
|
|
92
|
+
* signal.
|
|
93
|
+
*
|
|
94
|
+
* It was briefly derived from a query's `total`, which is wrong in a way worth
|
|
95
|
+
* recording: `total` is a property of the question, not of the index, so a
|
|
96
|
+
* cursor issued by a two-match search was rejected by a three-record index and
|
|
97
|
+
* paging never worked.
|
|
98
|
+
*
|
|
99
|
+
* Not a wall clock. Two hosts with unsynchronised clocks would order answers
|
|
100
|
+
* wrongly rather than merely coarsely, which is the one thing a revision must
|
|
101
|
+
* not do.
|
|
102
|
+
*/
|
|
103
|
+
const generations = new WeakMap();
|
|
104
|
+
let nextGeneration = 1;
|
|
105
|
+
function revisionOf(index) {
|
|
106
|
+
let generation = generations.get(index);
|
|
107
|
+
if (generation === undefined) {
|
|
108
|
+
generation = nextGeneration;
|
|
109
|
+
nextGeneration += 1;
|
|
110
|
+
generations.set(index, generation);
|
|
111
|
+
}
|
|
112
|
+
// Two small numbers in one, so both changes are visible and the result stays
|
|
113
|
+
// a safe integer for any corpus anyone will hold in memory.
|
|
114
|
+
return generation * 1_000_000 + index.size;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The Typert Remote a Watch surface calls.
|
|
118
|
+
*
|
|
119
|
+
* `watchQuery` is both the Cordis service key and the wire namespace, so the
|
|
120
|
+
* client reaches it as `ctx.remote.watchQuery`.
|
|
121
|
+
*/
|
|
122
|
+
let WatchQueryService = (() => {
|
|
123
|
+
let _classSuper = TypertRemoteService;
|
|
124
|
+
let _instanceExtraInitializers = [];
|
|
125
|
+
let _librarySearch_decorators;
|
|
126
|
+
let _libraryGet_decorators;
|
|
127
|
+
let _libraryRefresh_decorators;
|
|
128
|
+
let _coreHealth_decorators;
|
|
129
|
+
let _providerTest_decorators;
|
|
130
|
+
let _routeReadiness_decorators;
|
|
131
|
+
return class WatchQueryService extends _classSuper {
|
|
132
|
+
static {
|
|
133
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
134
|
+
_librarySearch_decorators = [Remote('librarySearch')];
|
|
135
|
+
_libraryGet_decorators = [Remote('libraryGet')];
|
|
136
|
+
_libraryRefresh_decorators = [Remote('libraryRefresh')];
|
|
137
|
+
_coreHealth_decorators = [Remote('coreHealth')];
|
|
138
|
+
_providerTest_decorators = [Remote('providerTest')];
|
|
139
|
+
_routeReadiness_decorators = [Remote('routeReadiness')];
|
|
140
|
+
__esDecorate(this, null, _librarySearch_decorators, { kind: "method", name: "librarySearch", static: false, private: false, access: { has: obj => "librarySearch" in obj, get: obj => obj.librarySearch }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
141
|
+
__esDecorate(this, null, _libraryGet_decorators, { kind: "method", name: "libraryGet", static: false, private: false, access: { has: obj => "libraryGet" in obj, get: obj => obj.libraryGet }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
142
|
+
__esDecorate(this, null, _libraryRefresh_decorators, { kind: "method", name: "libraryRefresh", static: false, private: false, access: { has: obj => "libraryRefresh" in obj, get: obj => obj.libraryRefresh }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
143
|
+
__esDecorate(this, null, _coreHealth_decorators, { kind: "method", name: "coreHealth", static: false, private: false, access: { has: obj => "coreHealth" in obj, get: obj => obj.coreHealth }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
144
|
+
__esDecorate(this, null, _providerTest_decorators, { kind: "method", name: "providerTest", static: false, private: false, access: { has: obj => "providerTest" in obj, get: obj => obj.providerTest }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
145
|
+
__esDecorate(this, null, _routeReadiness_decorators, { kind: "method", name: "routeReadiness", static: false, private: false, access: { has: obj => "routeReadiness" in obj, get: obj => obj.routeReadiness }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
146
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Deliberately not a `#private` field.
|
|
150
|
+
*
|
|
151
|
+
* Cordis hands a Service to callers through a Proxy, and a private field is
|
|
152
|
+
* unreachable through one: the Gateway invoked this method and got
|
|
153
|
+
* "Cannot read private member #config from an object whose class did not
|
|
154
|
+
* declare it". Every direct unit test passed, because a direct call has no
|
|
155
|
+
* proxy in front of it -- which is the whole argument for exercising this
|
|
156
|
+
* through the real Gateway.
|
|
157
|
+
*/
|
|
158
|
+
config = __runInitializers(this, _instanceExtraInitializers);
|
|
159
|
+
constructor(ctx, config) {
|
|
160
|
+
super(ctx, 'watchQuery');
|
|
161
|
+
this.config = config;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* One concrete method per read, rather than one `read` over a union.
|
|
165
|
+
*
|
|
166
|
+
* DSH already routes by method, so a discriminated union inside a single
|
|
167
|
+
* entry point would be a second router with its own schema to generate.
|
|
168
|
+
* One request type and one response type per method is what Typert emits
|
|
169
|
+
* a strict codec from most directly.
|
|
170
|
+
*
|
|
171
|
+
* `signal` is last, as Typert requires, and is not serialised.
|
|
172
|
+
*/
|
|
173
|
+
librarySearch(request, signal) {
|
|
174
|
+
// Not `async`: Typert requires a Promise return, and the search is
|
|
175
|
+
// synchronous today. Saying so here rather than marking the method
|
|
176
|
+
// async with nothing to await keeps the lint rule meaningful for when
|
|
177
|
+
// bounded execution makes this genuinely asynchronous.
|
|
178
|
+
return Promise.resolve(searchLibrary(request, this.config, signal));
|
|
179
|
+
}
|
|
180
|
+
/** One record by id. A direct lookup, not a one-result search. */
|
|
181
|
+
libraryGet(request, signal) {
|
|
182
|
+
return Promise.resolve(getLibraryRecord(request, this.config, signal));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Read the roots again, and put the result into service if it is healthy.
|
|
186
|
+
*
|
|
187
|
+
* The only method here with a side effect, and the only one that is not a
|
|
188
|
+
* question. It is a separate method for exactly that reason: a `rebuild`
|
|
189
|
+
* flag on `librarySearch` would make every search a potential re-read of the
|
|
190
|
+
* corpus, and would leave a caller no way to ask for an answer from what the
|
|
191
|
+
* host already has.
|
|
192
|
+
*
|
|
193
|
+
* Genuinely async, unlike its siblings: the rebuild yields between files so
|
|
194
|
+
* a caller that stops waiting can be observed doing so.
|
|
195
|
+
*/
|
|
196
|
+
libraryRefresh(request, signal) {
|
|
197
|
+
return refreshLibrary(request, this.config, signal);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* What Watch Core is doing right now, read from the running Bridge.
|
|
201
|
+
*
|
|
202
|
+
* The one method here that is not about the Library, and it is here because
|
|
203
|
+
* this is the only channel the browser has to the Host. Diagnostics used to
|
|
204
|
+
* render "Connected over stdio" and a version number as literals in a
|
|
205
|
+
* component, because there was nowhere to read them from.
|
|
206
|
+
*
|
|
207
|
+
* Nothing is defaulted. A value the Bridge has not established is `null`,
|
|
208
|
+
* and the panel renders that as "not reported" -- which is worth less than a
|
|
209
|
+
* real reading and far more than a confident wrong one.
|
|
210
|
+
*/
|
|
211
|
+
coreHealth(request, signal) {
|
|
212
|
+
return Promise.resolve(readCoreHealth(request, this.ctx, signal));
|
|
213
|
+
}
|
|
214
|
+
/** Spend one deliberately tiny provider request only after a person asks. */
|
|
215
|
+
providerTest(request, signal) {
|
|
216
|
+
return testProvider(request, this.ctx, signal);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Whether the Host would serve this route right now, asked without spending
|
|
220
|
+
* anything.
|
|
221
|
+
*
|
|
222
|
+
* The browser half used to answer this from its own memory of a provider
|
|
223
|
+
* test it had run, which is a claim about a Host it cannot see. A tab that
|
|
224
|
+
* stayed open across a Host restart, or across an edit made in another tab,
|
|
225
|
+
* kept drawing a tested badge over a route the Host had already stopped
|
|
226
|
+
* being willing to serve — and the composer it gates opened onto a refusal.
|
|
227
|
+
* There is one answer, and this is where it is read from.
|
|
228
|
+
*/
|
|
229
|
+
routeReadiness(request, signal) {
|
|
230
|
+
// Read straight out of Host memory: there is nothing to wait for, so the
|
|
231
|
+
// only thing a cancellation can do is refuse an answer already in hand.
|
|
232
|
+
// Taken so the signature is the one Typert generates a codec from, and so
|
|
233
|
+
// an aborted caller is not handed a verdict it stopped asking for.
|
|
234
|
+
if (signal.aborted)
|
|
235
|
+
return Promise.reject(signal.reason);
|
|
236
|
+
return Promise.resolve(readRouteReadiness(request, this.ctx));
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
})();
|
|
240
|
+
export { WatchQueryService };
|
|
241
|
+
/**
|
|
242
|
+
* Read the Host's verdict for one route.
|
|
243
|
+
*
|
|
244
|
+
* No network, no provider, no credential. When the provenance row is not
|
|
245
|
+
* composed the honest answer is that nothing here can say, which reads as
|
|
246
|
+
* unproved — the same direction the guard fails in.
|
|
247
|
+
*/
|
|
248
|
+
export function readRouteReadiness(request, ctx) {
|
|
249
|
+
const provenance = ctx.get?.(PROVENANCE_SERVICE);
|
|
250
|
+
const reason = provenance?.readiness(request.provider, request.model) ?? 'unreadable';
|
|
251
|
+
return {
|
|
252
|
+
outcome: 'route_readiness',
|
|
253
|
+
protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
254
|
+
requestId: request.requestId,
|
|
255
|
+
provider: request.provider,
|
|
256
|
+
model: request.model,
|
|
257
|
+
proved: reason === 'proved',
|
|
258
|
+
reason,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function providerFailure(request, failure) {
|
|
262
|
+
const code = failure?.code.toUpperCase() ?? 'UNREACHABLE';
|
|
263
|
+
if (code.includes('AUTH') || failure?.status === 401 || failure?.status === 403) {
|
|
264
|
+
return {
|
|
265
|
+
outcome: 'provider_test', protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
266
|
+
requestId: request.requestId, provider: request.provider, model: request.model,
|
|
267
|
+
ok: false, credential: 'rejected', reachability: 'unauthorized',
|
|
268
|
+
message: 'The provider rejected the saved credential.',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (code.includes('RATE') || failure?.status === 429) {
|
|
272
|
+
return {
|
|
273
|
+
outcome: 'provider_test', protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
274
|
+
requestId: request.requestId, provider: request.provider, model: request.model,
|
|
275
|
+
ok: false, credential: 'configured_unverified', reachability: 'rate_limited',
|
|
276
|
+
message: 'The provider rate-limited the test. Wait, then try again.',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
outcome: 'provider_test', protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
281
|
+
requestId: request.requestId, provider: request.provider, model: request.model,
|
|
282
|
+
ok: false, credential: 'configured_unverified', reachability: 'unreachable',
|
|
283
|
+
message: 'The provider test did not complete. Check the route and network, then try again.',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/** The service key, spelled once. */
|
|
287
|
+
const PROVENANCE_SERVICE = 'watchProvenance';
|
|
288
|
+
/** Execute a provider request without returning or logging model output. */
|
|
289
|
+
export async function testProvider(request, ctx, signal) {
|
|
290
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(request.deadlineMs)]);
|
|
291
|
+
// `get?.` because a caller may hand this a minimal context — a unit test
|
|
292
|
+
// driving the provider test against a stub runtime does. No registry means no
|
|
293
|
+
// capability, and the guard refuses an unattributed request, which is the
|
|
294
|
+
// correct outcome for a Host that has not composed the row.
|
|
295
|
+
const provenance = ctx.get?.(PROVENANCE_SERVICE);
|
|
296
|
+
// A capability rather than a scope, carried on the request itself. `stream`
|
|
297
|
+
// is lazy — nothing reaches the guard until the first pull — so anything
|
|
298
|
+
// ambient established around this call has already ended by the time the
|
|
299
|
+
// request happens. A value travelling with the request cannot be stale when
|
|
300
|
+
// the request arrives.
|
|
301
|
+
const authorization = provenance?.authorizeProviderTest(request.provider, request.model, request.requestId).token;
|
|
302
|
+
try {
|
|
303
|
+
const messages = [createUserMessage({
|
|
304
|
+
content: [{ type: 'text', text: 'Reply with OK.' }],
|
|
305
|
+
source: { kind: 'user' },
|
|
306
|
+
})];
|
|
307
|
+
for await (const chunk of ctx.llm.stream({
|
|
308
|
+
provider: request.provider, model: request.model, messages,
|
|
309
|
+
maxTokens: 1, temperature: 0, signal: bounded,
|
|
310
|
+
...authorization === undefined ? {} : { watchAuthorization: authorization },
|
|
311
|
+
})) {
|
|
312
|
+
if (chunk.type !== 'finish')
|
|
313
|
+
continue;
|
|
314
|
+
if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted') {
|
|
315
|
+
return providerFailure(request, chunk.reason.failure);
|
|
316
|
+
}
|
|
317
|
+
// Minted here and nowhere else: after a real request to this exact route
|
|
318
|
+
// came back. Pinned to the provider profile and credential reference as
|
|
319
|
+
// they are now, so either of those changing leaves the proof behind
|
|
320
|
+
// rather than carrying it forward. Not to the binding document — the
|
|
321
|
+
// guard reads that live, and this is the screen a person tests *before*
|
|
322
|
+
// they bind.
|
|
323
|
+
const facts = provenance?.factsFor(request.provider, request.model) ?? null;
|
|
324
|
+
if (provenance !== undefined && facts !== null) {
|
|
325
|
+
provenance.mint({
|
|
326
|
+
provider: request.provider, model: request.model,
|
|
327
|
+
requestId: request.requestId, at: new Date().toISOString(), ...facts,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
outcome: 'provider_test', protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
332
|
+
requestId: request.requestId, provider: request.provider, model: request.model,
|
|
333
|
+
ok: true, credential: 'verified', reachability: 'reachable',
|
|
334
|
+
message: 'Provider request succeeded. This exact binding is ready.',
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// The provider's message is intentionally not returned: adapters should
|
|
340
|
+
// redact it, but the readiness channel never needs provider text at all.
|
|
341
|
+
}
|
|
342
|
+
return providerFailure(request, null);
|
|
343
|
+
}
|
|
344
|
+
/** Route a parsed request to the namespace that answers it. */
|
|
345
|
+
/** Answer a Library read from the index the tool already owns. */
|
|
346
|
+
/** Assemble a snapshot, and say honestly whether it is whole. */
|
|
347
|
+
/** Install the read plane onto a host context. */
|
|
348
|
+
export function applyReadPlane(ctx, config) {
|
|
349
|
+
new WatchQueryService(ctx, config);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Answer a Library search against the shared index.
|
|
353
|
+
*
|
|
354
|
+
* Separate from the Service so the whole path is testable without a DSH
|
|
355
|
+
* runtime, and so the Service stays a Typert adapter with no decisions in it.
|
|
356
|
+
*/
|
|
357
|
+
export function searchLibrary(request, config, signal) {
|
|
358
|
+
// Semantics before the index. The generated codec proved the shape; it has
|
|
359
|
+
// no opinion about whether the query is a length this host answers for, or
|
|
360
|
+
// whether a modality is one it indexes. Nothing expensive runs until this
|
|
361
|
+
// passes, so a malformed request costs a bounds check and never a search.
|
|
362
|
+
const accepted = parseLibrarySearchRequest(request);
|
|
363
|
+
if (!accepted.ok)
|
|
364
|
+
return accepted.refusal;
|
|
365
|
+
const checked = accepted.value;
|
|
366
|
+
if (signal.aborted) {
|
|
367
|
+
return {
|
|
368
|
+
outcome: 'deadline_exceeded',
|
|
369
|
+
protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
370
|
+
requestId: checked.requestId,
|
|
371
|
+
deadlineMs: checked.deadlineMs,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const index = config.index();
|
|
375
|
+
const found = index.search({
|
|
376
|
+
text: checked.query,
|
|
377
|
+
limit: checked.limit,
|
|
378
|
+
offset: 0,
|
|
379
|
+
});
|
|
380
|
+
return {
|
|
381
|
+
outcome: 'page',
|
|
382
|
+
protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
383
|
+
requestId: checked.requestId,
|
|
384
|
+
revision: revisionOf(index),
|
|
385
|
+
// Which index answered, said out loud. A surface that has just refreshed
|
|
386
|
+
// compares this against the generation the refresh reported and knows
|
|
387
|
+
// whether the page in front of it is the new one.
|
|
388
|
+
generation: config.generations?.generation().generation ?? 0,
|
|
389
|
+
records: found.results.map(result => toWireRecord(result, index)),
|
|
390
|
+
nextCursor: null,
|
|
391
|
+
total: found.total,
|
|
392
|
+
indexState: wireIndexState(found.health, index.size),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The wire state for one index, without collapsing four answers into two.
|
|
397
|
+
*
|
|
398
|
+
* The defect this replaces was a single conditional: anything that was not
|
|
399
|
+
* `ready` became `stale`, so an index over an empty store reported "Index is
|
|
400
|
+
* behind the store". A person looking at a fresh profile was told their
|
|
401
|
+
* Library was out of date with respect to nothing, and the only honest reading
|
|
402
|
+
* of that screen — something is wrong — was the wrong one.
|
|
403
|
+
*
|
|
404
|
+
* `empty` and `stale` are opposite claims. Empty says the index agrees with a
|
|
405
|
+
* store that holds nothing. Stale says the store moved and the index has not
|
|
406
|
+
* caught up. Reporting the first as the second turns "there is nothing here
|
|
407
|
+
* yet" into "you are missing something", which is the difference between a
|
|
408
|
+
* quiet first run and a bug report.
|
|
409
|
+
*/
|
|
410
|
+
export function wireIndexState(health, size) {
|
|
411
|
+
// Emptiness first: an index with nothing in it is caught up with a store
|
|
412
|
+
// that has nothing in it, whatever else it has been through.
|
|
413
|
+
if (size === 0)
|
|
414
|
+
return health === 'indexing' ? 'rebuilding' : 'empty';
|
|
415
|
+
if (health === 'ready')
|
|
416
|
+
return 'ready';
|
|
417
|
+
if (health === 'indexing')
|
|
418
|
+
return 'rebuilding';
|
|
419
|
+
// `stale` and `corrupt` both mean the index does not describe the store. They
|
|
420
|
+
// are different repairs, and the wire vocabulary has one word; `stale` is the
|
|
421
|
+
// one that sends somebody to Refresh, which is right for both.
|
|
422
|
+
return 'stale';
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Flatten one search result into the wire record shape.
|
|
426
|
+
*
|
|
427
|
+
* The persisted record is looked up rather than reconstructed from the hit. A
|
|
428
|
+
* `SearchResult` carries what matching produced -- title, kind, hits -- and not
|
|
429
|
+
* the provenance the surface has to show, so building the wire record from it
|
|
430
|
+
* alone returned a null observedAt, an empty source and no runId. A search
|
|
431
|
+
* result and a get result describe the same record and must not disagree about
|
|
432
|
+
* where it came from.
|
|
433
|
+
*/
|
|
434
|
+
function toWireRecord(result, index) {
|
|
435
|
+
const stored = index.record(result.sourceId);
|
|
436
|
+
const evidenceIds = [...new Set(result.hits.flatMap(hit => hit.evidenceIds))];
|
|
437
|
+
if (stored === undefined) {
|
|
438
|
+
// Indexed and then removed between the search and this read. Say what the
|
|
439
|
+
// hit knows and nothing more; inventing provenance would be worse.
|
|
440
|
+
return {
|
|
441
|
+
recordId: result.sourceId,
|
|
442
|
+
// The revision lives on the hit, not the result: one source can be hit at
|
|
443
|
+
// more than one revision, and the first hit is the one shown.
|
|
444
|
+
revisionId: result.hits[0]?.sourceRevisionId ?? '',
|
|
445
|
+
title: result.title,
|
|
446
|
+
modality: result.kind,
|
|
447
|
+
observedAt: null,
|
|
448
|
+
source: '',
|
|
449
|
+
runId: null,
|
|
450
|
+
verdict: null,
|
|
451
|
+
tags: [],
|
|
452
|
+
evidenceIds,
|
|
453
|
+
current: result.current,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
...fromIndexRecord(stored),
|
|
458
|
+
// `current` is a property of this hit against the index, not of the record.
|
|
459
|
+
current: result.current,
|
|
460
|
+
evidenceIds: evidenceIds.length > 0 ? evidenceIds : [...stored.evidenceIds],
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Answer a Library get.
|
|
465
|
+
*
|
|
466
|
+
* `index.record()` is a keyed lookup. Implementing this as a search with
|
|
467
|
+
* `limit: 1` and then checking whether the single result happened to be the
|
|
468
|
+
* requested id reports every record except the top-ranked one as absent.
|
|
469
|
+
*/
|
|
470
|
+
export function getLibraryRecord(request, config, signal) {
|
|
471
|
+
// The identifier grammar is enforced here, not by the codec: `recordId` is a
|
|
472
|
+
// string either way, and a string is where a path would hide.
|
|
473
|
+
const accepted = parseLibraryGetRequest(request);
|
|
474
|
+
if (!accepted.ok)
|
|
475
|
+
return accepted.refusal;
|
|
476
|
+
const request_ = accepted.value;
|
|
477
|
+
const base = { protocol: WATCH_QUERY_PROTOCOL_VERSION, requestId: request_.requestId };
|
|
478
|
+
if (signal.aborted) {
|
|
479
|
+
return { outcome: 'deadline_exceeded', ...base, deadlineMs: request_.deadlineMs };
|
|
480
|
+
}
|
|
481
|
+
const index = config.index();
|
|
482
|
+
const found = index.record(request_.recordId);
|
|
483
|
+
const revision = revisionOf(index);
|
|
484
|
+
return found === undefined
|
|
485
|
+
? { outcome: 'absent', ...base, revision, recordId: request_.recordId }
|
|
486
|
+
: { outcome: 'record', ...base, revision, record: fromIndexRecord(found) };
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* The persisted record, as the wire carries it.
|
|
490
|
+
*
|
|
491
|
+
* Every field is the stored one. Nothing is derived from a temporal range: a
|
|
492
|
+
* range is media-relative, and an earlier version of this shape turned a clip
|
|
493
|
+
* beginning at offset zero into a 1970 timestamp.
|
|
494
|
+
*/
|
|
495
|
+
function fromIndexRecord(record) {
|
|
496
|
+
return {
|
|
497
|
+
recordId: record.recordId,
|
|
498
|
+
revisionId: record.revisionId,
|
|
499
|
+
title: record.title,
|
|
500
|
+
modality: record.kind,
|
|
501
|
+
observedAt: record.observedAt,
|
|
502
|
+
source: record.source ?? '',
|
|
503
|
+
runId: record.runId,
|
|
504
|
+
verdict: record.verdict,
|
|
505
|
+
tags: [...record.tags],
|
|
506
|
+
evidenceIds: [...record.evidenceIds],
|
|
507
|
+
current: true,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Rebuild the index, and say what happened to the one already in service.
|
|
512
|
+
*
|
|
513
|
+
* Every outcome leaves a searchable Library, which is why none of them is an
|
|
514
|
+
* exception. A refusal, an elapsed deadline, an abandoned rebuild and a failed
|
|
515
|
+
* one are four different facts, and a surface renders each differently.
|
|
516
|
+
*
|
|
517
|
+
* Separate from the Service for the same reason the reads are: the Service
|
|
518
|
+
* stays a Typert adapter with no decisions in it, and the whole path is
|
|
519
|
+
* testable without a DSH runtime.
|
|
520
|
+
*/
|
|
521
|
+
export async function refreshLibrary(request, config, signal) {
|
|
522
|
+
const accepted = parseLibraryRefreshRequest(request);
|
|
523
|
+
if (!accepted.ok)
|
|
524
|
+
return accepted.refusal;
|
|
525
|
+
const checked = accepted.value;
|
|
526
|
+
const base = { protocol: WATCH_QUERY_PROTOCOL_VERSION, requestId: checked.requestId };
|
|
527
|
+
if (signal.aborted) {
|
|
528
|
+
return { outcome: 'deadline_exceeded', ...base, deadlineMs: checked.deadlineMs };
|
|
529
|
+
}
|
|
530
|
+
const generations = config.generations;
|
|
531
|
+
if (generations === undefined) {
|
|
532
|
+
// Not an error to hide. A deployment that composed the read plane over an
|
|
533
|
+
// index it owns by other means has no refresh, and the surface has to be
|
|
534
|
+
// able to say so rather than offering a control that does nothing.
|
|
535
|
+
return {
|
|
536
|
+
outcome: 'refresh_failed',
|
|
537
|
+
...base,
|
|
538
|
+
reason: 'this host does not own the Library index, so it cannot rebuild it',
|
|
539
|
+
index: describeIndex(config.index()),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const outcome = await generations.refresh(checked.requestId, signal);
|
|
543
|
+
if (outcome.kind === 'refreshed') {
|
|
544
|
+
return { outcome: 'refreshed', ...base, index: outcome.index, skipped: outcome.index.skipped };
|
|
545
|
+
}
|
|
546
|
+
if (outcome.kind === 'cancelled') {
|
|
547
|
+
return { outcome: 'refresh_cancelled', ...base, index: outcome.index };
|
|
548
|
+
}
|
|
549
|
+
return { outcome: 'refresh_failed', ...base, reason: outcome.reason, index: outcome.index };
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Describe an index the host holds without a generation record for it.
|
|
553
|
+
*
|
|
554
|
+
* Only reachable where no `LibraryGenerations` is configured, so the numbers
|
|
555
|
+
* that belong to a rebuild are reported as absent rather than invented.
|
|
556
|
+
*/
|
|
557
|
+
function describeIndex(index) {
|
|
558
|
+
return {
|
|
559
|
+
generation: 0,
|
|
560
|
+
startedAt: EPOCH,
|
|
561
|
+
completedAt: null,
|
|
562
|
+
sourceCount: 0,
|
|
563
|
+
recordCount: index.size,
|
|
564
|
+
indexState: wireIndexState(index.health, index.size),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The timestamp for "there was never a rebuild".
|
|
569
|
+
*
|
|
570
|
+
* A fixed instant rather than `now`, because `now` would read as a rebuild
|
|
571
|
+
* that happened this second and did nothing.
|
|
572
|
+
*/
|
|
573
|
+
const EPOCH = '1970-01-01T00:00:00.000Z';
|
|
574
|
+
/**
|
|
575
|
+
* Read the Bridge's live state, and say honestly where it could not.
|
|
576
|
+
*
|
|
577
|
+
* Separate from the Service for the same reason the Library readers are: the
|
|
578
|
+
* Service is a Typert adapter with no decisions in it, and the whole path is
|
|
579
|
+
* testable without a DSH runtime.
|
|
580
|
+
*
|
|
581
|
+
* The rule this function exists to keep: **no field is defaulted.** A version
|
|
582
|
+
* the Bridge has never received is `null`, not `'unknown'` and not the version
|
|
583
|
+
* this build was compiled against. Diagnostics is the screen people open when
|
|
584
|
+
* they already suspect something is wrong, and it is the last place a
|
|
585
|
+
* plausible substitute belongs.
|
|
586
|
+
*/
|
|
587
|
+
export function readCoreHealth(request, ctx, signal) {
|
|
588
|
+
const accepted = parseCoreHealthRequest(request);
|
|
589
|
+
if (!accepted.ok)
|
|
590
|
+
return accepted.refusal;
|
|
591
|
+
const checked = accepted.value;
|
|
592
|
+
if (signal.aborted) {
|
|
593
|
+
return {
|
|
594
|
+
outcome: 'deadline_exceeded',
|
|
595
|
+
protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
596
|
+
requestId: checked.requestId,
|
|
597
|
+
deadlineMs: checked.deadlineMs,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// The Bridge may not be mounted at all — a Workspace can run without Watch.
|
|
601
|
+
// That is a real state and it gets a real answer, rather than a throw the
|
|
602
|
+
// Gateway would render as an internal error.
|
|
603
|
+
const bridge = ctx.watchCore;
|
|
604
|
+
if (bridge === undefined) {
|
|
605
|
+
return {
|
|
606
|
+
outcome: 'core_health',
|
|
607
|
+
protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
608
|
+
requestId: checked.requestId,
|
|
609
|
+
phase: 'disconnected',
|
|
610
|
+
blocker: 'core_missing',
|
|
611
|
+
coreVersion: null,
|
|
612
|
+
coreBuild: null,
|
|
613
|
+
protocolVersion: null,
|
|
614
|
+
protocolMin: null,
|
|
615
|
+
transport: null,
|
|
616
|
+
isTestOnlyMock: false,
|
|
617
|
+
contractsMatch: false,
|
|
618
|
+
contractDrift: [],
|
|
619
|
+
lastHandshakeAt: null,
|
|
620
|
+
restartCount: 0,
|
|
621
|
+
capabilities: { ready: 0, unavailable: 0, degraded: 0, unknown: 0 },
|
|
622
|
+
capabilityDetails: [],
|
|
623
|
+
fix: 'Watch Core is not configured for this Workspace. Install it and set '
|
|
624
|
+
+ 'the Bridge command in Settings → Watch.',
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
const health = bridge.health();
|
|
628
|
+
const handshake = health.handshake;
|
|
629
|
+
const drift = health.error?.error === 'bridge.schema_drift'
|
|
630
|
+
? (health.error.details['drift'] ?? [])
|
|
631
|
+
.map(entry => entry.family)
|
|
632
|
+
: [];
|
|
633
|
+
return {
|
|
634
|
+
outcome: 'core_health',
|
|
635
|
+
protocol: WATCH_QUERY_PROTOCOL_VERSION,
|
|
636
|
+
requestId: checked.requestId,
|
|
637
|
+
phase: health.phase,
|
|
638
|
+
blocker: health.blocker,
|
|
639
|
+
// From the handshake, never from this build's own constants: reporting
|
|
640
|
+
// what the Workspace speaks as though Core had said it is exactly the
|
|
641
|
+
// substitution that made the old panel wrong.
|
|
642
|
+
coreVersion: handshake?.coreVersion ?? null,
|
|
643
|
+
coreBuild: handshake?.coreBuild ?? null,
|
|
644
|
+
protocolVersion: handshake?.protocolVersion ?? null,
|
|
645
|
+
protocolMin: handshake === null ? null : handshake.protocolMin ?? null,
|
|
646
|
+
transport: health.transport,
|
|
647
|
+
isTestOnlyMock: health.isTestOnlyMock,
|
|
648
|
+
contractsMatch: handshake !== null && drift.length === 0,
|
|
649
|
+
contractDrift: drift,
|
|
650
|
+
lastHandshakeAt: health.lastHandshakeAt,
|
|
651
|
+
restartCount: health.restartCount,
|
|
652
|
+
capabilities: tally(bridge),
|
|
653
|
+
capabilityDetails: bridge.capabilities().map(capability => ({
|
|
654
|
+
capabilityId: capability.capabilityId,
|
|
655
|
+
status: capability.status,
|
|
656
|
+
usable: bridge.isCapable(capability.capabilityId),
|
|
657
|
+
missing: capability.missing,
|
|
658
|
+
fixes: capability.fixes,
|
|
659
|
+
lastCheckedAt: capability.lastCheckedAt,
|
|
660
|
+
})),
|
|
661
|
+
fix: health.error?.fix ?? '',
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Count capabilities by what is actually known about each one.
|
|
666
|
+
*
|
|
667
|
+
* `isCapable` decides `ready`, not the reported status, because a capability
|
|
668
|
+
* whose contract family drifted is reported by the engine as implemented and
|
|
669
|
+
* is nonetheless unusable — the two sides disagree about what its payload
|
|
670
|
+
* means. Counting the engine's word here would put a number on the screen that
|
|
671
|
+
* no button could honour.
|
|
672
|
+
*/
|
|
673
|
+
function tally(bridge) {
|
|
674
|
+
const counts = { ready: 0, unavailable: 0, degraded: 0, unknown: 0 };
|
|
675
|
+
const totals = { ...counts };
|
|
676
|
+
for (const capability of bridge.capabilities()) {
|
|
677
|
+
if (bridge.isCapable(capability.capabilityId))
|
|
678
|
+
totals.ready += 1;
|
|
679
|
+
else if (capability.status === 'unavailable')
|
|
680
|
+
totals.unavailable += 1;
|
|
681
|
+
else if (capability.status === 'probed')
|
|
682
|
+
totals.degraded += 1;
|
|
683
|
+
else
|
|
684
|
+
totals.unknown += 1;
|
|
685
|
+
}
|
|
686
|
+
return totals;
|
|
687
|
+
}
|
|
688
|
+
//# sourceMappingURL=read-plane.js.map
|