@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.
@@ -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