@yadsh/dsh-qa-surface 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -22
- package/compatibility.json +7 -3
- package/lib/access/capability-catalog.js +22 -6
- package/lib/access/capability-catalog.js.map +1 -1
- package/lib/access/model.js +36 -2
- package/lib/access/model.js.map +1 -1
- package/lib/access/service.js +19 -4
- package/lib/access/service.js.map +1 -1
- package/lib/account-remotes.js +4 -0
- package/lib/account-remotes.js.map +1 -1
- package/lib/accounts/database.js +49 -9
- package/lib/accounts/database.js.map +1 -1
- package/lib/accounts/errors.js.map +1 -1
- package/lib/accounts/file.js.map +1 -1
- package/lib/accounts/store.js +53 -8
- package/lib/accounts/store.js.map +1 -1
- package/lib/admin/permissions.js +6 -0
- package/lib/admin/permissions.js.map +1 -1
- package/lib/admin/quality-store.js +95 -19
- package/lib/admin/quality-store.js.map +1 -1
- package/lib/admin/service.js +432 -52
- package/lib/admin/service.js.map +1 -1
- package/lib/cli.js +24 -4
- package/lib/cli.js.map +1 -1
- package/lib/client/QaAccountsController.js +70 -5
- package/lib/client/QaAccountsController.js.map +1 -1
- package/lib/client/QaConfigController.js.map +1 -1
- package/lib/client/QaSessionController.js +626 -63
- package/lib/client/QaSessionController.js.map +1 -1
- package/lib/client/QaSurface.js +279 -43
- package/lib/client/QaSurface.js.map +1 -1
- package/lib/client/QaSurfaceGuard.js +2 -2
- package/lib/client/QaSurfaceGuard.js.map +1 -1
- package/lib/client/QaTranscriptAdapter.js +4 -2
- package/lib/client/QaTranscriptAdapter.js.map +1 -1
- package/lib/client/admin/QaAdmin.js +59 -34
- package/lib/client/admin/QaAdmin.js.map +1 -1
- package/lib/client/admin/pages/Conversations.js +21 -14
- package/lib/client/admin/pages/Conversations.js.map +1 -1
- package/lib/client/admin/pages/ExpertMemory.js +210 -0
- package/lib/client/admin/pages/ExpertMemory.js.map +1 -0
- package/lib/client/admin/pages/Overview.js +5 -5
- package/lib/client/admin/pages/Overview.js.map +1 -1
- package/lib/client/admin/pages/Quality.js +6 -6
- package/lib/client/admin/pages/Quality.js.map +1 -1
- package/lib/client/admin/pages/Review.js +13 -9
- package/lib/client/admin/pages/Review.js.map +1 -1
- package/lib/client/admin/pages/SkillFiles.js +3 -3
- package/lib/client/admin/pages/SkillFiles.js.map +1 -1
- package/lib/client/admin/pages/Users.js +12 -12
- package/lib/client/admin/pages/Users.js.map +1 -1
- package/lib/client/admin/routes.js +4 -0
- package/lib/client/admin/routes.js.map +1 -1
- package/lib/client/admin/shared.js +57 -10
- package/lib/client/admin/shared.js.map +1 -1
- package/lib/client/admin/styles.js +3 -0
- package/lib/client/admin/styles.js.map +1 -1
- package/lib/client/attestation.js +11 -2
- package/lib/client/attestation.js.map +1 -1
- package/lib/client/audit/QaAuditDialog.js +2 -2
- package/lib/client/audit/QaAuditDialog.js.map +1 -1
- package/lib/client/audit/QaAuditRowBadge.js +1 -1
- package/lib/client/audit/QaAuditRowBadge.js.map +1 -1
- package/lib/client/components/Markdown.js +7 -2
- package/lib/client/components/Markdown.js.map +1 -1
- package/lib/client/components/QaAgentsDrawer.js +7 -5
- package/lib/client/components/QaAgentsDrawer.js.map +1 -1
- package/lib/client/components/QaApproval.js +2 -2
- package/lib/client/components/QaApproval.js.map +1 -1
- package/lib/client/components/QaAuthGate.js +6 -6
- package/lib/client/components/QaAuthGate.js.map +1 -1
- package/lib/client/components/QaChangelog.js +87 -0
- package/lib/client/components/QaChangelog.js.map +1 -1
- package/lib/client/components/QaComposer.js +9 -6
- package/lib/client/components/QaComposer.js.map +1 -1
- package/lib/client/components/QaFileAttachment.js +1 -1
- package/lib/client/components/QaFileAttachment.js.map +1 -1
- package/lib/client/components/QaFilesPanel.js +7 -7
- package/lib/client/components/QaFilesPanel.js.map +1 -1
- package/lib/client/components/QaHeader.js +4 -4
- package/lib/client/components/QaHeader.js.map +1 -1
- package/lib/client/components/QaMessage.js +12 -12
- package/lib/client/components/QaMessage.js.map +1 -1
- package/lib/client/components/QaModal.js +11 -2
- package/lib/client/components/QaModal.js.map +1 -1
- package/lib/client/components/QaQuestions.js +4 -4
- package/lib/client/components/QaQuestions.js.map +1 -1
- package/lib/client/components/QaQueueDock.js +137 -0
- package/lib/client/components/QaQueueDock.js.map +1 -0
- package/lib/client/components/QaRequestQueue.js +22 -0
- package/lib/client/components/QaRequestQueue.js.map +1 -0
- package/lib/client/components/QaRightRail.js +2 -2
- package/lib/client/components/QaRightRail.js.map +1 -1
- package/lib/client/components/QaSidebar.js +66 -6
- package/lib/client/components/QaSidebar.js.map +1 -1
- package/lib/client/components/QaSourcesPanel.js +10 -8
- package/lib/client/components/QaSourcesPanel.js.map +1 -1
- package/lib/client/components/QaThemeSwitcher.js +19 -0
- package/lib/client/components/QaThemeSwitcher.js.map +1 -0
- package/lib/client/components/QaTurnNotice.js +40 -0
- package/lib/client/components/QaTurnNotice.js.map +1 -0
- package/lib/client/components/QaTurnRail.js +3 -3
- package/lib/client/components/QaTurnRail.js.map +1 -1
- package/lib/client/components/QaWelcomeNotice.js +2 -6
- package/lib/client/components/QaWelcomeNotice.js.map +1 -1
- package/lib/client/components/QaWidthHandle.js +2 -2
- package/lib/client/components/QaWidthHandle.js.map +1 -1
- package/lib/client/components/QaWorkGroup.js +5 -5
- package/lib/client/components/QaWorkGroup.js.map +1 -1
- package/lib/client/components/QaWorkspaceBrowser.js +10 -10
- package/lib/client/components/QaWorkspaceBrowser.js.map +1 -1
- package/lib/client/components/VariantSwitcher.js +1 -1
- package/lib/client/components/VariantSwitcher.js.map +1 -1
- package/lib/client/failure-copy.js +26 -8
- package/lib/client/failure-copy.js.map +1 -1
- package/lib/client/focus-ring.js +193 -0
- package/lib/client/focus-ring.js.map +1 -0
- package/lib/client/index.js +58 -24
- package/lib/client/index.js.map +1 -1
- package/lib/client/lineage.js +3 -16
- package/lib/client/lineage.js.map +1 -1
- package/lib/client/markdown/CodeBlock.js +2 -2
- package/lib/client/markdown/CodeBlock.js.map +1 -1
- package/lib/client/markdown/blocks.js +118 -31
- package/lib/client/markdown/blocks.js.map +1 -1
- package/lib/client/markdown/math.js +1 -1
- package/lib/client/markdown/math.js.map +1 -1
- package/lib/client/markdown/render.js +32 -12
- package/lib/client/markdown/render.js.map +1 -1
- package/lib/client/markdown/source-chip.js +3 -3
- package/lib/client/markdown/source-chip.js.map +1 -1
- package/lib/client/notifications/notification-dispatcher.js +90 -0
- package/lib/client/notifications/notification-dispatcher.js.map +1 -0
- package/lib/client/notifications/preferences.js +61 -0
- package/lib/client/notifications/preferences.js.map +1 -0
- package/lib/client/notifications/turn-completion-source.js +82 -0
- package/lib/client/notifications/turn-completion-source.js.map +1 -0
- package/lib/client/notifications/use-turn-notifications.js +108 -0
- package/lib/client/notifications/use-turn-notifications.js.map +1 -0
- package/lib/client/panels/PanelHost.js +5 -5
- package/lib/client/panels/PanelHost.js.map +1 -1
- package/lib/client/panels/PanelLauncher.js +2 -2
- package/lib/client/panels/PanelLauncher.js.map +1 -1
- package/lib/client/project-session-state.js +156 -4
- package/lib/client/project-session-state.js.map +1 -1
- package/lib/client/rating-harvest.js +138 -0
- package/lib/client/rating-harvest.js.map +1 -0
- package/lib/client/role/RoleSelector.js +9 -4
- package/lib/client/role/RoleSelector.js.map +1 -1
- package/lib/client/settings/card.js +58 -17
- package/lib/client/settings/card.js.map +1 -1
- package/lib/client/settings/fields.js +11 -10
- package/lib/client/settings/fields.js.map +1 -1
- package/lib/client/settings/format.js +0 -9
- package/lib/client/settings/format.js.map +1 -1
- package/lib/client/settings/sections/access.js +5 -5
- package/lib/client/settings/sections/access.js.map +1 -1
- package/lib/client/settings/sections/accounts.js +10 -10
- package/lib/client/settings/sections/accounts.js.map +1 -1
- package/lib/client/settings/sections/attachments.js +6 -6
- package/lib/client/settings/sections/attachments.js.map +1 -1
- package/lib/client/settings/sections/branding.js +7 -7
- package/lib/client/settings/sections/branding.js.map +1 -1
- package/lib/client/settings/sections/common.js +4 -2
- package/lib/client/settings/sections/common.js.map +1 -1
- package/lib/client/settings/sections/docs.js +4 -3
- package/lib/client/settings/sections/docs.js.map +1 -1
- package/lib/client/settings/sections/embedding.js +2 -2
- package/lib/client/settings/sections/embedding.js.map +1 -1
- package/lib/client/settings/sections/interface.js +12 -12
- package/lib/client/settings/sections/interface.js.map +1 -1
- package/lib/client/settings/sections/lockdown.js +9 -9
- package/lib/client/settings/sections/lockdown.js.map +1 -1
- package/lib/client/settings/sections/notes.js +12 -12
- package/lib/client/settings/sections/notes.js.map +1 -1
- package/lib/client/settings/sections/session.js +15 -12
- package/lib/client/settings/sections/session.js.map +1 -1
- package/lib/client/settings/sections/slash.js +9 -9
- package/lib/client/settings/sections/slash.js.map +1 -1
- package/lib/client/settings/sections/sources.js +26 -26
- package/lib/client/settings/sections/sources.js.map +1 -1
- package/lib/client/settings/sections/status.js +2 -2
- package/lib/client/settings/sections/status.js.map +1 -1
- package/lib/client/settings/styles.js +15 -9
- package/lib/client/settings/styles.js.map +1 -1
- package/lib/client/slash/SlashPalette.js +4 -4
- package/lib/client/slash/SlashPalette.js.map +1 -1
- package/lib/client/styles.js +139 -66
- package/lib/client/styles.js.map +1 -1
- package/lib/client/theme-preference.js +162 -0
- package/lib/client/theme-preference.js.map +1 -0
- package/lib/client/types.js +8 -0
- package/lib/client/types.js.map +1 -1
- package/lib/client/use-session-ui-state.js +7 -5
- package/lib/client/use-session-ui-state.js.map +1 -1
- package/lib/client/user-settings/GeneralSettingsPage.js +6 -6
- package/lib/client/user-settings/GeneralSettingsPage.js.map +1 -1
- package/lib/client/user-settings/IntegrationTokensPage.js +6 -6
- package/lib/client/user-settings/IntegrationTokensPage.js.map +1 -1
- package/lib/client/user-settings/NotificationSettingsPage.js +107 -0
- package/lib/client/user-settings/NotificationSettingsPage.js.map +1 -0
- package/lib/client/user-settings/PasswordSettingsPage.js +1 -1
- package/lib/client/user-settings/PasswordSettingsPage.js.map +1 -1
- package/lib/client/user-settings/ProfileSettingsPage.js +2 -2
- package/lib/client/user-settings/ProfileSettingsPage.js.map +1 -1
- package/lib/client/user-settings/SkillCatalog.js +3 -3
- package/lib/client/user-settings/SkillCatalog.js.map +1 -1
- package/lib/client/user-settings/SkillEditor.js +26 -8
- package/lib/client/user-settings/SkillEditor.js.map +1 -1
- package/lib/client/user-settings/SkillToolPicker.js +3 -3
- package/lib/client/user-settings/SkillToolPicker.js.map +1 -1
- package/lib/client/user-settings/SkillsSettingsPage.js +1 -1
- package/lib/client/user-settings/SkillsSettingsPage.js.map +1 -1
- package/lib/client/user-settings/StartersSettingsPage.js +6 -6
- package/lib/client/user-settings/StartersSettingsPage.js.map +1 -1
- package/lib/client/user-settings/UserSettingsDialog.js +7 -2
- package/lib/client/user-settings/UserSettingsDialog.js.map +1 -1
- package/lib/client/user-settings/copy.js +2 -0
- package/lib/client/user-settings/copy.js.map +1 -1
- package/lib/client/user-settings/draft.js +4 -1
- package/lib/client/user-settings/draft.js.map +1 -1
- package/lib/client/user-settings/fields.js +5 -5
- package/lib/client/user-settings/fields.js.map +1 -1
- package/lib/client.js +7483 -2075
- package/lib/client.js.map +1 -1
- package/lib/config-resolvers/defaults.js +7 -0
- package/lib/config-resolvers/defaults.js.map +1 -1
- package/lib/config-resolvers/lockdown.js +7 -0
- package/lib/config-resolvers/lockdown.js.map +1 -1
- package/lib/config-resolvers/notifications.js +15 -0
- package/lib/config-resolvers/notifications.js.map +1 -0
- package/lib/config-resolvers/session.js +11 -1
- package/lib/config-resolvers/session.js.map +1 -1
- package/lib/config.js +67 -18
- package/lib/config.js.map +1 -1
- package/lib/enforcement/tool-grants.js +6 -6
- package/lib/enforcement/tool-grants.js.map +1 -1
- package/lib/index.js +171 -39
- package/lib/index.js.map +1 -1
- package/lib/integration/answer.js +74 -8
- package/lib/integration/answer.js.map +1 -1
- package/lib/integration/expert-memory.js +39 -0
- package/lib/integration/expert-memory.js.map +1 -0
- package/lib/integration/host-runner.js +26 -14
- package/lib/integration/host-runner.js.map +1 -1
- package/lib/notifications.js +66 -0
- package/lib/notifications.js.map +1 -0
- package/lib/personal-skills/errors.js.map +1 -1
- package/lib/personal-skills/service.js +81 -7
- package/lib/personal-skills/service.js.map +1 -1
- package/lib/prompt-notes.js +2 -10
- package/lib/prompt-notes.js.map +1 -1
- package/lib/provenance/extractors.js +1 -1
- package/lib/provenance/extractors.js.map +1 -1
- package/lib/provenance/host-store.js +5 -1
- package/lib/provenance/host-store.js.map +1 -1
- package/lib/qa-tools/docs-tools.js +64 -11
- package/lib/qa-tools/docs-tools.js.map +1 -1
- package/lib/qa-tools/lifecycle.js +6 -3
- package/lib/qa-tools/lifecycle.js.map +1 -1
- package/lib/request-queue.js +32 -0
- package/lib/request-queue.js.map +1 -0
- package/lib/resolve-config.js +2 -0
- package/lib/resolve-config.js.map +1 -1
- package/lib/secure-session.js +7 -2
- package/lib/secure-session.js.map +1 -1
- package/lib/shared/settings.js +19 -2
- package/lib/shared/settings.js.map +1 -1
- package/lib/turn-failure-log.js +67 -0
- package/lib/turn-failure-log.js.map +1 -0
- package/lib/typert.host.js +1459 -647
- package/lib/typert.remote-client.d.ts +25 -9
- package/lib/typert.remote-client.js +1356 -636
- package/lib/types/access/capability-catalog.d.ts +20 -4
- package/lib/types/access/model.d.ts +23 -0
- package/lib/types/access/service.d.ts +2 -3
- package/lib/types/account-remotes.d.ts +8 -1
- package/lib/types/accounts/database.d.ts +13 -3
- package/lib/types/accounts/errors.d.ts +9 -1
- package/lib/types/accounts/file.d.ts +12 -0
- package/lib/types/accounts/store.d.ts +30 -2
- package/lib/types/admin/quality-store.d.ts +33 -0
- package/lib/types/admin/service.d.ts +119 -9
- package/lib/types/client/QaAccountsController.d.ts +37 -3
- package/lib/types/client/QaConfigController.d.ts +2 -2
- package/lib/types/client/QaSessionController.d.ts +199 -6
- package/lib/types/client/QaSurface.d.ts +6 -1
- package/lib/types/client/admin/pages/ExpertMemory.d.ts +19 -0
- package/lib/types/client/admin/routes.d.ts +3 -1
- package/lib/types/client/admin/shared.d.ts +20 -1
- package/lib/types/client/components/Markdown.d.ts +8 -1
- package/lib/types/client/components/QaHeader.d.ts +3 -1
- package/lib/types/client/components/QaModal.d.ts +7 -0
- package/lib/types/client/components/QaQueueDock.d.ts +27 -0
- package/lib/types/client/components/QaRequestQueue.d.ts +20 -0
- package/lib/types/client/components/QaThemeSwitcher.d.ts +14 -0
- package/lib/types/client/components/QaTurnNotice.d.ts +55 -0
- package/lib/types/client/focus-ring.d.ts +65 -0
- package/lib/types/client/lineage.d.ts +3 -13
- package/lib/types/client/markdown/blocks.d.ts +30 -2
- package/lib/types/client/markdown/render.d.ts +4 -1
- package/lib/types/client/notifications/notification-dispatcher.d.ts +68 -0
- package/lib/types/client/notifications/preferences.d.ts +44 -0
- package/lib/types/client/notifications/turn-completion-source.d.ts +114 -0
- package/lib/types/client/notifications/use-turn-notifications.d.ts +51 -0
- package/lib/types/client/project-session-state.d.ts +35 -1
- package/lib/types/client/rating-harvest.d.ts +49 -0
- package/lib/types/client/role/RoleSelector.d.ts +7 -1
- package/lib/types/client/settings/card.d.ts +36 -5
- package/lib/types/client/settings/fields.d.ts +24 -0
- package/lib/types/client/settings/format.d.ts +0 -5
- package/lib/types/client/settings/sections/common.d.ts +3 -1
- package/lib/types/client/settings/sections/session.d.ts +2 -1
- package/lib/types/client/settings/styles.d.ts +9 -4
- package/lib/types/client/settings-extensions/user-session.d.ts +1 -1
- package/lib/types/client/theme-preference.d.ts +69 -0
- package/lib/types/client/types.d.ts +36 -5
- package/lib/types/client/use-session-ui-state.d.ts +6 -4
- package/lib/types/client/user-settings/GeneralSettingsPage.d.ts +5 -5
- package/lib/types/client/user-settings/NotificationSettingsPage.d.ts +40 -0
- package/lib/types/client/user-settings/UserSettingsDialog.d.ts +12 -2
- package/lib/types/client/user-settings/draft.d.ts +4 -1
- package/lib/types/client/user-settings/fields.d.ts +7 -0
- package/lib/types/config-resolvers/notifications.d.ts +10 -0
- package/lib/types/config-resolvers/session.d.ts +6 -0
- package/lib/types/config.d.ts +20 -1
- package/lib/types/index.d.ts +70 -10
- package/lib/types/integration/answer.d.ts +27 -0
- package/lib/types/integration/expert-memory.d.ts +49 -0
- package/lib/types/notifications.d.ts +32 -0
- package/lib/types/personal-skills/errors.d.ts +1 -1
- package/lib/types/personal-skills/service.d.ts +6 -0
- package/lib/types/prompt-notes.d.ts +17 -4
- package/lib/types/qa-tools/docs-tools.d.ts +2 -2
- package/lib/types/request-queue.d.ts +32 -0
- package/lib/types/shared/settings.d.ts +19 -2
- package/lib/types/turn-failure-log.d.ts +18 -0
- package/lib/types/types.d.ts +200 -4
- package/package.json +64 -62
- package/scripts/attach-workspace-sessions.mjs +24 -5
- package/scripts/repair-session-events.mjs +25 -5
package/lib/admin/service.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { QaAccountsError } from "../accounts/store.js";
|
|
2
2
|
import { defaultCapabilityConfig, normalizeUserAccess, resolveSkillAccess, } from "../access/model.js";
|
|
3
|
+
import { expertMemoryReasonOf } from "../integration/expert-memory.js";
|
|
3
4
|
import { allows } from "./permissions.js";
|
|
4
5
|
import { aggregateQuality } from "./metrics.js";
|
|
5
6
|
import { paginate } from "./paging.js";
|
|
6
7
|
import { deriveQueue } from "./queue.js";
|
|
8
|
+
import { QA_FEEDBACK_RATINGS } from "./quality-store.js";
|
|
7
9
|
import { defaultAdminRedactor } from "./redaction.js";
|
|
8
10
|
import { projectTranscript, } from "./conversation-log.js";
|
|
9
11
|
/**
|
|
@@ -22,9 +24,36 @@ export const QA_ADMIN_PAGE_MAX = 100;
|
|
|
22
24
|
* says so; moving these counters onto a real index is the next iteration.
|
|
23
25
|
*/
|
|
24
26
|
export const QA_ADMIN_SCAN_LIMIT = 200;
|
|
25
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* How long one projected log may serve the list and metrics before it is read
|
|
29
|
+
* again — and only while the Harness still holds that conversation. A log no
|
|
30
|
+
* process holds cannot gain events, so re-reading it is pure cost: on a
|
|
31
|
+
* deployment with a durable store one read pays for a listing of every stored
|
|
32
|
+
* session and a replay of the whole log before it reaches the one conversation
|
|
33
|
+
* asked for. Reading the scan window again on every page load is what took the
|
|
34
|
+
* aggregate pages minutes; keeping what cannot have changed is what makes the
|
|
35
|
+
* next load free.
|
|
36
|
+
*/
|
|
26
37
|
const TRANSCRIPT_TTL_MS = 30_000;
|
|
27
|
-
|
|
38
|
+
/**
|
|
39
|
+
* How many projections the cache holds. It has to cover one scan window with
|
|
40
|
+
* room for a page of summaries: a cache narrower than the window evicts the
|
|
41
|
+
* conversations a pass has already read, and the next pass pays for them again.
|
|
42
|
+
*/
|
|
43
|
+
const TRANSCRIPT_CACHE_MAX = QA_ADMIN_SCAN_LIMIT + QA_ADMIN_PAGE_MAX;
|
|
44
|
+
/**
|
|
45
|
+
* What one harvest request may carry. The browser replays its ratings in
|
|
46
|
+
* batches far below this; the ceiling is what a caller that ignored them is
|
|
47
|
+
* refused outright, rather than half-applied.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_HARVEST_BATCH = 500;
|
|
50
|
+
/** One harvested identifier, or undefined when the entry cannot be filed. */
|
|
51
|
+
function harvestId(value) {
|
|
52
|
+
if (typeof value !== "string")
|
|
53
|
+
return undefined;
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
return trimmed === "" ? undefined : trimmed;
|
|
56
|
+
}
|
|
28
57
|
/**
|
|
29
58
|
* Every session one conversation owns: the chat itself and everything
|
|
30
59
|
* delegated from it, transitively. A subagent's session is part of the answer
|
|
@@ -87,9 +116,26 @@ function reviewStatusOf(reviews, conversationId) {
|
|
|
87
116
|
? "needs_followup"
|
|
88
117
|
: "reviewed";
|
|
89
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Whether a running pass has no caller left to answer. A caller that passed no
|
|
121
|
+
* cancellation cannot leave, so it keeps the pass alive.
|
|
122
|
+
*/
|
|
123
|
+
function nobodyWaits(waiting) {
|
|
124
|
+
for (const signal of waiting) {
|
|
125
|
+
if (signal === undefined || !signal.aborted)
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
function sameWindow(left, right) {
|
|
131
|
+
return (left.length === right.length &&
|
|
132
|
+
left.every((id, index) => id === right[index]));
|
|
133
|
+
}
|
|
90
134
|
export class QaAdminService {
|
|
91
135
|
options;
|
|
92
136
|
redactor;
|
|
137
|
+
/** The aggregate pass running now, shared by the callers waiting for it. */
|
|
138
|
+
scanning;
|
|
93
139
|
transcripts = new Map();
|
|
94
140
|
constructor(options) {
|
|
95
141
|
this.options = options;
|
|
@@ -176,30 +222,116 @@ export class QaAdminService {
|
|
|
176
222
|
})
|
|
177
223
|
.filter((row) => row !== undefined);
|
|
178
224
|
}
|
|
179
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* One conversation's projected log, held across calls.
|
|
227
|
+
*
|
|
228
|
+
* A log the Harness no longer holds is finished — nothing can append to it —
|
|
229
|
+
* so its projection stays valid until the cache evicts it or the conversation
|
|
230
|
+
* is deleted. Only a log this process is still writing is re-read once the TTL
|
|
231
|
+
* expires. A cache hit counts as a use: an aggregate pass walks the whole
|
|
232
|
+
* window, and evicting by read time would drop the conversations the pages ask
|
|
233
|
+
* for most often and then read them again.
|
|
234
|
+
*
|
|
235
|
+
* The one answer this can hold back is a conversation resumed and finished
|
|
236
|
+
* between two page loads: its counters lag until the log is read for another
|
|
237
|
+
* reason. The conversation itself is never affected — the detail view always
|
|
238
|
+
* reads the log — and what the aggregate pages show instead is the newest
|
|
239
|
+
* projection of every finished conversation, which is the cheaper lie.
|
|
240
|
+
*/
|
|
180
241
|
async transcript(conversationId, fresh = false) {
|
|
181
242
|
const cached = this.transcripts.get(conversationId);
|
|
182
243
|
const now = this.options.clock?.now() ?? Date.now();
|
|
183
|
-
if (
|
|
184
|
-
|
|
244
|
+
if (cached !== undefined) {
|
|
245
|
+
const stale = fresh ||
|
|
246
|
+
(this.options.sessionLog.live(conversationId) &&
|
|
247
|
+
now - cached.at >= TRANSCRIPT_TTL_MS);
|
|
248
|
+
if (!stale) {
|
|
249
|
+
this.retain(conversationId, cached);
|
|
250
|
+
return { ok: true, project: cached.project };
|
|
251
|
+
}
|
|
252
|
+
this.transcripts.delete(conversationId);
|
|
185
253
|
}
|
|
186
254
|
const result = await this.options.sessionLog.read(conversationId);
|
|
187
255
|
if (!result.ok) {
|
|
188
|
-
this.transcripts.delete(conversationId);
|
|
189
256
|
return { ok: false, reason: result.reason };
|
|
190
257
|
}
|
|
191
258
|
const project = projectTranscript(result.events, this.redactor);
|
|
192
|
-
this.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.transcripts.delete(oldest[0]);
|
|
197
|
-
}
|
|
259
|
+
this.retain(conversationId, {
|
|
260
|
+
at: this.options.clock?.now() ?? Date.now(),
|
|
261
|
+
project,
|
|
262
|
+
});
|
|
198
263
|
return { ok: true, project };
|
|
199
264
|
}
|
|
200
|
-
|
|
265
|
+
/** Insert or re-touch one projection, keeping the cache inside its budget. */
|
|
266
|
+
retain(conversationId, entry) {
|
|
267
|
+
this.transcripts.delete(conversationId);
|
|
268
|
+
this.transcripts.set(conversationId, entry);
|
|
269
|
+
while (this.transcripts.size > TRANSCRIPT_CACHE_MAX) {
|
|
270
|
+
const oldest = this.transcripts.keys().next();
|
|
271
|
+
if (oldest.done === true)
|
|
272
|
+
break;
|
|
273
|
+
this.transcripts.delete(oldest.value);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The aggregate rows of the newest scan window, read in one pass.
|
|
278
|
+
*
|
|
279
|
+
* The message counts and the tool failures come from the same projections, so
|
|
280
|
+
* one walk fills both. While a walk runs, a caller that wants the same window
|
|
281
|
+
* joins it rather than starting a second scan of the deployment's logs, and the
|
|
282
|
+
* walk stops once no caller waits for it: a scan nobody is reading is only ever
|
|
283
|
+
* a load on the store.
|
|
284
|
+
*/
|
|
285
|
+
async scanOf(rows, signal) {
|
|
286
|
+
signal?.throwIfAborted();
|
|
287
|
+
const window = [...rows]
|
|
288
|
+
.sort((left, right) => right.createdAt - left.createdAt)
|
|
289
|
+
.slice(0, QA_ADMIN_SCAN_LIMIT);
|
|
290
|
+
const ids = window.map((row) => row.conversationId);
|
|
291
|
+
const running = this.scanning;
|
|
292
|
+
if (running !== undefined &&
|
|
293
|
+
!nobodyWaits(running.waiting) &&
|
|
294
|
+
sameWindow(running.window, ids)) {
|
|
295
|
+
running.waiting.add(signal);
|
|
296
|
+
try {
|
|
297
|
+
return await running.promise;
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
running.waiting.delete(signal);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
201
303
|
const facts = [];
|
|
202
|
-
|
|
304
|
+
const failures = [];
|
|
305
|
+
const waiting = new Set([signal]);
|
|
306
|
+
const promise = this.walk(window, waiting, facts, failures);
|
|
307
|
+
const pass = { window: ids, promise, waiting };
|
|
308
|
+
this.scanning = pass;
|
|
309
|
+
void promise
|
|
310
|
+
.catch(() => undefined)
|
|
311
|
+
.then(() => {
|
|
312
|
+
if (this.scanning === pass)
|
|
313
|
+
this.scanning = undefined;
|
|
314
|
+
});
|
|
315
|
+
try {
|
|
316
|
+
return await promise;
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
waiting.delete(signal);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Walk one scan window, filling the counts and the failure signals as it goes.
|
|
324
|
+
*
|
|
325
|
+
* `waiting` is the caller set of the pass this walk belongs to: the walk gives
|
|
326
|
+
* up between two reads once every caller has left, so a reviewer who abandons a
|
|
327
|
+
* page that has not answered does not keep the deployment reading logs for an
|
|
328
|
+
* answer nobody will see.
|
|
329
|
+
*/
|
|
330
|
+
async walk(window, waiting, facts, failures) {
|
|
331
|
+
for (const row of window) {
|
|
332
|
+
if (nobodyWaits(waiting)) {
|
|
333
|
+
throw new Error("qa admin aggregate scan abandoned: no caller left");
|
|
334
|
+
}
|
|
203
335
|
const transcript = await this.transcript(row.conversationId);
|
|
204
336
|
facts.push({
|
|
205
337
|
conversationId: row.conversationId,
|
|
@@ -210,18 +342,21 @@ export class QaAdminService {
|
|
|
210
342
|
: 0,
|
|
211
343
|
createdAt: row.createdAt,
|
|
212
344
|
});
|
|
345
|
+
if (transcript.ok) {
|
|
346
|
+
failures.push(...this.failuresOf(row.conversationId, transcript.project));
|
|
347
|
+
}
|
|
213
348
|
}
|
|
214
|
-
return facts;
|
|
349
|
+
return { facts, failures };
|
|
215
350
|
}
|
|
216
351
|
// -------------------------------------------------------------------------
|
|
217
352
|
// Overview and metrics
|
|
218
353
|
// -------------------------------------------------------------------------
|
|
219
|
-
async overview(token) {
|
|
354
|
+
async overview(token, signal) {
|
|
220
355
|
this.require(token, "conversations.read.all");
|
|
221
356
|
const rows = await this.index();
|
|
222
|
-
const metrics = await this.metricsOf(rows);
|
|
357
|
+
const metrics = await this.metricsOf(rows, signal);
|
|
223
358
|
const fresh = [...this.options.quality().allFeedback()].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
224
|
-
const queue = await this.queueRows(rows);
|
|
359
|
+
const queue = await this.queueRows(rows, signal);
|
|
225
360
|
return Object.freeze({
|
|
226
361
|
metrics,
|
|
227
362
|
alerts: this.alertsOf(metrics),
|
|
@@ -229,19 +364,16 @@ export class QaAdminService {
|
|
|
229
364
|
queue: queue.slice(0, 5),
|
|
230
365
|
});
|
|
231
366
|
}
|
|
232
|
-
async metrics(token) {
|
|
367
|
+
async metrics(token, signal) {
|
|
233
368
|
this.require(token, "analytics.read");
|
|
234
|
-
return this.metricsOf(await this.index());
|
|
369
|
+
return this.metricsOf(await this.index(), signal);
|
|
235
370
|
}
|
|
236
|
-
async metricsOf(rows) {
|
|
371
|
+
async metricsOf(rows, signal) {
|
|
237
372
|
const quality = this.options.quality();
|
|
238
373
|
// Feedback and reviews are exact; the message counts come from the bounded
|
|
239
374
|
// 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
375
|
return aggregateQuality({
|
|
244
|
-
conversations: await this.
|
|
376
|
+
conversations: (await this.scanOf(rows, signal)).facts,
|
|
245
377
|
feedback: quality.allFeedback(),
|
|
246
378
|
reviews: quality.allReviews(),
|
|
247
379
|
});
|
|
@@ -534,8 +666,9 @@ export class QaAdminService {
|
|
|
534
666
|
// -------------------------------------------------------------------------
|
|
535
667
|
// Conversations
|
|
536
668
|
// -------------------------------------------------------------------------
|
|
537
|
-
async conversations(token, query, cursor, limit) {
|
|
669
|
+
async conversations(token, query, cursor, limit, signal) {
|
|
538
670
|
this.require(token, "conversations.read.all");
|
|
671
|
+
signal?.throwIfAborted();
|
|
539
672
|
const rows = await this.index();
|
|
540
673
|
const quality = this.options.quality();
|
|
541
674
|
const feedback = quality.allFeedback();
|
|
@@ -545,17 +678,23 @@ export class QaAdminService {
|
|
|
545
678
|
const ratingFilter = given(query.rating);
|
|
546
679
|
const reviewFilter = given(query.reviewStatus);
|
|
547
680
|
const search = given(query.search) ?? "";
|
|
681
|
+
const searching = search.trim() !== "";
|
|
548
682
|
const from = epochOf(given(query.from) ?? undefined);
|
|
549
683
|
const to = epochOf(given(query.to) ?? undefined);
|
|
550
684
|
// 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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
685
|
+
// set is bounded by the newest logs before the text is available. Without
|
|
686
|
+
// search text no filter here needs a log at all — and reading one per
|
|
687
|
+
// candidate to answer a page of twenty-five rows made this call cost every
|
|
688
|
+
// conversation the deployment stores, each read paying for a listing of all
|
|
689
|
+
// of them before it reaches the one log asked for.
|
|
690
|
+
const searchable = searching
|
|
691
|
+
? [...rows]
|
|
555
692
|
.sort((left, right) => right.createdAt - left.createdAt)
|
|
556
|
-
.slice(0, QA_ADMIN_SCAN_LIMIT)
|
|
693
|
+
.slice(0, QA_ADMIN_SCAN_LIMIT)
|
|
694
|
+
: rows;
|
|
557
695
|
const candidates = [];
|
|
558
696
|
for (const row of searchable) {
|
|
697
|
+
signal?.throwIfAborted();
|
|
559
698
|
if (userIdFilter !== undefined && row.userId !== userIdFilter)
|
|
560
699
|
continue;
|
|
561
700
|
if (subroleFilter !== undefined && row.subroleId !== subroleFilter) {
|
|
@@ -574,10 +713,13 @@ export class QaAdminService {
|
|
|
574
713
|
reviewStatusOf(reviews, row.conversationId) !== reviewFilter) {
|
|
575
714
|
continue;
|
|
576
715
|
}
|
|
577
|
-
//
|
|
578
|
-
// the summary of
|
|
579
|
-
|
|
580
|
-
|
|
716
|
+
// Only a search reads a candidate's log, and the projection stays cached
|
|
717
|
+
// for the summary of the rows that survive the filter.
|
|
718
|
+
let title;
|
|
719
|
+
if (searching) {
|
|
720
|
+
const transcript = await this.transcript(row.conversationId);
|
|
721
|
+
title = transcript.ok ? transcript.project.title : undefined;
|
|
722
|
+
}
|
|
581
723
|
if (!matches(search, row.displayName, row.email, row.userId, row.conversationId, title)) {
|
|
582
724
|
continue;
|
|
583
725
|
}
|
|
@@ -593,6 +735,7 @@ export class QaAdminService {
|
|
|
593
735
|
});
|
|
594
736
|
const items = [];
|
|
595
737
|
for (const row of page.items) {
|
|
738
|
+
signal?.throwIfAborted();
|
|
596
739
|
items.push(await this.summaryOf(row, feedback, reviews));
|
|
597
740
|
}
|
|
598
741
|
return {
|
|
@@ -741,7 +884,10 @@ export class QaAdminService {
|
|
|
741
884
|
const qualityRows = this.options.quality().dropConversations(targets);
|
|
742
885
|
for (const sessionId of targets)
|
|
743
886
|
this.options.dropSources?.(sessionId);
|
|
744
|
-
|
|
887
|
+
// A removed log is never read again, so its projection must not survive to
|
|
888
|
+
// answer a later page: the cache holds it as if the conversation existed.
|
|
889
|
+
for (const sessionId of targets)
|
|
890
|
+
this.transcripts.delete(sessionId);
|
|
745
891
|
this.options.quality().appendAudit({
|
|
746
892
|
actorId: actor.id,
|
|
747
893
|
action: "conversation.deleted",
|
|
@@ -783,16 +929,6 @@ export class QaAdminService {
|
|
|
783
929
|
}
|
|
784
930
|
return signals;
|
|
785
931
|
}
|
|
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
932
|
// -------------------------------------------------------------------------
|
|
797
933
|
// Feedback
|
|
798
934
|
// -------------------------------------------------------------------------
|
|
@@ -817,6 +953,74 @@ export class QaAdminService {
|
|
|
817
953
|
});
|
|
818
954
|
return record;
|
|
819
955
|
}
|
|
956
|
+
/**
|
|
957
|
+
* Replay the ratings a browser still holds from the window in which a thumbs
|
|
958
|
+
* never reached the Host.
|
|
959
|
+
*
|
|
960
|
+
* A harvest never overwrites. The browser's map stores no timestamp, so an
|
|
961
|
+
* entry may well be a rating the same user has since filed together with a
|
|
962
|
+
* reason and a comment; replacing that record with the bare thumbs would
|
|
963
|
+
* delete exactly what a reviewer reads. An entry the Host already answers for
|
|
964
|
+
* is counted as present and left alone.
|
|
965
|
+
*
|
|
966
|
+
* One unusable or foreign entry costs that entry alone: a browser keeps
|
|
967
|
+
* ratings of chats it no longer owns, and the rest of the replay must not be
|
|
968
|
+
* lost because of them.
|
|
969
|
+
*/
|
|
970
|
+
harvestFeedback(token, entries) {
|
|
971
|
+
const { accounts, actor } = this.require(token, "conversations.read.own");
|
|
972
|
+
if (!Array.isArray(entries)) {
|
|
973
|
+
throw new TypeError("entries must be an array");
|
|
974
|
+
}
|
|
975
|
+
if (entries.length > MAX_HARVEST_BATCH) {
|
|
976
|
+
throw new QaAccountsError("auth-required", "harvest batch is too large");
|
|
977
|
+
}
|
|
978
|
+
const quality = this.options.quality();
|
|
979
|
+
let recorded = 0;
|
|
980
|
+
let present = 0;
|
|
981
|
+
let rejected = 0;
|
|
982
|
+
for (const entry of entries) {
|
|
983
|
+
const conversationId = harvestId(entry?.conversationId);
|
|
984
|
+
const messageId = harvestId(entry?.messageId);
|
|
985
|
+
const rating = entry?.rating;
|
|
986
|
+
if (conversationId === undefined ||
|
|
987
|
+
messageId === undefined ||
|
|
988
|
+
!QA_FEEDBACK_RATINGS.includes(rating)) {
|
|
989
|
+
rejected += 1;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
if (accounts.ownerIdOf(conversationId) !== actor.id) {
|
|
993
|
+
rejected += 1;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (quality.feedbackOf(conversationId, messageId, actor.id) !== undefined) {
|
|
997
|
+
present += 1;
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
quality.rateFeedback({ conversationId, messageId, userId: actor.id }, { rating });
|
|
1002
|
+
recorded += 1;
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
// The store re-validates what it is given, and one row it refuses is
|
|
1006
|
+
// that row's problem rather than the batch's.
|
|
1007
|
+
this.options.logger.warn("admin.feedback-harvest-entry-refused", {
|
|
1008
|
+
conversationId,
|
|
1009
|
+
messageId,
|
|
1010
|
+
userId: actor.id,
|
|
1011
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1012
|
+
});
|
|
1013
|
+
rejected += 1;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
this.options.logger.info("admin.feedback-harvested", {
|
|
1017
|
+
userId: actor.id,
|
|
1018
|
+
recorded,
|
|
1019
|
+
present,
|
|
1020
|
+
rejected,
|
|
1021
|
+
});
|
|
1022
|
+
return { recorded, present, rejected };
|
|
1023
|
+
}
|
|
820
1024
|
async feedback(token, query, cursor, limit) {
|
|
821
1025
|
this.require(token, "reviews.read");
|
|
822
1026
|
const rows = await this.index();
|
|
@@ -893,7 +1097,7 @@ export class QaAdminService {
|
|
|
893
1097
|
// -------------------------------------------------------------------------
|
|
894
1098
|
// Review queue and results
|
|
895
1099
|
// -------------------------------------------------------------------------
|
|
896
|
-
async queueRows(rows) {
|
|
1100
|
+
async queueRows(rows, signal) {
|
|
897
1101
|
const conversations = rows ?? (await this.index());
|
|
898
1102
|
const quality = this.options.quality();
|
|
899
1103
|
const reviews = quality.allReviews();
|
|
@@ -902,12 +1106,11 @@ export class QaAdminService {
|
|
|
902
1106
|
feedback: quality.allFeedback(),
|
|
903
1107
|
reviews,
|
|
904
1108
|
manual: quality.manualQueue(),
|
|
905
|
-
failures: await this.
|
|
906
|
-
.sort((left, right) => right.createdAt - left.createdAt)
|
|
907
|
-
.slice(0, QA_ADMIN_SCAN_LIMIT)),
|
|
1109
|
+
failures: (await this.scanOf(conversations, signal)).failures,
|
|
908
1110
|
});
|
|
909
1111
|
const result = [];
|
|
910
1112
|
for (const item of queue) {
|
|
1113
|
+
signal?.throwIfAborted();
|
|
911
1114
|
const row = byId.get(item.conversationId);
|
|
912
1115
|
if (row === undefined)
|
|
913
1116
|
continue;
|
|
@@ -931,9 +1134,9 @@ export class QaAdminService {
|
|
|
931
1134
|
}
|
|
932
1135
|
return Object.freeze(result);
|
|
933
1136
|
}
|
|
934
|
-
async reviewQueue(token, cursor, limit) {
|
|
1137
|
+
async reviewQueue(token, cursor, limit, signal) {
|
|
935
1138
|
this.require(token, "reviews.read");
|
|
936
|
-
const rows = await this.queueRows();
|
|
1139
|
+
const rows = await this.queueRows(await this.index(), signal);
|
|
937
1140
|
return paginate({
|
|
938
1141
|
rows,
|
|
939
1142
|
keyOf: (row) => `${row.priority}:${row.raisedAt}`,
|
|
@@ -1134,6 +1337,183 @@ export class QaAdminService {
|
|
|
1134
1337
|
return this.options.skills().get(target.scope, name);
|
|
1135
1338
|
}
|
|
1136
1339
|
// -------------------------------------------------------------------------
|
|
1340
|
+
// Expert memory
|
|
1341
|
+
// -------------------------------------------------------------------------
|
|
1342
|
+
/**
|
|
1343
|
+
* The domain-experts maintenance seam, or the refusal that says this stand
|
|
1344
|
+
* runs no experts.
|
|
1345
|
+
*
|
|
1346
|
+
* Reach is host-plane on purpose: the sibling plugin's remotes carry no token
|
|
1347
|
+
* and check no permission, because inside the Harness their caller is already
|
|
1348
|
+
* a trusted component. This console answers a browser on a LAN port, so it
|
|
1349
|
+
* calls the service directly and gates every call with its own `require`.
|
|
1350
|
+
*/
|
|
1351
|
+
expertMemory() {
|
|
1352
|
+
const admin = this.options.domainExperts?.()?.memoryAdmin;
|
|
1353
|
+
if (admin === undefined) {
|
|
1354
|
+
throw new QaAccountsError("memory-unavailable", "no domain-experts plugin is composed on this deployment");
|
|
1355
|
+
}
|
|
1356
|
+
return admin;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Run one memory call, translating the sibling plugin's refusal.
|
|
1360
|
+
*
|
|
1361
|
+
* The reason crosses the wire, never the message: a memory refusal names
|
|
1362
|
+
* namespaces, and a storage failure names the database file behind them.
|
|
1363
|
+
*/
|
|
1364
|
+
async memoryCall(action, operation) {
|
|
1365
|
+
const admin = this.expertMemory();
|
|
1366
|
+
try {
|
|
1367
|
+
return await operation(admin);
|
|
1368
|
+
}
|
|
1369
|
+
catch (error) {
|
|
1370
|
+
if (error instanceof QaAccountsError)
|
|
1371
|
+
throw error;
|
|
1372
|
+
const reason = expertMemoryReasonOf(error);
|
|
1373
|
+
this.options.logger.warn("admin.memory-refused", {
|
|
1374
|
+
action,
|
|
1375
|
+
reason: reason ?? "unknown",
|
|
1376
|
+
});
|
|
1377
|
+
if (reason === undefined)
|
|
1378
|
+
throw error;
|
|
1379
|
+
throw new QaAccountsError(reason, `expert memory refused the ${action}`);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* What an audit row keeps about one record.
|
|
1384
|
+
*
|
|
1385
|
+
* A remembered line runs to a few pages, and the trail holds a capped JSON
|
|
1386
|
+
* snapshot; the identity of the record and the head of its text is what an
|
|
1387
|
+
* operator reads back to decide whether the deletion was wrong.
|
|
1388
|
+
*/
|
|
1389
|
+
memoryImage(record) {
|
|
1390
|
+
return {
|
|
1391
|
+
namespace: record.namespace,
|
|
1392
|
+
key: record.key,
|
|
1393
|
+
tags: record.tags,
|
|
1394
|
+
updatedAt: record.updatedAt,
|
|
1395
|
+
text: record.text.slice(0, 200),
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/** The record one key holds now, if it still holds one. */
|
|
1399
|
+
async rememberedRecord(admin, namespace, key) {
|
|
1400
|
+
const page = await admin.search(namespace, key, QA_ADMIN_PAGE_MAX, 0);
|
|
1401
|
+
return page.records.find((record) => record.key === key);
|
|
1402
|
+
}
|
|
1403
|
+
/** Every expert memory namespace on the stand, with its owner and size. */
|
|
1404
|
+
async memoryScopes(token) {
|
|
1405
|
+
this.require(token, "memory.read");
|
|
1406
|
+
return await this.memoryCall("scopes", (admin) => admin.scopes());
|
|
1407
|
+
}
|
|
1408
|
+
/** One page of one namespace, newest first, with the filter's match count. */
|
|
1409
|
+
async memoryRecords(token, namespace, query, limit, offset) {
|
|
1410
|
+
this.require(token, "memory.read");
|
|
1411
|
+
const size = clampLimit(limit === null ? undefined : limit);
|
|
1412
|
+
const from = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
|
|
1413
|
+
const page = await this.memoryCall("search", (admin) => admin.search(namespace, query, size, from));
|
|
1414
|
+
return {
|
|
1415
|
+
records: page.records,
|
|
1416
|
+
total: page.total,
|
|
1417
|
+
offset: from,
|
|
1418
|
+
limit: size,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Rewrite one remembered line.
|
|
1423
|
+
*
|
|
1424
|
+
* The key is not editable: it is what the expert's own notes and the audit
|
|
1425
|
+
* trail point at, and a console that renamed it would orphan both. The write
|
|
1426
|
+
* refuses a record that vanished meanwhile rather than re-creating it — an
|
|
1427
|
+
* operator editing a stale row should reload, not resurrect.
|
|
1428
|
+
*/
|
|
1429
|
+
async correctMemory(token, namespace, key, draft) {
|
|
1430
|
+
const { actor } = this.require(token, "memory.manage");
|
|
1431
|
+
const before = await this.memoryCall("read-before", (admin) => this.rememberedRecord(admin, namespace, key));
|
|
1432
|
+
const record = await this.memoryCall("correct", (admin) => admin.correct({ namespace, key, text: draft.text, tags: draft.tags }));
|
|
1433
|
+
this.options.quality().appendAudit({
|
|
1434
|
+
actorId: actor.id,
|
|
1435
|
+
action: "memory.corrected",
|
|
1436
|
+
targetType: "expert-memory",
|
|
1437
|
+
targetId: `${namespace}/${key}`,
|
|
1438
|
+
...(before === undefined ? {} : { before: this.memoryImage(before) }),
|
|
1439
|
+
after: this.memoryImage(record),
|
|
1440
|
+
});
|
|
1441
|
+
this.options.logger.info("admin.memory-corrected", {
|
|
1442
|
+
actor: actor.id,
|
|
1443
|
+
namespace,
|
|
1444
|
+
key,
|
|
1445
|
+
});
|
|
1446
|
+
return record;
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Drop one or several remembered lines.
|
|
1450
|
+
*
|
|
1451
|
+
* A single deletion keeps the text it removed; a bulk one keeps only the
|
|
1452
|
+
* count and the keys, because an audit trail that copies every page of memory
|
|
1453
|
+
* it deletes becomes the thing an operator has to clean up.
|
|
1454
|
+
*/
|
|
1455
|
+
async forgetMemory(token, namespace, keys) {
|
|
1456
|
+
const { actor } = this.require(token, "memory.manage");
|
|
1457
|
+
const wanted = [
|
|
1458
|
+
...new Set(keys.map((key) => key.trim()).filter((key) => key !== "")),
|
|
1459
|
+
];
|
|
1460
|
+
if (wanted.length === 0) {
|
|
1461
|
+
throw new QaAccountsError("invalid-memory", "a memory deletion needs at least one record key");
|
|
1462
|
+
}
|
|
1463
|
+
const sole = wanted.length === 1 ? wanted[0] : undefined;
|
|
1464
|
+
const before = sole === undefined
|
|
1465
|
+
? undefined
|
|
1466
|
+
: await this.memoryCall("read-before", (admin) => this.rememberedRecord(admin, namespace, sole));
|
|
1467
|
+
const removed = await this.memoryCall("forget", async (admin) => sole === undefined
|
|
1468
|
+
? await admin.removeMany(namespace, wanted)
|
|
1469
|
+
: (await admin.remove(namespace, sole))
|
|
1470
|
+
? 1
|
|
1471
|
+
: 0);
|
|
1472
|
+
this.options.quality().appendAudit({
|
|
1473
|
+
actorId: actor.id,
|
|
1474
|
+
action: "memory.deleted",
|
|
1475
|
+
targetType: "expert-memory",
|
|
1476
|
+
targetId: namespace,
|
|
1477
|
+
before: before === undefined
|
|
1478
|
+
? { keys: wanted, count: wanted.length }
|
|
1479
|
+
: this.memoryImage(before),
|
|
1480
|
+
});
|
|
1481
|
+
this.options.logger.info("admin.memory-deleted", {
|
|
1482
|
+
actor: actor.id,
|
|
1483
|
+
namespace,
|
|
1484
|
+
named: wanted.length,
|
|
1485
|
+
removed,
|
|
1486
|
+
});
|
|
1487
|
+
return removed;
|
|
1488
|
+
}
|
|
1489
|
+
/** Empty one expert's private namespace. */
|
|
1490
|
+
async wipeMemory(token, namespace, expectedRecords) {
|
|
1491
|
+
const { actor } = this.require(token, "memory.manage");
|
|
1492
|
+
const page = await this.memoryCall("read-before", (admin) => admin.search(namespace, "", 1, 0));
|
|
1493
|
+
if (expectedRecords !== null &&
|
|
1494
|
+
Number.isFinite(expectedRecords) &&
|
|
1495
|
+
expectedRecords >= 0 &&
|
|
1496
|
+
page.total !== expectedRecords) {
|
|
1497
|
+
// The console asks for the count it showed. Memory an expert wrote while
|
|
1498
|
+
// the operator was deciding is not memory the operator agreed to erase.
|
|
1499
|
+
throw new QaAccountsError("memory-record-unknown", "the namespace holds a different number of records than the one confirmed");
|
|
1500
|
+
}
|
|
1501
|
+
const cleared = await this.memoryCall("wipe", (current) => current.wipe(namespace));
|
|
1502
|
+
this.options.quality().appendAudit({
|
|
1503
|
+
actorId: actor.id,
|
|
1504
|
+
action: "memory.wiped",
|
|
1505
|
+
targetType: "expert-memory",
|
|
1506
|
+
targetId: namespace,
|
|
1507
|
+
after: { cleared },
|
|
1508
|
+
});
|
|
1509
|
+
this.options.logger.info("admin.memory-wiped", {
|
|
1510
|
+
actor: actor.id,
|
|
1511
|
+
namespace,
|
|
1512
|
+
cleared,
|
|
1513
|
+
});
|
|
1514
|
+
return cleared;
|
|
1515
|
+
}
|
|
1516
|
+
// -------------------------------------------------------------------------
|
|
1137
1517
|
// Audit
|
|
1138
1518
|
// -------------------------------------------------------------------------
|
|
1139
1519
|
/**
|