@yadsh/dsh-qa-surface 0.6.1 → 0.7.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 (281) hide show
  1. package/README.md +136 -7
  2. package/capability-policy.json +1 -1
  3. package/lib/access/capability-catalog.js +144 -0
  4. package/lib/access/capability-catalog.js.map +1 -0
  5. package/lib/access/model.js +379 -0
  6. package/lib/access/model.js.map +1 -0
  7. package/lib/access/role-repository.js +195 -0
  8. package/lib/access/role-repository.js.map +1 -0
  9. package/lib/access/service.js +380 -0
  10. package/lib/access/service.js.map +1 -0
  11. package/lib/access/skill-metadata.js +234 -0
  12. package/lib/access/skill-metadata.js.map +1 -0
  13. package/lib/account-remotes.js +20 -10
  14. package/lib/account-remotes.js.map +1 -1
  15. package/lib/accounts/errors.js.map +1 -1
  16. package/lib/accounts/file.js.map +1 -1
  17. package/lib/accounts/store.js +202 -3
  18. package/lib/accounts/store.js.map +1 -1
  19. package/lib/admin/conversation-log.js +256 -0
  20. package/lib/admin/conversation-log.js.map +1 -0
  21. package/lib/admin/index.js +17 -0
  22. package/lib/admin/index.js.map +1 -0
  23. package/lib/admin/metrics.js +103 -0
  24. package/lib/admin/metrics.js.map +1 -0
  25. package/lib/admin/paging.js +23 -0
  26. package/lib/admin/paging.js.map +1 -0
  27. package/lib/admin/permissions.js +50 -0
  28. package/lib/admin/permissions.js.map +1 -0
  29. package/lib/admin/quality-store.js +485 -0
  30. package/lib/admin/quality-store.js.map +1 -0
  31. package/lib/admin/queue.js +107 -0
  32. package/lib/admin/queue.js.map +1 -0
  33. package/lib/admin/redaction.js +45 -0
  34. package/lib/admin/redaction.js.map +1 -0
  35. package/lib/admin/service.js +897 -0
  36. package/lib/admin/service.js.map +1 -0
  37. package/lib/admin/session-log.js +142 -0
  38. package/lib/admin/session-log.js.map +1 -0
  39. package/lib/cli.js +9 -6
  40. package/lib/cli.js.map +1 -1
  41. package/lib/client/QaSessionController.js +28 -2
  42. package/lib/client/QaSessionController.js.map +1 -1
  43. package/lib/client/QaSurface.js +143 -5
  44. package/lib/client/QaSurface.js.map +1 -1
  45. package/lib/client/QaTranscriptAdapter.js +1 -0
  46. package/lib/client/QaTranscriptAdapter.js.map +1 -1
  47. package/lib/client/admin/QaAdmin.js +458 -0
  48. package/lib/client/admin/QaAdmin.js.map +1 -0
  49. package/lib/client/admin/format.js +177 -0
  50. package/lib/client/admin/format.js.map +1 -0
  51. package/lib/client/admin/pages/Conversations.js +114 -0
  52. package/lib/client/admin/pages/Conversations.js.map +1 -0
  53. package/lib/client/admin/pages/Overview.js +24 -0
  54. package/lib/client/admin/pages/Overview.js.map +1 -0
  55. package/lib/client/admin/pages/Quality.js +71 -0
  56. package/lib/client/admin/pages/Quality.js.map +1 -0
  57. package/lib/client/admin/pages/Review.js +93 -0
  58. package/lib/client/admin/pages/Review.js.map +1 -0
  59. package/lib/client/admin/pages/Users.js +95 -0
  60. package/lib/client/admin/pages/Users.js.map +1 -0
  61. package/lib/client/admin/routes.js +127 -0
  62. package/lib/client/admin/routes.js.map +1 -0
  63. package/lib/client/admin/shared.js +91 -0
  64. package/lib/client/admin/shared.js.map +1 -0
  65. package/lib/client/admin/styles.js +111 -0
  66. package/lib/client/admin/styles.js.map +1 -0
  67. package/lib/client/components/QaChangelog.js +35 -0
  68. package/lib/client/components/QaChangelog.js.map +1 -1
  69. package/lib/client/components/QaHeader.js +3 -3
  70. package/lib/client/components/QaHeader.js.map +1 -1
  71. package/lib/client/components/QaMessage.js +61 -2
  72. package/lib/client/components/QaMessage.js.map +1 -1
  73. package/lib/client/create-session.js +1 -1
  74. package/lib/client/create-session.js.map +1 -1
  75. package/lib/client/index.js +33 -1
  76. package/lib/client/index.js.map +1 -1
  77. package/lib/client/role/RoleSelector.js +31 -0
  78. package/lib/client/role/RoleSelector.js.map +1 -0
  79. package/lib/client/session-sources.js +37 -1
  80. package/lib/client/session-sources.js.map +1 -1
  81. package/lib/client/settings/card.js +2 -2
  82. package/lib/client/settings/card.js.map +1 -1
  83. package/lib/client/settings/sections.js +0 -1
  84. package/lib/client/settings/sections.js.map +1 -1
  85. package/lib/client/styles.js +31 -0
  86. package/lib/client/styles.js.map +1 -1
  87. package/lib/client/types.js.map +1 -1
  88. package/lib/client.js +9908 -4857
  89. package/lib/client.js.map +1 -1
  90. package/lib/config-resolvers/defaults.js +0 -2
  91. package/lib/config-resolvers/defaults.js.map +1 -1
  92. package/lib/config.js +0 -249
  93. package/lib/config.js.map +1 -1
  94. package/lib/enforcement/skill-policy.js +172 -0
  95. package/lib/enforcement/skill-policy.js.map +1 -0
  96. package/lib/enforcement/tool-grants.js +151 -0
  97. package/lib/enforcement/tool-grants.js.map +1 -0
  98. package/lib/index.js +224 -42
  99. package/lib/index.js.map +1 -1
  100. package/lib/personal-skills/service.js +10 -3
  101. package/lib/personal-skills/service.js.map +1 -1
  102. package/lib/qa-tools/index.js +4 -0
  103. package/lib/qa-tools/index.js.map +1 -1
  104. package/lib/resolve-config.js +0 -2
  105. package/lib/resolve-config.js.map +1 -1
  106. package/lib/secure-session.js +71 -17
  107. package/lib/secure-session.js.map +1 -1
  108. package/lib/typert.host.js +2237 -202
  109. package/lib/typert.remote-client.d.ts +49 -3
  110. package/lib/typert.remote-client.js +1863 -183
  111. package/lib/types/access/capability-catalog.d.ts +37 -0
  112. package/lib/types/access/model.d.ts +60 -0
  113. package/lib/types/access/role-repository.d.ts +32 -0
  114. package/lib/types/access/service.d.ts +57 -0
  115. package/lib/types/access/skill-metadata.d.ts +53 -0
  116. package/lib/types/account-remotes.d.ts +6 -0
  117. package/lib/types/accounts/errors.d.ts +1 -1
  118. package/lib/types/accounts/file.d.ts +16 -5
  119. package/lib/types/accounts/store.d.ts +83 -3
  120. package/lib/types/admin/conversation-log.d.ts +39 -0
  121. package/lib/types/admin/index.d.ts +24 -0
  122. package/lib/types/admin/metrics.d.ts +29 -0
  123. package/lib/types/admin/paging.d.ts +33 -0
  124. package/lib/types/admin/permissions.d.ts +10 -0
  125. package/lib/types/admin/quality-store.d.ts +82 -0
  126. package/lib/types/admin/queue.d.ts +21 -0
  127. package/lib/types/admin/redaction.d.ts +29 -0
  128. package/lib/types/admin/service.d.ts +105 -0
  129. package/lib/types/admin/session-log.d.ts +53 -0
  130. package/lib/types/client/QaSessionController.d.ts +12 -1
  131. package/lib/types/client/QaSurface.d.ts +7 -1
  132. package/lib/types/client/admin/QaAdmin.d.ts +17 -0
  133. package/lib/types/client/admin/format.d.ts +43 -0
  134. package/lib/types/client/admin/pages/Conversations.d.ts +21 -0
  135. package/lib/types/client/admin/pages/Overview.d.ts +13 -0
  136. package/lib/types/client/admin/pages/Quality.d.ts +18 -0
  137. package/lib/types/client/admin/pages/Review.d.ts +15 -0
  138. package/lib/types/client/admin/pages/Users.d.ts +22 -0
  139. package/lib/types/client/admin/routes.d.ts +50 -0
  140. package/lib/types/client/admin/shared.d.ts +53 -0
  141. package/lib/types/client/admin/styles.d.ts +11 -0
  142. package/lib/types/client/components/QaHeader.d.ts +5 -1
  143. package/lib/types/client/components/QaMessage.d.ts +14 -1
  144. package/lib/types/client/create-session.d.ts +2 -0
  145. package/lib/types/client/role/RoleSelector.d.ts +14 -0
  146. package/lib/types/client/session-sources.d.ts +9 -0
  147. package/lib/types/client/settings/sections.d.ts +0 -1
  148. package/lib/types/client/types.d.ts +39 -2
  149. package/lib/types/enforcement/skill-policy.d.ts +29 -0
  150. package/lib/types/enforcement/tool-grants.d.ts +84 -0
  151. package/lib/types/index.d.ts +62 -14
  152. package/lib/types/qa-tools/index.d.ts +2 -0
  153. package/lib/types/secure-session.d.ts +10 -1
  154. package/lib/types/types.d.ts +568 -5
  155. package/package.json +1 -1
  156. package/lib/client/settings/sections/documents.js +0 -88
  157. package/lib/client/settings/sections/documents.js.map +0 -1
  158. package/lib/documents/artifacts/ids.js +0 -43
  159. package/lib/documents/artifacts/ids.js.map +0 -1
  160. package/lib/documents/artifacts/manifest.js +0 -66
  161. package/lib/documents/artifacts/manifest.js.map +0 -1
  162. package/lib/documents/artifacts/store.js +0 -169
  163. package/lib/documents/artifacts/store.js.map +0 -1
  164. package/lib/documents/capabilities.js +0 -93
  165. package/lib/documents/capabilities.js.map +0 -1
  166. package/lib/documents/config.js +0 -302
  167. package/lib/documents/config.js.map +0 -1
  168. package/lib/documents/defaults.js +0 -78
  169. package/lib/documents/defaults.js.map +0 -1
  170. package/lib/documents/errors.js +0 -101
  171. package/lib/documents/errors.js.map +0 -1
  172. package/lib/documents/index.js +0 -80
  173. package/lib/documents/index.js.map +0 -1
  174. package/lib/documents/inspect/facts.js +0 -161
  175. package/lib/documents/inspect/facts.js.map +0 -1
  176. package/lib/documents/inspect/zip.js +0 -108
  177. package/lib/documents/inspect/zip.js.map +0 -1
  178. package/lib/documents/markdown/directives.js +0 -135
  179. package/lib/documents/markdown/directives.js.map +0 -1
  180. package/lib/documents/markdown/frontmatter.js +0 -35
  181. package/lib/documents/markdown/frontmatter.js.map +0 -1
  182. package/lib/documents/markdown/normalize.js +0 -113
  183. package/lib/documents/markdown/normalize.js.map +0 -1
  184. package/lib/documents/orchestrator/assets.js +0 -243
  185. package/lib/documents/orchestrator/assets.js.map +0 -1
  186. package/lib/documents/orchestrator/convert-document.js +0 -186
  187. package/lib/documents/orchestrator/convert-document.js.map +0 -1
  188. package/lib/documents/orchestrator/create-document.js +0 -292
  189. package/lib/documents/orchestrator/create-document.js.map +0 -1
  190. package/lib/documents/orchestrator/extract-document.js +0 -162
  191. package/lib/documents/orchestrator/extract-document.js.map +0 -1
  192. package/lib/documents/orchestrator/inspect-document.js +0 -89
  193. package/lib/documents/orchestrator/inspect-document.js.map +0 -1
  194. package/lib/documents/orchestrator/runtime-deps.js +0 -59
  195. package/lib/documents/orchestrator/runtime-deps.js.map +0 -1
  196. package/lib/documents/orchestrator/scope.js +0 -132
  197. package/lib/documents/orchestrator/scope.js.map +0 -1
  198. package/lib/documents/providers/docling.js +0 -247
  199. package/lib/documents/providers/docling.js.map +0 -1
  200. package/lib/documents/providers/libreoffice.js +0 -130
  201. package/lib/documents/providers/libreoffice.js.map +0 -1
  202. package/lib/documents/providers/markitdown.js +0 -89
  203. package/lib/documents/providers/markitdown.js.map +0 -1
  204. package/lib/documents/providers/pandoc.js +0 -161
  205. package/lib/documents/providers/pandoc.js.map +0 -1
  206. package/lib/documents/providers/process.js +0 -164
  207. package/lib/documents/providers/process.js.map +0 -1
  208. package/lib/documents/providers/registry.js +0 -152
  209. package/lib/documents/providers/registry.js.map +0 -1
  210. package/lib/documents/providers/shared.js +0 -75
  211. package/lib/documents/providers/shared.js.map +0 -1
  212. package/lib/documents/runtime.js +0 -114
  213. package/lib/documents/runtime.js.map +0 -1
  214. package/lib/documents/security/file-types.js +0 -188
  215. package/lib/documents/security/file-types.js.map +0 -1
  216. package/lib/documents/security/limits.js +0 -78
  217. package/lib/documents/security/limits.js.map +0 -1
  218. package/lib/documents/security/mime.js +0 -43
  219. package/lib/documents/security/mime.js.map +0 -1
  220. package/lib/documents/security/paths.js +0 -133
  221. package/lib/documents/security/paths.js.map +0 -1
  222. package/lib/documents/templates/registry.js +0 -147
  223. package/lib/documents/templates/registry.js.map +0 -1
  224. package/lib/documents/templates/resolver.js +0 -59
  225. package/lib/documents/templates/resolver.js.map +0 -1
  226. package/lib/documents/tools/convert.js +0 -125
  227. package/lib/documents/tools/convert.js.map +0 -1
  228. package/lib/documents/tools/create.js +0 -187
  229. package/lib/documents/tools/create.js.map +0 -1
  230. package/lib/documents/tools/index.js +0 -41
  231. package/lib/documents/tools/index.js.map +0 -1
  232. package/lib/documents/tools/inspect.js +0 -135
  233. package/lib/documents/tools/inspect.js.map +0 -1
  234. package/lib/documents/tools/shared.js +0 -88
  235. package/lib/documents/tools/shared.js.map +0 -1
  236. package/lib/documents/tools/to-markdown.js +0 -125
  237. package/lib/documents/tools/to-markdown.js.map +0 -1
  238. package/lib/documents/types.js +0 -12
  239. package/lib/documents/types.js.map +0 -1
  240. package/lib/types/client/settings/sections/documents.d.ts +0 -13
  241. package/lib/types/documents/artifacts/ids.d.ts +0 -14
  242. package/lib/types/documents/artifacts/manifest.d.ts +0 -32
  243. package/lib/types/documents/artifacts/store.d.ts +0 -67
  244. package/lib/types/documents/capabilities.d.ts +0 -32
  245. package/lib/types/documents/config.d.ts +0 -27
  246. package/lib/types/documents/defaults.d.ts +0 -161
  247. package/lib/types/documents/errors.d.ts +0 -42
  248. package/lib/types/documents/index.d.ts +0 -63
  249. package/lib/types/documents/inspect/facts.d.ts +0 -43
  250. package/lib/types/documents/inspect/zip.d.ts +0 -28
  251. package/lib/types/documents/markdown/directives.d.ts +0 -39
  252. package/lib/types/documents/markdown/frontmatter.d.ts +0 -15
  253. package/lib/types/documents/markdown/normalize.d.ts +0 -30
  254. package/lib/types/documents/orchestrator/assets.d.ts +0 -55
  255. package/lib/types/documents/orchestrator/convert-document.d.ts +0 -25
  256. package/lib/types/documents/orchestrator/create-document.d.ts +0 -18
  257. package/lib/types/documents/orchestrator/extract-document.d.ts +0 -24
  258. package/lib/types/documents/orchestrator/inspect-document.d.ts +0 -12
  259. package/lib/types/documents/orchestrator/runtime-deps.d.ts +0 -43
  260. package/lib/types/documents/orchestrator/scope.d.ts +0 -52
  261. package/lib/types/documents/providers/docling.d.ts +0 -54
  262. package/lib/types/documents/providers/libreoffice.d.ts +0 -40
  263. package/lib/types/documents/providers/markitdown.d.ts +0 -28
  264. package/lib/types/documents/providers/pandoc.d.ts +0 -42
  265. package/lib/types/documents/providers/process.d.ts +0 -54
  266. package/lib/types/documents/providers/registry.d.ts +0 -68
  267. package/lib/types/documents/providers/shared.d.ts +0 -27
  268. package/lib/types/documents/runtime.d.ts +0 -59
  269. package/lib/types/documents/security/file-types.d.ts +0 -39
  270. package/lib/types/documents/security/limits.d.ts +0 -24
  271. package/lib/types/documents/security/mime.d.ts +0 -13
  272. package/lib/types/documents/security/paths.d.ts +0 -38
  273. package/lib/types/documents/templates/registry.d.ts +0 -50
  274. package/lib/types/documents/templates/resolver.d.ts +0 -30
  275. package/lib/types/documents/tools/convert.d.ts +0 -9
  276. package/lib/types/documents/tools/create.d.ts +0 -14
  277. package/lib/types/documents/tools/index.d.ts +0 -33
  278. package/lib/types/documents/tools/inspect.d.ts +0 -9
  279. package/lib/types/documents/tools/shared.d.ts +0 -77
  280. package/lib/types/documents/tools/to-markdown.d.ts +0 -12
  281. package/lib/types/documents/types.d.ts +0 -294
