@yadsh/dsh-qa-surface 0.12.0 → 0.13.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.
Files changed (50) hide show
  1. package/lib/admin/service.js +254 -52
  2. package/lib/admin/service.js.map +1 -1
  3. package/lib/client/QaAccountsController.js +19 -1
  4. package/lib/client/QaAccountsController.js.map +1 -1
  5. package/lib/client/admin/pages/Conversations.js +9 -2
  6. package/lib/client/admin/pages/Conversations.js.map +1 -1
  7. package/lib/client/admin/pages/Overview.js +1 -1
  8. package/lib/client/admin/pages/Overview.js.map +1 -1
  9. package/lib/client/admin/pages/Quality.js +1 -1
  10. package/lib/client/admin/pages/Quality.js.map +1 -1
  11. package/lib/client/admin/pages/Review.js +8 -4
  12. package/lib/client/admin/pages/Review.js.map +1 -1
  13. package/lib/client/admin/shared.js +42 -4
  14. package/lib/client/admin/shared.js.map +1 -1
  15. package/lib/client/components/QaChangelog.js +23 -0
  16. package/lib/client/components/QaChangelog.js.map +1 -1
  17. package/lib/client/index.js +18 -4
  18. package/lib/client/index.js.map +1 -1
  19. package/lib/client/markdown/MermaidBlock.js +80 -0
  20. package/lib/client/markdown/MermaidBlock.js.map +1 -0
  21. package/lib/client/markdown/render.js +4 -0
  22. package/lib/client/markdown/render.js.map +1 -1
  23. package/lib/client/rating-harvest.js +138 -0
  24. package/lib/client/rating-harvest.js.map +1 -0
  25. package/lib/client/styles.js +23 -0
  26. package/lib/client/styles.js.map +1 -1
  27. package/lib/client/types.js.map +1 -1
  28. package/lib/client.js +2289 -107
  29. package/lib/client.js.map +1 -1
  30. package/lib/index.js +26 -9
  31. package/lib/index.js.map +1 -1
  32. package/lib/provenance/extractors.js +1 -1
  33. package/lib/provenance/extractors.js.map +1 -1
  34. package/lib/provenance/host-store.js +5 -1
  35. package/lib/provenance/host-store.js.map +1 -1
  36. package/lib/qa-tools/docs-tools.js +63 -10
  37. package/lib/qa-tools/docs-tools.js.map +1 -1
  38. package/lib/typert.host.js +137 -72
  39. package/lib/typert.remote-client.d.ts +11 -9
  40. package/lib/typert.remote-client.js +117 -67
  41. package/lib/types/admin/service.d.ts +60 -9
  42. package/lib/types/client/QaAccountsController.d.ts +12 -1
  43. package/lib/types/client/admin/shared.d.ts +16 -1
  44. package/lib/types/client/markdown/MermaidBlock.d.ts +6 -0
  45. package/lib/types/client/rating-harvest.d.ts +49 -0
  46. package/lib/types/client/types.d.ts +11 -4
  47. package/lib/types/index.d.ts +18 -6
  48. package/lib/types/qa-tools/docs-tools.d.ts +2 -2
  49. package/lib/types/types.d.ts +20 -0
  50. package/package.json +5 -4
@@ -4,6 +4,7 @@ import { allows } from "./permissions.js";
4
4
  import { aggregateQuality } from "./metrics.js";
5
5
  import { paginate } from "./paging.js";
6
6
  import { deriveQueue } from "./queue.js";
7
+ import { QA_FEEDBACK_RATINGS } from "./quality-store.js";
7
8
  import { defaultAdminRedactor } from "./redaction.js";
8
9
  import { projectTranscript, } from "./conversation-log.js";
9
10
  /**
@@ -22,9 +23,36 @@ export const QA_ADMIN_PAGE_MAX = 100;
22
23
  * says so; moving these counters onto a real index is the next iteration.
23
24
  */
24
25
  export const QA_ADMIN_SCAN_LIMIT = 200;