@@ -0,0 +1,897 @@
1
+ import { QaAccountsError } from "../accounts/store.js";
2
+ import { defaultCapabilityConfig, normalizeUserAccess, } from "../access/model.js";
3
+ import { allows } from "./permissions.js";
4
+ import { aggregateQuality } from "./metrics.js";
5
+ import { paginate } from "./paging.js";
6
+ import { deriveQueue } from "./queue.js";
7
+ import { defaultAdminRedactor } from "./redaction.js";
8
+ import { projectTranscript, } from "./conversation-log.js";
9
+ /**
10
+ * The administrative console's server side.
11
+ *
12
+ * Every entry point takes the caller's token and names the permission it
13
+ * needs; there is no `isAdmin` shortcut and no client-supplied identity. The
14
+ * browser may only ever read what a role grants, and every write lands in the
15
+ * audit trail with its before and after images.
16
+ */
17
+ export const QA_ADMIN_PAGE_DEFAULT = 25;
18
+ export const QA_ADMIN_PAGE_MAX = 100;
19
+ /**
20
+ * How many conversations one metrics or search pass will open. Reading every
21
+ * stored log is unbounded work, so the aggregation covers the newest logs and
22
+ * says so; moving these counters onto a real index is the next iteration.
23
+ */
24
+ export const QA_ADMIN_SCAN_LIMIT = 200;
25
+ /** How long one projected log may serve the list and metrics before re-reading. */
26
+ const TRANSCRIPT_TTL_MS = 30_000;
27
+ const TRANSCRIPT_CACHE_MAX = 128;
28
+ function isoOf(epochMs) {
29
+ return new Date(epochMs).toISOString();
30
+ }
31
+ function epochOf(iso) {
32
+ if (iso === undefined || iso === "")
33
+ return undefined;
34
+ const parsed = Date.parse(iso);
35
+ return Number.isNaN(parsed) ? undefined : parsed;
36
+ }
37
+ function clampLimit(value) {
38
+ if (value === undefined || !Number.isFinite(value))
39
+ return QA_ADMIN_PAGE_DEFAULT;
40
+ return Math.max(1, Math.min(QA_ADMIN_PAGE_MAX, Math.floor(value)));
41
+ }
42
+ function matches(search, ...values) {
43
+ const needle = search.trim().toLocaleLowerCase();
44
+ if (needle === "")
45
+ return true;
46
+ return values.some((value) => value?.toLocaleLowerCase().includes(needle) === true);
47
+ }
48
+ /**
49
+ * Treat an absent filter and an explicit null alike. The browser sends the
50
+ * field either way, and a null that reached a comparison would filter on the
51
+ * absence of a value instead of meaning "no filter at all".
52
+ */
53
+ function given(value) {
54
+ return value === null ? undefined : value;
55
+ }
56
+ /**
57
+ * Review state of a conversation as a whole: a needs-followup verdict outranks
58
+ * a completed one, and no review at all is the unreviewed default.
59
+ */
60
+ function reviewStatusOf(reviews, conversationId) {
61
+ const mine = reviews.filter((row) => row.conversationId === conversationId);
62
+ if (mine.length === 0)
63
+ return "unreviewed";
64
+ return mine.some((row) => row.status === "needs_followup")
65
+ ? "needs_followup"
66
+ : "reviewed";
67
+ }
68
+ export class QaAdminService {
69
+ options;
70
+ redactor;
71
+ transcripts = new Map();
72
+ constructor(options) {
73
+ this.options = options;
74
+ this.redactor = options.redactor ?? defaultAdminRedactor;
75
+ }
76
+ // -------------------------------------------------------------------------
77
+ // Authorization
78
+ // -------------------------------------------------------------------------
79
+ requireAccounts() {
80
+ const accounts = this.options.accounts();
81
+ if (accounts === undefined) {
82
+ throw new Error("QA accounts are not enabled on this deployment.");
83
+ }
84
+ return accounts;
85
+ }
86
+ /**
87
+ * Resolve the caller and check one permission. The refusal carries the shared
88
+ * `forbidden` reason, so a browser shows the same audience-safe copy for
89
+ * every administrative surface it may not open.
90
+ */
91
+ require(token, permission) {
92
+ const accounts = this.requireAccounts();
93
+ const actor = accounts.currentUser(token);
94
+ if (!allows(actor.role, permission)) {
95
+ this.options.logger.warn("admin.forbidden", {
96
+ userId: actor.id,
97
+ role: actor.role,
98
+ permission,
99
+ });
100
+ throw new QaAccountsError("forbidden", `the ${actor.role} role does not grant ${permission}`);
101
+ }
102
+ return { accounts, actor };
103
+ }
104
+ // -------------------------------------------------------------------------
105
+ // Conversation index
106
+ // -------------------------------------------------------------------------
107
+ /**
108
+ * The deployment's conversation index: one row per reserved QA session,
109
+ * enriched with the stored header when the deployment can list one. A chat
110
+ * that exists but has no readable log still appears — the reviewer must see
111
+ * that it happened.
112
+ */
113
+ async index() {
114
+ const accounts = this.requireAccounts();
115
+ const directory = new Map(accounts.directory().map((user) => [user.id, user]));
116
+ const headers = new Map((await this.options.sessionLog.list()).map((header) => [
117
+ header.id,
118
+ header,
119
+ ]));
120
+ return accounts
121
+ .listOwnershipRecords()
122
+ .map((record) => {
123
+ const header = headers.get(record.sessionId);
124
+ // A subagent's session is an implementation detail of one answer, not a
125
+ // conversation a user had.
126
+ if (header?.parentSessionId !== undefined) {
127
+ return undefined;
128
+ }
129
+ const createdAt = header?.createdAt ?? Date.parse(record.claimedAt);
130
+ const snapshot = record.capabilitySnapshot;
131
+ return {
132
+ conversationId: record.sessionId,
133
+ userId: record.userId,
134
+ displayName: directory.get(record.userId)?.displayName ?? record.userId,
135
+ email: directory.get(record.userId)?.email ?? "",
136
+ subroleId: record.subroleId ?? "",
137
+ adminPreview: record.adminPreview === true,
138
+ createdAt,
139
+ createdAtIso: isoOf(createdAt),
140
+ ...(snapshot === undefined
141
+ ? {}
142
+ : {
143
+ effectiveTools: snapshot.tools,
144
+ effectiveSkills: snapshot.skills,
145
+ }),
146
+ };
147
+ })
148
+ .filter((row) => row !== undefined);
149
+ }
150
+ /** One conversation's projected log, cached for the TTL unless asked fresh. */
151
+ async transcript(conversationId, fresh = false) {
152
+ const cached = this.transcripts.get(conversationId);
153
+ const now = this.options.clock?.now() ?? Date.now();
154
+ if (!fresh && cached !== undefined && now - cached.at < TRANSCRIPT_TTL_MS) {
155
+ return { ok: true, project: cached.project };
156
+ }
157
+ const result = await this.options.sessionLog.read(conversationId);
158
+ if (!result.ok) {
159
+ this.transcripts.delete(conversationId);
160
+ return { ok: false, reason: result.reason };
161
+ }
162
+ const project = projectTranscript(result.events, this.redactor);
163
+ this.transcripts.set(conversationId, { at: now, project });
164
+ if (this.transcripts.size > TRANSCRIPT_CACHE_MAX) {
165
+ const oldest = [...this.transcripts.entries()].sort((left, right) => left[1].at - right[1].at)[0];
166
+ if (oldest !== undefined)
167
+ this.transcripts.delete(oldest[0]);
168
+ }
169
+ return { ok: true, project };
170
+ }
171
+ async facts(rows) {
172
+ const facts = [];
173
+ for (const row of rows) {
174
+ const transcript = await this.transcript(row.conversationId);
175
+ facts.push({
176
+ conversationId: row.conversationId,
177
+ userId: row.userId,
178
+ subroleId: row.subroleId,
179
+ assistantMessages: transcript.ok
180
+ ? transcript.project.messages.filter((message) => message.role === "assistant").length
181
+ : 0,
182
+ createdAt: row.createdAt,
183
+ });
184
+ }
185
+ return facts;
186
+ }
187
+ // -------------------------------------------------------------------------
188
+ // Overview and metrics
189
+ // -------------------------------------------------------------------------
190
+ async overview(token) {
191
+ this.require(token, "conversations.read.all");
192
+ const rows = await this.index();
193
+ const metrics = await this.metricsOf(rows);
194
+ const fresh = [...this.options.quality().allFeedback()].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
195
+ const queue = await this.queueRows(rows);
196
+ return Object.freeze({
197
+ metrics,
198
+ alerts: this.alertsOf(metrics),
199
+ recentFeedback: this.feedbackRows(rows, fresh.slice(0, 5), this.options.quality().allReviews()),
200
+ queue: queue.slice(0, 5),
201
+ });
202
+ }
203
+ async metrics(token) {
204
+ this.require(token, "analytics.read");
205
+ return this.metricsOf(await this.index());
206
+ }
207
+ async metricsOf(rows) {
208
+ const quality = this.options.quality();
209
+ // Feedback and reviews are exact; the message counts come from the bounded
210
+ // log scan and are labelled as such where they are shown.
211
+ const scanned = [...rows]
212
+ .sort((left, right) => right.createdAt - left.createdAt)
213
+ .slice(0, QA_ADMIN_SCAN_LIMIT);
214
+ return aggregateQuality({
215
+ conversations: await this.facts(scanned),
216
+ feedback: quality.allFeedback(),
217
+ reviews: quality.allReviews(),
218
+ });
219
+ }
220
+ /** One attention line per condition worth an administrator's time. */
221
+ alertsOf(metrics) {
222
+ const alerts = [];
223
+ if (metrics.unreviewedNegatives > 0) {
224
+ alerts.push({
225
+ level: "critical",
226
+ code: "unreviewed-negatives",
227
+ count: metrics.unreviewedNegatives,
228
+ });
229
+ }
230
+ if (metrics.positiveRate !== null) {
231
+ for (const row of metrics.bySubrole) {
232
+ if (row.rated < 5 || row.positiveRate === null)
233
+ continue;
234
+ if (row.positiveRate <= metrics.positiveRate - 0.1) {
235
+ alerts.push({
236
+ level: "warning",
237
+ code: "subrole-satisfaction",
238
+ count: row.rated,
239
+ subject: row.key,
240
+ rate: row.positiveRate,
241
+ });
242
+ }
243
+ }
244
+ }
245
+ const reasons = new Map();
246
+ for (const row of this.options.quality().allFeedback()) {
247
+ if (row.rating !== "negative")
248
+ continue;
249
+ for (const reason of row.reasons ?? []) {
250
+ reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
251
+ }
252
+ }
253
+ for (const [reason, count] of [...reasons].sort((left, right) => right[1] - left[1])) {
254
+ if (count >= 3) {
255
+ alerts.push({
256
+ level: "warning",
257
+ code: "recurring-reason",
258
+ count,
259
+ subject: reason,
260
+ });
261
+ }
262
+ }
263
+ return Object.freeze(alerts.map((alert) => Object.freeze(alert)));
264
+ }
265
+ // -------------------------------------------------------------------------
266
+ // Users
267
+ // -------------------------------------------------------------------------
268
+ async users(token, query, cursor, limit) {
269
+ const { accounts } = this.require(token, "users.read");
270
+ const config = this.options.roles().snapshot();
271
+ const rows = accounts.listAdminUsers((value) => normalizeUserAccess(value, config));
272
+ const feedback = this.options.quality().allFeedback();
273
+ const ownership = accounts.listOwnershipRecords();
274
+ const enriched = rows.map((user) => Object.freeze({
275
+ ...user,
276
+ conversations: ownership.filter((row) => row.userId === user.id).length,
277
+ feedbackGiven: feedback.filter((row) => row.userId === user.id).length,
278
+ }));
279
+ const status = given(query.status);
280
+ const role = given(query.role);
281
+ const subroleId = given(query.subroleId);
282
+ const search = given(query.search) ?? "";
283
+ const filtered = enriched.filter((user) => {
284
+ if (status === "active" && user.disabled)
285
+ return false;
286
+ if (status === "disabled" && !user.disabled)
287
+ return false;
288
+ if (role !== undefined && user.role !== role)
289
+ return false;
290
+ if (subroleId !== undefined &&
291
+ !user.access.allowedSubroles.includes(subroleId)) {
292
+ return false;
293
+ }
294
+ return matches(search, user.displayName, user.fullName, user.email);
295
+ });
296
+ const sorted = [...filtered].sort((left, right) => left.displayName.localeCompare(right.displayName, "ru"));
297
+ return paginate({
298
+ rows: sorted,
299
+ keyOf: (row) => row.displayName.toLocaleLowerCase(),
300
+ idOf: (row) => row.id,
301
+ cursor,
302
+ limit: clampLimit(limit),
303
+ });
304
+ }
305
+ async user(token, userId) {
306
+ const { accounts } = this.require(token, "users.read");
307
+ const config = this.options.roles().snapshot();
308
+ const row = accounts.adminUser(userId, (value) => normalizeUserAccess(value, config));
309
+ if (row === undefined) {
310
+ throw new QaAccountsError("invalid-credentials", "no such account");
311
+ }
312
+ const feedback = this.options.quality().allFeedback();
313
+ const ownership = accounts
314
+ .listOwnershipRecords()
315
+ .filter((record) => record.userId === userId);
316
+ const catalog = await this.options.access().catalog.snapshot();
317
+ const effective = row.access.allowedSubroles.map((subroleId) => {
318
+ const role = config.subroles.find(({ id }) => id === subroleId);
319
+ const installed = (values, set) => [...new Set(values)].filter((id) => set.has(id)).length;
320
+ const toolIds = [
321
+ ...config.common.tools.always,
322
+ ...config.common.tools.skillGrantable,
323
+ ...(role?.capabilities.tools.always ?? []),
324
+ ...(role?.capabilities.tools.skillGrantable ?? []),
325
+ ];
326
+ return Object.freeze({
327
+ subroleId,
328
+ name: role?.name ?? subroleId,
329
+ tools: installed(toolIds, catalog.toolIds),
330
+ skills: installed(config.common.skills, catalog.skillIds) +
331
+ installed(role?.capabilities.skills ?? [], catalog.skillIds),
332
+ });
333
+ });
334
+ let messages = 0;
335
+ for (const record of ownership.slice(0, QA_ADMIN_SCAN_LIMIT)) {
336
+ const transcript = await this.transcript(record.sessionId);
337
+ if (transcript.ok)
338
+ messages += transcript.project.messages.length;
339
+ }
340
+ const userFeedback = feedback.filter((item) => item.userId === userId);
341
+ return Object.freeze({
342
+ user: Object.freeze({
343
+ ...row,
344
+ conversations: ownership.length,
345
+ feedbackGiven: userFeedback.length,
346
+ }),
347
+ effective: Object.freeze(effective),
348
+ activity: Object.freeze({
349
+ conversations: ownership.length,
350
+ messages,
351
+ positiveRatings: userFeedback.filter(({ rating }) => rating === "positive").length,
352
+ negativeRatings: userFeedback.filter(({ rating }) => rating === "negative").length,
353
+ }),
354
+ });
355
+ }
356
+ async updateUser(token, userId, update) {
357
+ const { accounts, actor } = this.require(token, "users.manage");
358
+ const config = this.options.roles().snapshot();
359
+ const before = accounts.adminUser(userId, (value) => normalizeUserAccess(value, config));
360
+ if (before === undefined) {
361
+ throw new QaAccountsError("invalid-credentials", "no such account");
362
+ }
363
+ const nextRole = given(update.role);
364
+ const nextDisabled = given(update.disabled);
365
+ const nextAccess = given(update.access);
366
+ if (nextRole !== undefined && nextRole !== before.role) {
367
+ this.assertAdminSurvives(accounts, userId, nextRole);
368
+ accounts.setAccessRole(userId, nextRole);
369
+ this.options.quality().appendAudit({
370
+ actorId: actor.id,
371
+ action: "authorization.changed",
372
+ targetType: "user",
373
+ targetId: userId,
374
+ before: before.role,
375
+ after: nextRole,
376
+ });
377
+ }
378
+ if (nextDisabled !== undefined && nextDisabled !== before.disabled) {
379
+ if (nextDisabled)
380
+ this.assertAdminSurvives(accounts, userId, "user");
381
+ accounts.setAccessDisabled(userId, nextDisabled);
382
+ this.options.quality().appendAudit({
383
+ actorId: actor.id,
384
+ action: nextDisabled ? "user.disabled" : "user.enabled",
385
+ targetType: "user",
386
+ targetId: userId,
387
+ });
388
+ }
389
+ if (nextAccess !== undefined) {
390
+ const access = this.exactAssignment(nextAccess, userId);
391
+ accounts.setAccess(userId, access);
392
+ this.options.quality().appendAudit({
393
+ actorId: actor.id,
394
+ action: "subrole.assignment.changed",
395
+ targetType: "user",
396
+ targetId: userId,
397
+ before: before.access,
398
+ after: access,
399
+ });
400
+ }
401
+ this.options.logger.info("admin.user-updated", { userId, actor: actor.id });
402
+ return this.user(token, userId);
403
+ }
404
+ /**
405
+ * Refuse a write that would leave the deployment without a usable admin.
406
+ * Granting the admin role carries no such risk; demoting or disabling the
407
+ * last enabled administrator locks everyone out of the console.
408
+ */
409
+ assertAdminSurvives(accounts, targetId, nextRole) {
410
+ if (nextRole === "admin")
411
+ return;
412
+ const admins = accounts
413
+ .listAdminUsers((value) => normalizeUserAccess(value, defaultCapabilityConfig()))
414
+ .filter((user) => user.role === "admin" && !user.disabled);
415
+ const survivor = admins.some((user) => user.id !== targetId);
416
+ if (!survivor) {
417
+ throw new QaAccountsError("forbidden", "the deployment must keep at least one enabled administrator");
418
+ }
419
+ }
420
+ /** Validate an assignment against the enabled roles before storing it. */
421
+ exactAssignment(input, userId) {
422
+ const config = this.options.roles().snapshot();
423
+ const enabled = new Set(config.subroles.filter(({ enabled }) => enabled).map(({ id }) => id));
424
+ const allowed = [...new Set(input.allowedSubroles)];
425
+ if (allowed.length === 0 ||
426
+ !allowed.every((id) => enabled.has(id)) ||
427
+ !allowed.includes(input.defaultSubrole)) {
428
+ this.options.logger.warn("admin.assignment-rejected", { userId });
429
+ throw new QaAccountsError("invalid-role", "an assignment must name enabled roles and select one of them");
430
+ }
431
+ return Object.freeze({
432
+ allowedSubroles: Object.freeze(allowed),
433
+ defaultSubrole: input.defaultSubrole,
434
+ });
435
+ }
436
+ // -------------------------------------------------------------------------
437
+ // Conversations
438
+ // -------------------------------------------------------------------------
439
+ async conversations(token, query, cursor, limit) {
440
+ this.require(token, "conversations.read.all");
441
+ const rows = await this.index();
442
+ const quality = this.options.quality();
443
+ const feedback = quality.allFeedback();
444
+ const reviews = quality.allReviews();
445
+ const userIdFilter = given(query.userId);
446
+ const subroleFilter = given(query.subroleId);
447
+ const ratingFilter = given(query.rating);
448
+ const reviewFilter = given(query.reviewStatus);
449
+ const search = given(query.search) ?? "";
450
+ const from = epochOf(given(query.from) ?? undefined);
451
+ const to = epochOf(given(query.to) ?? undefined);
452
+ // A search may name a title, which only the log knows, so the candidate
453
+ // set is bounded by the newest logs before the text is available.
454
+ const searchable = search.trim() === ""
455
+ ? rows
456
+ : [...rows]
457
+ .sort((left, right) => right.createdAt - left.createdAt)
458
+ .slice(0, QA_ADMIN_SCAN_LIMIT);
459
+ const candidates = [];
460
+ for (const row of searchable) {
461
+ if (userIdFilter !== undefined && row.userId !== userIdFilter)
462
+ continue;
463
+ if (subroleFilter !== undefined && row.subroleId !== subroleFilter) {
464
+ continue;
465
+ }
466
+ if (from !== undefined && row.createdAt < from)
467
+ continue;
468
+ if (to !== undefined && row.createdAt > to)
469
+ continue;
470
+ const mine = feedback.filter((item) => item.conversationId === row.conversationId);
471
+ if (ratingFilter !== undefined &&
472
+ !mine.some((item) => item.rating === ratingFilter)) {
473
+ continue;
474
+ }
475
+ if (reviewFilter !== undefined &&
476
+ reviewStatusOf(reviews, row.conversationId) !== reviewFilter) {
477
+ continue;
478
+ }
479
+ // One projection per candidate, reused by the search match and then by
480
+ // the summary of a page row: the read is cached either way.
481
+ const transcript = await this.transcript(row.conversationId);
482
+ const title = transcript.ok ? transcript.project.title : undefined;
483
+ if (!matches(search, row.displayName, row.email, row.userId, row.conversationId, title)) {
484
+ continue;
485
+ }
486
+ candidates.push(row);
487
+ }
488
+ const sorted = candidates.sort((left, right) => right.createdAt - left.createdAt);
489
+ const page = paginate({
490
+ rows: sorted,
491
+ keyOf: (row) => row.createdAtIso,
492
+ idOf: (row) => row.conversationId,
493
+ cursor,
494
+ limit: clampLimit(limit),
495
+ });
496
+ const items = [];
497
+ for (const row of page.items) {
498
+ items.push(await this.summaryOf(row, feedback, reviews));
499
+ }
500
+ return {
501
+ items: Object.freeze(items),
502
+ nextCursor: page.nextCursor,
503
+ total: page.total,
504
+ };
505
+ }
506
+ async summaryOf(row, feedback, reviews) {
507
+ const transcript = await this.transcript(row.conversationId);
508
+ const mine = feedback.filter((item) => item.conversationId === row.conversationId);
509
+ const lastActivity = transcript.ok
510
+ ? transcript.project.lastActivity
511
+ : undefined;
512
+ const title = transcript.ok ? transcript.project.title : undefined;
513
+ return Object.freeze({
514
+ conversationId: row.conversationId,
515
+ userId: row.userId,
516
+ displayName: row.displayName,
517
+ subroleId: row.subroleId,
518
+ createdAt: row.createdAtIso,
519
+ updatedAt: isoOf(Math.max(row.createdAt, lastActivity ?? row.createdAt)),
520
+ ...(title === undefined ? {} : { title }),
521
+ messageCount: transcript.ok ? transcript.project.messages.length : 0,
522
+ positiveFeedback: mine.filter(({ rating }) => rating === "positive")
523
+ .length,
524
+ negativeFeedback: mine.filter(({ rating }) => rating === "negative")
525
+ .length,
526
+ reviewStatus: reviewStatusOf(reviews, row.conversationId),
527
+ });
528
+ }
529
+ async conversation(token, conversationId) {
530
+ const { accounts, actor } = this.require(token, "conversations.read.own");
531
+ const owner = accounts.ownerIdOf(conversationId);
532
+ if (owner === undefined) {
533
+ throw new QaAccountsError("session-owned-elsewhere", "this conversation is unknown to the deployment");
534
+ }
535
+ // Reading someone else's conversation is exactly what
536
+ // `conversations.read.all` buys; owning it is what the base permission buys.
537
+ if (owner !== actor.id && !allows(actor.role, "conversations.read.all")) {
538
+ this.options.logger.warn("admin.conversation-forbidden", {
539
+ userId: actor.id,
540
+ conversationId,
541
+ });
542
+ throw new QaAccountsError("forbidden", "this conversation belongs to another account");
543
+ }
544
+ const rows = await this.index();
545
+ const row = rows.find((entry) => entry.conversationId === conversationId);
546
+ if (row === undefined) {
547
+ throw new QaAccountsError("session-owned-elsewhere", "this conversation is unknown to the deployment");
548
+ }
549
+ const quality = this.options.quality();
550
+ const feedback = quality.allFeedback();
551
+ const reviews = quality.allReviews();
552
+ const summary = await this.summaryOf(row, feedback, reviews);
553
+ const transcript = await this.transcript(conversationId, true);
554
+ const project = transcript.ok ? transcript.project : undefined;
555
+ const messages = (project?.messages ?? []).map((message) => Object.freeze({
556
+ ...message,
557
+ feedback: Object.freeze(feedback.filter((item) => item.conversationId === conversationId &&
558
+ item.messageId === message.id)),
559
+ reviews: Object.freeze(reviews.filter((item) => item.conversationId === conversationId &&
560
+ item.messageId === message.id)),
561
+ }));
562
+ return Object.freeze({
563
+ conversationId,
564
+ summary,
565
+ runtime: Object.freeze({
566
+ subroleId: row.subroleId,
567
+ adminPreview: row.adminPreview,
568
+ ...(project?.model === undefined ? {} : { model: project.model }),
569
+ ...(project?.provider === undefined
570
+ ? {}
571
+ : { provider: project.provider }),
572
+ ...(row.effectiveTools === undefined
573
+ ? {}
574
+ : { effectiveTools: row.effectiveTools }),
575
+ ...(row.effectiveSkills === undefined
576
+ ? {}
577
+ : { effectiveSkills: row.effectiveSkills }),
578
+ ...(project === undefined
579
+ ? {}
580
+ : { loadedSkills: project.loadedSkills }),
581
+ ...(transcript.ok
582
+ ? {}
583
+ : { transcriptUnavailable: transcript.reason }),
584
+ }),
585
+ messages: Object.freeze(messages),
586
+ reviews: Object.freeze(reviews.filter((item) => item.conversationId === conversationId)),
587
+ queueItems: Object.freeze(deriveQueue({
588
+ feedback,
589
+ reviews,
590
+ manual: quality.manualQueue(),
591
+ failures: this.failuresOf(conversationId, project),
592
+ }).filter((item) => item.conversationId === conversationId)),
593
+ });
594
+ }
595
+ failuresOf(conversationId, project) {
596
+ if (project === undefined)
597
+ return [];
598
+ const signals = [];
599
+ for (const message of project.messages) {
600
+ for (const call of message.toolCalls ?? []) {
601
+ if (call.error === undefined)
602
+ continue;
603
+ signals.push({
604
+ conversationId,
605
+ messageId: message.id,
606
+ at: isoOf(call.time ?? message.time),
607
+ });
608
+ }
609
+ }
610
+ return signals;
611
+ }
612
+ async failureSignals(rows) {
613
+ const signals = [];
614
+ for (const row of rows) {
615
+ const transcript = await this.transcript(row.conversationId);
616
+ if (!transcript.ok)
617
+ continue;
618
+ signals.push(...this.failuresOf(row.conversationId, transcript.project));
619
+ }
620
+ return signals;
621
+ }
622
+ // -------------------------------------------------------------------------
623
+ // Feedback
624
+ // -------------------------------------------------------------------------
625
+ /**
626
+ * Record the caller's verdict on one assistant message. The conversation must
627
+ * be the caller's own: a rating is the user's own signal, and nobody rates
628
+ * someone else's answer on their behalf.
629
+ */
630
+ rateMessage(token, conversationId, messageId, input) {
631
+ const { accounts, actor } = this.require(token, "conversations.read.own");
632
+ if (accounts.ownerIdOf(conversationId) !== actor.id) {
633
+ throw new QaAccountsError("forbidden", "feedback may only be given on your own conversations");
634
+ }
635
+ const record = this.options
636
+ .quality()
637
+ .rateFeedback({ conversationId, messageId, userId: actor.id }, input);
638
+ this.options.logger.info("admin.feedback-recorded", {
639
+ conversationId,
640
+ messageId,
641
+ rating: record.rating,
642
+ userId: actor.id,
643
+ });
644
+ return record;
645
+ }
646
+ async feedback(token, query, cursor, limit) {
647
+ this.require(token, "reviews.read");
648
+ const rows = await this.index();
649
+ const quality = this.options.quality();
650
+ const reviews = quality.allReviews();
651
+ const ratingFilter = given(query.rating);
652
+ const userFilter = given(query.userId);
653
+ const subroleFilter = given(query.subroleId);
654
+ const reasonFilter = given(query.reason);
655
+ const reviewFilter = given(query.reviewStatus);
656
+ const from = epochOf(given(query.from) ?? undefined);
657
+ const to = epochOf(given(query.to) ?? undefined);
658
+ const conversations = new Map(rows.map((row) => [row.conversationId, row]));
659
+ const filtered = quality.allFeedback().filter((row) => {
660
+ const conversation = conversations.get(row.conversationId);
661
+ if (ratingFilter !== undefined && row.rating !== ratingFilter)
662
+ return false;
663
+ if (userFilter !== undefined && row.userId !== userFilter)
664
+ return false;
665
+ if (subroleFilter !== undefined &&
666
+ conversation?.subroleId !== subroleFilter) {
667
+ return false;
668
+ }
669
+ if (reasonFilter !== undefined &&
670
+ !(row.reasons ?? []).includes(reasonFilter)) {
671
+ return false;
672
+ }
673
+ const at = epochOf(row.createdAt);
674
+ if (from !== undefined && (at === undefined || at < from))
675
+ return false;
676
+ if (to !== undefined && (at === undefined || at > to))
677
+ return false;
678
+ if (reviewFilter !== undefined &&
679
+ reviewStatusOf(reviews, row.conversationId) !== reviewFilter) {
680
+ return false;
681
+ }
682
+ return true;
683
+ });
684
+ const sorted = [...filtered].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
685
+ const page = paginate({
686
+ rows: sorted,
687
+ keyOf: (row) => row.createdAt,
688
+ idOf: (row) => row.id,
689
+ cursor,
690
+ limit: clampLimit(limit),
691
+ });
692
+ // Titles are read for the rows on this page only: a title lives in the
693
+ // conversation's log, and paging is what keeps that work bounded.
694
+ const titles = new Map();
695
+ for (const row of page.items) {
696
+ const transcript = await this.transcript(row.conversationId);
697
+ titles.set(row.conversationId, transcript.ok ? transcript.project.title : undefined);
698
+ }
699
+ return {
700
+ items: Object.freeze(this.feedbackRows(rows, page.items, reviews, titles)),
701
+ nextCursor: page.nextCursor,
702
+ total: page.total,
703
+ };
704
+ }
705
+ feedbackRows(rows, feedback, reviews, titles = new Map()) {
706
+ const conversations = new Map(rows.map((row) => [row.conversationId, row]));
707
+ return feedback.map((row) => {
708
+ const conversation = conversations.get(row.conversationId);
709
+ const title = titles.get(row.conversationId);
710
+ return Object.freeze({
711
+ ...row,
712
+ displayName: conversation?.displayName ?? row.userId,
713
+ subroleId: conversation?.subroleId ?? "",
714
+ ...(title === undefined ? {} : { conversationTitle: title }),
715
+ reviewStatus: reviewStatusOf(reviews, row.conversationId),
716
+ });
717
+ });
718
+ }
719
+ // -------------------------------------------------------------------------
720
+ // Review queue and results
721
+ // -------------------------------------------------------------------------
722
+ async queueRows(rows) {
723
+ const conversations = rows ?? (await this.index());
724
+ const quality = this.options.quality();
725
+ const reviews = quality.allReviews();
726
+ const byId = new Map(conversations.map((row) => [row.conversationId, row]));
727
+ const queue = deriveQueue({
728
+ feedback: quality.allFeedback(),
729
+ reviews,
730
+ manual: quality.manualQueue(),
731
+ failures: await this.failureSignals([...conversations]
732
+ .sort((left, right) => right.createdAt - left.createdAt)
733
+ .slice(0, QA_ADMIN_SCAN_LIMIT)),
734
+ });
735
+ const result = [];
736
+ for (const item of queue) {
737
+ const row = byId.get(item.conversationId);
738
+ if (row === undefined)
739
+ continue;
740
+ const transcript = await this.transcript(item.conversationId);
741
+ const review = reviews.find((entry) => entry.id === item.reviewId);
742
+ result.push(Object.freeze({
743
+ ...item,
744
+ displayName: row.displayName,
745
+ subroleId: row.subroleId,
746
+ ...(transcript.ok && transcript.project.title !== undefined
747
+ ? { title: transcript.project.title }
748
+ : {}),
749
+ raisedAtLabel: item.raisedAt,
750
+ ...(review === undefined
751
+ ? {}
752
+ : {
753
+ issueSummary: review.issues,
754
+ severity: review.severity,
755
+ }),
756
+ }));
757
+ }
758
+ return Object.freeze(result);
759
+ }
760
+ async reviewQueue(token, cursor, limit) {
761
+ this.require(token, "reviews.read");
762
+ const rows = await this.queueRows();
763
+ return paginate({
764
+ rows,
765
+ keyOf: (row) => `${row.priority}:${row.raisedAt}`,
766
+ idOf: (row) => `${row.conversationId}:${row.messageId ?? ""}:${row.feedbackId ?? row.reviewId ?? ""}`,
767
+ cursor,
768
+ limit: clampLimit(limit),
769
+ });
770
+ }
771
+ /** Park a conversation for review without rating it. */
772
+ queueConversation(token, conversationId, messageId) {
773
+ const { accounts, actor } = this.require(token, "reviews.write");
774
+ if (accounts.ownerIdOf(conversationId) === undefined) {
775
+ throw new QaAccountsError("session-owned-elsewhere", "this conversation is unknown to the deployment");
776
+ }
777
+ const entry = this.options
778
+ .quality()
779
+ .enqueueReview(actor.id, conversationId, messageId);
780
+ this.options.quality().appendAudit({
781
+ actorId: actor.id,
782
+ action: "review.queued",
783
+ targetType: "conversation",
784
+ targetId: conversationId,
785
+ after: { messageId: entry.messageId ?? null },
786
+ });
787
+ return {
788
+ conversationId,
789
+ ...(entry.messageId === undefined ? {} : { messageId: entry.messageId }),
790
+ reason: "manual",
791
+ priority: "normal",
792
+ status: "unreviewed",
793
+ raisedAt: entry.createdAt,
794
+ };
795
+ }
796
+ /** Save a reviewer's verdict, and take the item out of the queue when done. */
797
+ saveReview(token, input) {
798
+ const { accounts, actor } = this.require(token, "reviews.write");
799
+ if (accounts.ownerIdOf(input.conversationId) === undefined) {
800
+ throw new QaAccountsError("session-owned-elsewhere", "this conversation is unknown to the deployment");
801
+ }
802
+ const quality = this.options.quality();
803
+ const { review, created } = quality.saveReview(actor.id, input);
804
+ quality.appendAudit({
805
+ actorId: actor.id,
806
+ action: created ? "conversation.reviewed" : "review.updated",
807
+ targetType: "conversation",
808
+ targetId: input.conversationId,
809
+ after: review,
810
+ });
811
+ if (review.status === "needs_followup") {
812
+ quality.enqueueReview(actor.id, input.conversationId, input.messageId);
813
+ }
814
+ else {
815
+ quality.dequeueReview(input.conversationId, input.messageId);
816
+ }
817
+ this.options.logger.info("admin.review-saved", {
818
+ conversationId: input.conversationId,
819
+ reviewerId: actor.id,
820
+ status: review.status,
821
+ severity: review.severity,
822
+ });
823
+ return review;
824
+ }
825
+ // -------------------------------------------------------------------------
826
+ // Audit
827
+ // -------------------------------------------------------------------------
828
+ /**
829
+ * The audit trail as one table. Two writers keep records — the capability
830
+ * service, whose repository predates the console, and the quality store —
831
+ * and an administrator expects one timeline, so both are normalized here.
832
+ */
833
+ async audit(token, query, cursor, limit) {
834
+ this.require(token, "audit.read");
835
+ const actorFilter = given(query.actorId);
836
+ const actionFilter = given(query.action);
837
+ const from = epochOf(given(query.from) ?? undefined);
838
+ const to = epochOf(given(query.to) ?? undefined);
839
+ const rows = [
840
+ ...this.accessAuditEvents(),
841
+ ...this.options.quality().auditEvents(),
842
+ ]
843
+ .filter((row) => {
844
+ if (actorFilter !== undefined && row.actorId !== actorFilter) {
845
+ return false;
846
+ }
847
+ if (actionFilter !== undefined && row.action !== actionFilter) {
848
+ return false;
849
+ }
850
+ const at = epochOf(row.timestamp);
851
+ if (from !== undefined && (at === undefined || at < from))
852
+ return false;
853
+ if (to !== undefined && (at === undefined || at > to))
854
+ return false;
855
+ return true;
856
+ })
857
+ .sort((left, right) => right.timestamp === left.timestamp
858
+ ? right.id.localeCompare(left.id)
859
+ : right.timestamp.localeCompare(left.timestamp));
860
+ return paginate({
861
+ rows,
862
+ keyOf: (row) => row.timestamp,
863
+ idOf: (row) => row.id,
864
+ cursor,
865
+ limit: clampLimit(limit),
866
+ });
867
+ }
868
+ /** Capability-policy events, reshaped into the console's audit vocabulary. */
869
+ accessAuditEvents() {
870
+ const actions = {
871
+ "subrole.created": "subrole.created",
872
+ "subrole.updated": "subrole.updated",
873
+ "subrole.deleted": "subrole.deleted",
874
+ "common.updated": "common_capabilities.updated",
875
+ "assignment.updated": "subrole.assignment.changed",
876
+ };
877
+ const events = [];
878
+ const source = this.options.roles().audit();
879
+ source.forEach((event, index) => {
880
+ const action = actions[event.action];
881
+ if (action === undefined)
882
+ return;
883
+ events.push(Object.freeze({
884
+ id: `policy:${index}:${event.timestamp}`,
885
+ timestamp: event.timestamp,
886
+ actorId: event.actorId,
887
+ action,
888
+ targetType: "capability",
889
+ ...(event.targetId === undefined ? {} : { targetId: event.targetId }),
890
+ ...(event.before === undefined ? {} : { before: event.before }),
891
+ ...(event.after === undefined ? {} : { after: event.after }),
892
+ }));
893
+ });
894
+ return events;
895
+ }
896
+ }
897
+ //# sourceMappingURL=service.js.map