25
- /** How long one projected log may serve the list and metrics before re-reading. */
26
+ /**
27
+ * How long one projected log may serve the list and metrics before it is read
28
+ * again — and only while the Harness still holds that conversation. A log no
29
+ * process holds cannot gain events, so re-reading it is pure cost: on a
30
+ * deployment with a durable store one read pays for a listing of every stored
31
+ * session and a replay of the whole log before it reaches the one conversation
32
+ * asked for. Reading the scan window again on every page load is what took the
33
+ * aggregate pages minutes; keeping what cannot have changed is what makes the
34
+ * next load free.
35
+ */
26
36
  const TRANSCRIPT_TTL_MS = 30_000;
27
- const TRANSCRIPT_CACHE_MAX = 128;
37
+ /**
38
+ * How many projections the cache holds. It has to cover one scan window with
39
+ * room for a page of summaries: a cache narrower than the window evicts the
40
+ * conversations a pass has already read, and the next pass pays for them again.
41
+ */
42
+ const TRANSCRIPT_CACHE_MAX = QA_ADMIN_SCAN_LIMIT + QA_ADMIN_PAGE_MAX;
43
+ /**
44
+ * What one harvest request may carry. The browser replays its ratings in
45
+ * batches far below this; the ceiling is what a caller that ignored them is
46
+ * refused outright, rather than half-applied.
47
+ */
48
+ const MAX_HARVEST_BATCH = 500;
49
+ /** One harvested identifier, or undefined when the entry cannot be filed. */
50
+ function harvestId(value) {
51
+ if (typeof value !== "string")
52
+ return undefined;
53
+ const trimmed = value.trim();
54
+ return trimmed === "" ? undefined : trimmed;
55
+ }
28
56
  /**
29
57
  * Every session one conversation owns: the chat itself and everything
30
58
  * delegated from it, transitively. A subagent's session is part of the answer
@@ -87,9 +115,26 @@ function reviewStatusOf(reviews, conversationId) {
87
115
  ? "needs_followup"
88
116
  : "reviewed";
89
117
  }
118
+ /**
119
+ * Whether a running pass has no caller left to answer. A caller that passed no
120
+ * cancellation cannot leave, so it keeps the pass alive.
121
+ */
122
+ function nobodyWaits(waiting) {
123
+ for (const signal of waiting) {
124
+ if (signal === undefined || !signal.aborted)
125
+ return false;
126
+ }
127
+ return true;
128
+ }
129
+ function sameWindow(left, right) {
130
+ return (left.length === right.length &&
131
+ left.every((id, index) => id === right[index]));
132
+ }
90
133
  export class QaAdminService {
91
134
  options;
92
135
  redactor;
136
+ /** The aggregate pass running now, shared by the callers waiting for it. */
137
+ scanning;
93
138
  transcripts = new Map();
94
139
  constructor(options) {
95
140
  this.options = options;
@@ -176,30 +221,116 @@ export class QaAdminService {
176
221
  })
177
222
  .filter((row) => row !== undefined);
178
223
  }
179
- /** One conversation's projected log, cached for the TTL unless asked fresh. */
224
+ /**
225
+ * One conversation's projected log, held across calls.
226
+ *
227
+ * A log the Harness no longer holds is finished — nothing can append to it —
228
+ * so its projection stays valid until the cache evicts it or the conversation
229
+ * is deleted. Only a log this process is still writing is re-read once the TTL
230
+ * expires. A cache hit counts as a use: an aggregate pass walks the whole
231
+ * window, and evicting by read time would drop the conversations the pages ask
232
+ * for most often and then read them again.
233
+ *
234
+ * The one answer this can hold back is a conversation resumed and finished
235
+ * between two page loads: its counters lag until the log is read for another
236
+ * reason. The conversation itself is never affected — the detail view always
237
+ * reads the log — and what the aggregate pages show instead is the newest
238
+ * projection of every finished conversation, which is the cheaper lie.
239
+ */
180
240
  async transcript(conversationId, fresh = false) {
181
241
  const cached = this.transcripts.get(conversationId);
182
242
  const now = this.options.clock?.now() ?? Date.now();
183
- if (!fresh && cached !== undefined && now - cached.at < TRANSCRIPT_TTL_MS) {
184
- return { ok: true, project: cached.project };
243
+ if (cached !== undefined) {
244
+ const stale = fresh ||
245
+ (this.options.sessionLog.live(conversationId) &&
246
+ now - cached.at >= TRANSCRIPT_TTL_MS);
247
+ if (!stale) {
248
+ this.retain(conversationId, cached);
249
+ return { ok: true, project: cached.project };
250
+ }
251
+ this.transcripts.delete(conversationId);
185
252
  }
186
253
  const result = await this.options.sessionLog.read(conversationId);
187
254
  if (!result.ok) {
188
- this.transcripts.delete(conversationId);
189
255
  return { ok: false, reason: result.reason };
190
256
  }
191
257
  const project = projectTranscript(result.events, this.redactor);
192
- this.transcripts.set(conversationId, { at: now, project });
193
- if (this.transcripts.size > TRANSCRIPT_CACHE_MAX) {
194
- const oldest = [...this.transcripts.entries()].sort((left, right) => left[1].at - right[1].at)[0];
195
- if (oldest !== undefined)
196
- this.transcripts.delete(oldest[0]);
197
- }
258
+ this.retain(conversationId, {
259
+ at: this.options.clock?.now() ?? Date.now(),
260
+ project,
261
+ });
198
262
  return { ok: true, project };
199
263
  }
200
- async facts(rows) {
264
+ /** Insert or re-touch one projection, keeping the cache inside its budget. */
265
+ retain(conversationId, entry) {
266
+ this.transcripts.delete(conversationId);
267
+ this.transcripts.set(conversationId, entry);
268
+ while (this.transcripts.size > TRANSCRIPT_CACHE_MAX) {
269
+ const oldest = this.transcripts.keys().next();
270
+ if (oldest.done === true)
271
+ break;
272
+ this.transcripts.delete(oldest.value);
273
+ }
274
+ }
275
+ /**
276
+ * The aggregate rows of the newest scan window, read in one pass.
277
+ *
278
+ * The message counts and the tool failures come from the same projections, so
279
+ * one walk fills both. While a walk runs, a caller that wants the same window
280
+ * joins it rather than starting a second scan of the deployment's logs, and the
281
+ * walk stops once no caller waits for it: a scan nobody is reading is only ever
282
+ * a load on the store.
283
+ */
284
+ async scanOf(rows, signal) {
285
+ signal?.throwIfAborted();
286
+ const window = [...rows]
287
+ .sort((left, right) => right.createdAt - left.createdAt)
288
+ .slice(0, QA_ADMIN_SCAN_LIMIT);
289
+ const ids = window.map((row) => row.conversationId);
290
+ const running = this.scanning;
291
+ if (running !== undefined &&
292
+ !nobodyWaits(running.waiting) &&
293
+ sameWindow(running.window, ids)) {
294
+ running.waiting.add(signal);
295
+ try {
296
+ return await running.promise;
297
+ }
298
+ finally {
299
+ running.waiting.delete(signal);
300
+ }
301
+ }
201
302
  const facts = [];
202
- for (const row of rows) {
303
+ const failures = [];
304
+ const waiting = new Set([signal]);
305
+ const promise = this.walk(window, waiting, facts, failures);
306
+ const pass = { window: ids, promise, waiting };
307
+ this.scanning = pass;
308
+ void promise
309
+ .catch(() => undefined)
310
+ .then(() => {
311
+ if (this.scanning === pass)
312
+ this.scanning = undefined;
313
+ });
314
+ try {
315
+ return await promise;
316
+ }
317
+ finally {
318
+ waiting.delete(signal);
319
+ }
320
+ }
321
+ /**
322
+ * Walk one scan window, filling the counts and the failure signals as it goes.
323
+ *
324
+ * `waiting` is the caller set of the pass this walk belongs to: the walk gives
325
+ * up between two reads once every caller has left, so a reviewer who abandons a
326
+ * page that has not answered does not keep the deployment reading logs for an
327
+ * answer nobody will see.
328
+ */
329
+ async walk(window, waiting, facts, failures) {
330
+ for (const row of window) {
331
+ if (nobodyWaits(waiting)) {
332
+ throw new Error("qa admin aggregate scan abandoned: no caller left");
333
+ }
203
334
  const transcript = await this.transcript(row.conversationId);
204
335
  facts.push({
205
336
  conversationId: row.conversationId,
@@ -210,18 +341,21 @@ export class QaAdminService {
210
341
  : 0,
211
342
  createdAt: row.createdAt,
212
343
  });
344
+ if (transcript.ok) {
345
+ failures.push(...this.failuresOf(row.conversationId, transcript.project));
346
+ }
213
347
  }
214
- return facts;
348
+ return { facts, failures };
215
349
  }
216
350
  // -------------------------------------------------------------------------
217
351
  // Overview and metrics
218
352
  // -------------------------------------------------------------------------
219
- async overview(token) {
353
+ async overview(token, signal) {
220
354
  this.require(token, "conversations.read.all");
221
355
  const rows = await this.index();
222
- const metrics = await this.metricsOf(rows);
356
+ const metrics = await this.metricsOf(rows, signal);
223
357
  const fresh = [...this.options.quality().allFeedback()].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
224
- const queue = await this.queueRows(rows);
358
+ const queue = await this.queueRows(rows, signal);
225
359
  return Object.freeze({
226
360
  metrics,
227
361
  alerts: this.alertsOf(metrics),
@@ -229,19 +363,16 @@ export class QaAdminService {
229
363
  queue: queue.slice(0, 5),
230
364
  });
231
365
  }
232
- async metrics(token) {
366
+ async metrics(token, signal) {
233
367
  this.require(token, "analytics.read");
234
- return this.metricsOf(await this.index());
368
+ return this.metricsOf(await this.index(), signal);
235
369
  }
236
- async metricsOf(rows) {
370
+ async metricsOf(rows, signal) {
237
371
  const quality = this.options.quality();
238
372
  // Feedback and reviews are exact; the message counts come from the bounded
239
373
  // log scan and are labelled as such where they are shown.
240
- const scanned = [...rows]
241
- .sort((left, right) => right.createdAt - left.createdAt)
242
- .slice(0, QA_ADMIN_SCAN_LIMIT);
243
374
  return aggregateQuality({
244
- conversations: await this.facts(scanned),
375
+ conversations: (await this.scanOf(rows, signal)).facts,
245
376
  feedback: quality.allFeedback(),
246
377
  reviews: quality.allReviews(),
247
378
  });
@@ -534,8 +665,9 @@ export class QaAdminService {
534
665
  // -------------------------------------------------------------------------
535
666
  // Conversations
536
667
  // -------------------------------------------------------------------------
537
- async conversations(token, query, cursor, limit) {
668
+ async conversations(token, query, cursor, limit, signal) {
538
669
  this.require(token, "conversations.read.all");
670
+ signal?.throwIfAborted();
539
671
  const rows = await this.index();
540
672
  const quality = this.options.quality();
541
673
  const feedback = quality.allFeedback();
@@ -545,17 +677,23 @@ export class QaAdminService {
545
677
  const ratingFilter = given(query.rating);
546
678
  const reviewFilter = given(query.reviewStatus);
547
679
  const search = given(query.search) ?? "";
680
+ const searching = search.trim() !== "";
548
681
  const from = epochOf(given(query.from) ?? undefined);
549
682
  const to = epochOf(given(query.to) ?? undefined);
550
683
  // A search may name a title, which only the log knows, so the candidate
551
- // set is bounded by the newest logs before the text is available.
552
- const searchable = search.trim() === ""
553
- ? rows
554
- : [...rows]
684
+ // set is bounded by the newest logs before the text is available. Without
685
+ // search text no filter here needs a log at all — and reading one per
686
+ // candidate to answer a page of twenty-five rows made this call cost every
687
+ // conversation the deployment stores, each read paying for a listing of all
688
+ // of them before it reaches the one log asked for.
689
+ const searchable = searching
690
+ ? [...rows]
555
691
  .sort((left, right) => right.createdAt - left.createdAt)
556
- .slice(0, QA_ADMIN_SCAN_LIMIT);
692
+ .slice(0, QA_ADMIN_SCAN_LIMIT)
693
+ : rows;
557
694
  const candidates = [];
558
695
  for (const row of searchable) {
696
+ signal?.throwIfAborted();
559
697
  if (userIdFilter !== undefined && row.userId !== userIdFilter)
560
698
  continue;
561
699
  if (subroleFilter !== undefined && row.subroleId !== subroleFilter) {
@@ -574,10 +712,13 @@ export class QaAdminService {
574
712
  reviewStatusOf(reviews, row.conversationId) !== reviewFilter) {
575
713
  continue;
576
714
  }
577
- // One projection per candidate, reused by the search match and then by
578
- // the summary of a page row: the read is cached either way.
579
- const transcript = await this.transcript(row.conversationId);
580
- const title = transcript.ok ? transcript.project.title : undefined;
715
+ // Only a search reads a candidate's log, and the projection stays cached
716
+ // for the summary of the rows that survive the filter.
717
+ let title;
718
+ if (searching) {
719
+ const transcript = await this.transcript(row.conversationId);
720
+ title = transcript.ok ? transcript.project.title : undefined;
721
+ }
581
722
  if (!matches(search, row.displayName, row.email, row.userId, row.conversationId, title)) {
582
723
  continue;
583
724
  }
@@ -593,6 +734,7 @@ export class QaAdminService {
593
734
  });
594
735
  const items = [];
595
736
  for (const row of page.items) {
737
+ signal?.throwIfAborted();
596
738
  items.push(await this.summaryOf(row, feedback, reviews));
597
739
  }
598
740
  return {
@@ -741,7 +883,10 @@ export class QaAdminService {
741
883
  const qualityRows = this.options.quality().dropConversations(targets);
742
884
  for (const sessionId of targets)
743
885
  this.options.dropSources?.(sessionId);
744
- this.transcripts.delete(conversationId);
886
+ // A removed log is never read again, so its projection must not survive to
887
+ // answer a later page: the cache holds it as if the conversation existed.
888
+ for (const sessionId of targets)
889
+ this.transcripts.delete(sessionId);
745
890
  this.options.quality().appendAudit({
746
891
  actorId: actor.id,
747
892
  action: "conversation.deleted",
@@ -783,16 +928,6 @@ export class QaAdminService {
783
928
  }
784
929
  return signals;
785
930
  }
786
- async failureSignals(rows) {
787
- const signals = [];
788
- for (const row of rows) {
789
- const transcript = await this.transcript(row.conversationId);
790
- if (!transcript.ok)
791
- continue;
792
- signals.push(...this.failuresOf(row.conversationId, transcript.project));
793
- }
794
- return signals;
795
- }
796
931
  // -------------------------------------------------------------------------
797
932
  // Feedback
798
933
  // -------------------------------------------------------------------------
@@ -817,6 +952,74 @@ export class QaAdminService {
817
952
  });
818
953
  return record;
819
954
  }
955
+ /**
956
+ * Replay the ratings a browser still holds from the window in which a thumbs
957
+ * never reached the Host.
958
+ *
959
+ * A harvest never overwrites. The browser's map stores no timestamp, so an
960
+ * entry may well be a rating the same user has since filed together with a
961
+ * reason and a comment; replacing that record with the bare thumbs would
962
+ * delete exactly what a reviewer reads. An entry the Host already answers for
963
+ * is counted as present and left alone.
964
+ *
965
+ * One unusable or foreign entry costs that entry alone: a browser keeps
966
+ * ratings of chats it no longer owns, and the rest of the replay must not be
967
+ * lost because of them.
968
+ */
969
+ harvestFeedback(token, entries) {
970
+ const { accounts, actor } = this.require(token, "conversations.read.own");
971
+ if (!Array.isArray(entries)) {
972
+ throw new TypeError("entries must be an array");
973
+ }
974
+ if (entries.length > MAX_HARVEST_BATCH) {
975
+ throw new QaAccountsError("auth-required", "harvest batch is too large");
976
+ }
977
+ const quality = this.options.quality();
978
+ let recorded = 0;
979
+ let present = 0;
980
+ let rejected = 0;
981
+ for (const entry of entries) {
982
+ const conversationId = harvestId(entry?.conversationId);
983
+ const messageId = harvestId(entry?.messageId);
984
+ const rating = entry?.rating;
985
+ if (conversationId === undefined ||
986
+ messageId === undefined ||
987
+ !QA_FEEDBACK_RATINGS.includes(rating)) {
988
+ rejected += 1;
989
+ continue;
990
+ }
991
+ if (accounts.ownerIdOf(conversationId) !== actor.id) {
992
+ rejected += 1;
993
+ continue;
994
+ }
995
+ if (quality.feedbackOf(conversationId, messageId, actor.id) !== undefined) {
996
+ present += 1;
997
+ continue;
998
+ }
999
+ try {
1000
+ quality.rateFeedback({ conversationId, messageId, userId: actor.id }, { rating });
1001
+ recorded += 1;
1002
+ }
1003
+ catch (error) {
1004
+ // The store re-validates what it is given, and one row it refuses is
1005
+ // that row's problem rather than the batch's.
1006
+ this.options.logger.warn("admin.feedback-harvest-entry-refused", {
1007
+ conversationId,
1008
+ messageId,
1009
+ userId: actor.id,
1010
+ reason: error instanceof Error ? error.message : String(error),
1011
+ });
1012
+ rejected += 1;
1013
+ }
1014
+ }
1015
+ this.options.logger.info("admin.feedback-harvested", {
1016
+ userId: actor.id,
1017
+ recorded,
1018
+ present,
1019
+ rejected,
1020
+ });
1021
+ return { recorded, present, rejected };
1022
+ }
820
1023
  async feedback(token, query, cursor, limit) {
821
1024
  this.require(token, "reviews.read");
822
1025
  const rows = await this.index();
@@ -893,7 +1096,7 @@ export class QaAdminService {
893
1096
  // -------------------------------------------------------------------------
894
1097
  // Review queue and results
895
1098
  // -------------------------------------------------------------------------
896
- async queueRows(rows) {
1099
+ async queueRows(rows, signal) {
897
1100
  const conversations = rows ?? (await this.index());
898
1101
  const quality = this.options.quality();
899
1102
  const reviews = quality.allReviews();
@@ -902,12 +1105,11 @@ export class QaAdminService {
902
1105
  feedback: quality.allFeedback(),
903
1106
  reviews,
904
1107
  manual: quality.manualQueue(),
905
- failures: await this.failureSignals([...conversations]
906
- .sort((left, right) => right.createdAt - left.createdAt)
907
- .slice(0, QA_ADMIN_SCAN_LIMIT)),
1108
+ failures: (await this.scanOf(conversations, signal)).failures,
908
1109
  });
909
1110
  const result = [];
910
1111
  for (const item of queue) {
1112
+ signal?.throwIfAborted();
911
1113
  const row = byId.get(item.conversationId);
912
1114
  if (row === undefined)
913
1115
  continue;
@@ -931,9 +1133,9 @@ export class QaAdminService {
931
1133
  }
932
1134
  return Object.freeze(result);
933
1135
  }
934
- async reviewQueue(token, cursor, limit) {
1136
+ async reviewQueue(token, cursor, limit, signal) {
935
1137
  this.require(token, "reviews.read");
936
- const rows = await this.queueRows();
1138
+ const rows = await this.queueRows(await this.index(), signal);
937
1139
  return paginate({
938
1140
  rows,
939
1141
  keyOf: (row) => `${row.priority}:${row.raisedAt}`,