From 02c2c92342f67980c6b92c29574a5b944e91bf68 Mon Sep 17 00:00:00 2001 From: THOTH Date: Thu, 12 Nov 2026 20:06:32 +0900 Subject: [PATCH] Format sources, add security and accessibility lint, prepare v0.2.0-beta.4 --- .github/workflows/product-check.yml | 3 + Makefile.ps1 | 1 + SOURCE_MANIFEST.json | 2527 +++++----- apps/web/.prettierignore | 10 + apps/web/.prettierrc.json | 3 + apps/web/eslint.config.js | 8 +- apps/web/package.json | 82 +- apps/web/preview-timeline.html | 13 - apps/web/src/App.test.tsx | 8 +- apps/web/src/App.tsx | 26 +- apps/web/src/api/actionDraft.ts | 20 +- apps/web/src/api/capabilities.ts | 90 +- apps/web/src/api/contracts.test.ts | 200 +- apps/web/src/api/conversation.test.ts | 202 +- apps/web/src/api/conversation.ts | 221 +- .../api/conversationRefetchDiagnosis.test.ts | 125 +- apps/web/src/api/expectedResultContract.ts | 4 +- apps/web/src/api/expectedResultState.test.ts | 10 +- apps/web/src/api/expectedResultState.ts | 9 +- apps/web/src/api/files.test.ts | 163 +- apps/web/src/api/files.ts | 32 +- apps/web/src/api/forgetProject.test.ts | 13 +- apps/web/src/api/forgetProject.ts | 24 +- apps/web/src/api/groundingNegotiation.test.ts | 43 +- apps/web/src/api/history.test.ts | 98 +- apps/web/src/api/history.ts | 270 +- apps/web/src/api/historyComparison.ts | 78 +- apps/web/src/api/historyHttp.test.ts | 107 +- apps/web/src/api/historyModels.ts | 49 +- apps/web/src/api/historySchemas.ts | 105 +- .../web/src/api/historyServerContract.test.ts | 99 +- apps/web/src/api/historyTestFixture.ts | 387 +- apps/web/src/api/hypothesisLink.ts | 73 +- apps/web/src/api/hypothesisSame.ts | 47 +- apps/web/src/api/judgmentRecords.ts | 125 +- apps/web/src/api/koreanParticles.test.ts | 35 +- apps/web/src/api/koreanParticles.ts | 4 +- apps/web/src/api/lessons.ts | 35 +- apps/web/src/api/localRetrieval.ts | 207 +- .../web/src/api/localRetrievalInstall.test.ts | 9 +- .../src/api/localWorkspacePersistence.test.ts | 140 +- apps/web/src/api/localWorkspacePersistence.ts | 389 +- apps/web/src/api/parserError.test.ts | 53 +- apps/web/src/api/parserError.ts | 14 +- apps/web/src/api/presentation.ts | 23 +- .../api/progressEvidenceLocations.test.tsx | 176 +- apps/web/src/api/progressEvidenceLocations.ts | 43 +- apps/web/src/api/projectActionFailure.ts | 13 +- apps/web/src/api/projectCreation.ts | 7 +- apps/web/src/api/projectDeletion.ts | 6 +- apps/web/src/api/protocol.ts | 23 +- apps/web/src/api/publicWeb.test.ts | 8 +- apps/web/src/api/readAccess.ts | 17 +- apps/web/src/api/recordViews.ts | 259 +- apps/web/src/api/requestLanguage.test.ts | 36 +- apps/web/src/api/research.ts | 321 +- apps/web/src/api/researchFollowup.ts | 138 +- apps/web/src/api/researchProgress.test.tsx | 54 +- apps/web/src/api/researchProgress.ts | 53 +- .../researchProgressCharacterization.test.ts | 288 +- apps/web/src/api/researchProgressText.ts | 74 +- apps/web/src/api/researchStatusRead.test.ts | 54 +- apps/web/src/api/researchStatusRead.ts | 33 +- apps/web/src/api/responseLanguageDefault.ts | 46 +- apps/web/src/api/responseLanguages.test.ts | 69 +- apps/web/src/api/responseLanguages.ts | 4 +- apps/web/src/api/restore.test.ts | 70 +- apps/web/src/api/restore.ts | 228 +- apps/web/src/api/restorePending.test.ts | 32 +- apps/web/src/api/restorePending.ts | 72 +- apps/web/src/api/resultEvidence.test.ts | 161 +- apps/web/src/api/resultEvidence.ts | 276 +- .../src/api/resultEvidenceIdentity.test.ts | 72 +- apps/web/src/api/resultEvidenceIdentity.ts | 30 +- .../web/src/api/resultEvidenceRuntime.test.ts | 148 +- .../api/resultEvidenceServerContract.test.ts | 52 +- apps/web/src/api/resultEvidenceTestFixture.ts | 90 +- apps/web/src/api/rpcClient.test.ts | 92 +- apps/web/src/api/rpcClient.ts | 131 +- apps/web/src/api/rpcTransport.test.ts | 52 +- apps/web/src/api/sourceBindings.test.ts | 45 +- apps/web/src/api/sourceBindings.ts | 17 +- apps/web/src/api/sourcePolicy.ts | 96 +- apps/web/src/api/sourceTime.ts | 43 +- .../web/src/api/submissionPersistence.test.ts | 34 +- apps/web/src/api/submissionPersistence.ts | 32 +- apps/web/src/api/threadName.ts | 4 +- apps/web/src/api/timelineExample.ts | 91 +- apps/web/src/api/trace.ts | 195 +- apps/web/src/api/uploadTypes.ts | 33 +- .../web/src/components/AbsenceChecks.test.tsx | 73 +- apps/web/src/components/AbsenceChecks.tsx | 81 +- .../web/src/components/ActionCompare.test.tsx | 59 +- apps/web/src/components/ActionCompare.tsx | 267 +- .../components/ActionCompareApproval.test.tsx | 80 +- apps/web/src/components/ActionCompareLive.tsx | 17 +- .../components/ActionCompareStale.test.tsx | 31 +- apps/web/src/components/ActionEffort.test.tsx | 128 +- apps/web/src/components/ActionEffort.tsx | 257 +- apps/web/src/components/AnswerBody.tsx | 66 +- .../src/components/AnswerCitations.test.tsx | 103 +- apps/web/src/components/AnswerHold.tsx | 36 +- .../src/components/AnswerReadability.test.tsx | 142 +- apps/web/src/components/ApprovalCard.test.tsx | 287 +- apps/web/src/components/ApprovalCard.tsx | 165 +- apps/web/src/components/ApprovalSection.tsx | 99 +- apps/web/src/components/ArchivedProjects.tsx | 164 +- .../AsyncDraftAndCitationRegression.test.tsx | 257 +- .../src/components/AutoRetrySwitch.test.tsx | 83 +- apps/web/src/components/AutoRetrySwitch.tsx | 48 +- .../components/CapabilityWorkbench.test.tsx | 212 +- .../src/components/CapabilityWorkbench.tsx | 339 +- .../components/CheckedObservations.test.tsx | 55 +- .../src/components/CheckedObservations.tsx | 122 +- apps/web/src/components/ClaudeCodeHelp.tsx | 12 +- apps/web/src/components/ClosureMark.test.tsx | 77 +- apps/web/src/components/ClosureMark.tsx | 75 +- apps/web/src/components/ClosureRecorder.tsx | 186 +- apps/web/src/components/CodexToolHelp.tsx | 12 +- apps/web/src/components/ConfirmDialog.tsx | 12 +- .../src/components/ConfirmedFacts.test.tsx | 51 +- apps/web/src/components/ConfirmedFacts.tsx | 74 +- .../ConnectFailureInWorkspace.test.tsx | 212 +- .../src/components/ConnectedSourceCard.tsx | 48 +- .../src/components/ContractV3Screens.test.tsx | 144 +- .../ConversationEarlierRound.test.tsx | 95 +- .../ConversationRunningTurn.test.tsx | 130 +- .../src/components/ConversationTimeline.tsx | 399 +- .../ConversationTimelineAutoRetry.test.tsx | 127 +- .../ConversationTimelineLegacyEmpty.test.tsx | 36 +- .../ConversationTimelineRefetch.test.tsx | 296 +- .../ConversationTimelineSynthetic.test.tsx | 75 +- .../components/ConversationTurnView.test.tsx | 181 +- .../src/components/ConversationTurnView.tsx | 114 +- apps/web/src/components/Disclosure.tsx | 27 +- .../components/DiscriminationRecords.test.tsx | 149 +- apps/web/src/components/DomainRecords.tsx | 179 +- .../components/EffectDeclarationFields.tsx | 58 +- apps/web/src/components/EvidenceRail.test.tsx | 46 +- apps/web/src/components/EvidenceRail.tsx | 56 +- .../web/src/components/EvidenceUseSummary.tsx | 39 +- .../components/ExecutionHoldNotice.test.tsx | 207 +- .../src/components/ExecutionHoldNotice.tsx | 190 +- apps/web/src/components/ExpectedResults.tsx | 109 +- .../components/FileConnectionPanel.test.tsx | 333 +- .../src/components/FileConnectionPanel.tsx | 363 +- .../FileConnectionPanelParserError.test.tsx | 70 +- .../web/src/components/FirstRunSetup.test.tsx | 2590 ++++++++-- apps/web/src/components/FirstRunSetup.tsx | 400 +- .../src/components/GroundingDisplay.test.tsx | 96 +- apps/web/src/components/GroundingNotice.tsx | 17 +- .../src/components/HypothesisCompare.test.tsx | 124 +- apps/web/src/components/HypothesisCompare.tsx | 335 +- .../HypothesisCompareDraft.test.tsx | 55 +- .../src/components/HypothesisCompareLive.tsx | 153 +- .../HypothesisContractSwitch.test.tsx | 88 +- .../components/HypothesisContractSwitch.tsx | 55 +- .../components/HypothesisEvidence.test.tsx | 409 +- .../components/HypothesisEvidencePanel.tsx | 60 +- apps/web/src/components/HypothesisFields.tsx | 74 +- .../components/HypothesisLinkNotice.test.tsx | 151 +- .../src/components/HypothesisLinkNotice.tsx | 112 +- .../components/HypothesisLinkScreens.test.tsx | 59 +- .../src/components/HypothesisReview.test.tsx | 177 +- .../src/components/HypothesisScreen.test.tsx | 183 +- .../src/components/LanguageScreens.test.tsx | 52 +- .../src/components/LanguageSettings.test.tsx | 305 +- .../src/components/LanguageSettingsCard.tsx | 88 +- apps/web/src/components/LessonRecall.test.tsx | 192 +- apps/web/src/components/LessonRecall.tsx | 73 +- apps/web/src/components/LinkRecheckDialog.tsx | 167 +- .../src/components/LiveProjectWorkspace.tsx | 1002 +++- .../LiveProjectWorkspaceConsent.test.tsx | 157 +- .../LiveProjectWorkspaceInvestigate.test.tsx | 256 +- .../LiveProjectWorkspaceReadiness.test.tsx | 191 +- .../LiveProjectWorkspaceRestart.test.tsx | 918 +++- .../LocalRetrievalSettingsCard.test.tsx | 507 +- .../components/LocalRetrievalSettingsCard.tsx | 795 ++- .../src/components/M7LiveReadDenial.test.tsx | 449 +- .../src/components/M7OwnerSiblings.test.tsx | 448 +- apps/web/src/components/M7ReadDenial.test.tsx | 488 +- .../components/M7RemainingOwnership.test.tsx | 496 +- .../src/components/M7ScopeBoundary.test.tsx | 135 +- .../src/components/M7StateOwnership.test.tsx | 506 +- apps/web/src/components/MemoryEditDialog.tsx | 121 +- .../src/components/MemoryInjectionSwitch.tsx | 69 +- .../src/components/ModelCredentialPanel.tsx | 169 +- .../web/src/components/ModelSettings.test.tsx | 723 ++- apps/web/src/components/ModelSettings.tsx | 355 +- .../src/components/NavigationDrawer.test.tsx | 177 +- apps/web/src/components/OAuthLoginFlow.tsx | 623 ++- .../src/components/ProjectActionNotice.tsx | 15 +- .../components/ProjectArchiveRestore.test.tsx | 408 +- apps/web/src/components/ProjectForm.test.tsx | 19 +- apps/web/src/components/ProjectForm.tsx | 60 +- .../src/components/ProjectLanding.test.tsx | 8 +- .../src/components/ProjectManagement.test.tsx | 234 +- .../components/ProjectMemoryPanel.test.tsx | 414 +- .../web/src/components/ProjectMemoryPanel.tsx | 216 +- apps/web/src/components/ProjectNavItem.tsx | 43 +- .../ProjectPermanentDelete.test.tsx | 244 +- .../src/components/ProjectPermanentDelete.tsx | 109 +- .../src/components/ProjectReadinessCard.tsx | 79 +- .../src/components/ProjectSessionPages.tsx | 395 +- .../src/components/ProjectWebSetup.test.tsx | 12 +- apps/web/src/components/ProjectWebSetup.tsx | 78 +- apps/web/src/components/ReadableList.tsx | 31 +- apps/web/src/components/RecordInspector.tsx | 61 +- .../src/components/RecordedStanding.test.tsx | 132 +- .../components/RefutationConditionsPanel.tsx | 131 +- apps/web/src/components/RenameDialog.tsx | 15 +- .../components/RequestLanguageFlow.test.tsx | 198 +- .../components/ResearchActivityStrip.test.tsx | 128 +- .../src/components/ResearchActivityStrip.tsx | 370 +- .../ResearchCancelledPresentation.test.tsx | 53 +- apps/web/src/components/ResearchComposer.tsx | 323 +- .../web/src/components/ResearchDetailPane.tsx | 234 +- .../ResearchEvidenceDetail.test.tsx | 252 +- .../components/ResearchFollowupSummary.tsx | 231 +- apps/web/src/components/ResearchHold.test.tsx | 171 +- .../src/components/ResearchLiveProgress.tsx | 84 +- apps/web/src/components/ResearchProgress.tsx | 120 +- .../components/ResearchProgressStop.test.tsx | 23 +- .../components/ResearchResultCard.test.tsx | 323 +- .../web/src/components/ResearchResultCard.tsx | 323 +- apps/web/src/components/ResearchSidePanel.tsx | 22 +- .../web/src/components/ResourcesPage.test.tsx | 138 +- .../src/components/ResultCardOnce.test.tsx | 197 +- .../src/components/ResultEvidencePanel.tsx | 167 +- apps/web/src/components/ResultExample.tsx | 267 +- apps/web/src/components/ResultHoldDetails.tsx | 79 +- .../ResultInterruptionNotice.test.tsx | 103 +- .../components/ResultInterruptionNotice.tsx | 148 +- apps/web/src/components/ResultScreen.test.tsx | 269 +- .../src/components/ResultStatusStrip.test.tsx | 117 +- apps/web/src/components/ResultStatusStrip.tsx | 39 +- apps/web/src/components/ReviewDialog.tsx | 114 +- apps/web/src/components/ReviewStatusLine.tsx | 75 +- .../src/components/SameHypothesis.test.tsx | 229 +- apps/web/src/components/SameHypothesis.tsx | 188 +- apps/web/src/components/Select.test.tsx | 17 +- apps/web/src/components/Sentences.test.tsx | 21 +- .../components/SourceCitationGate.test.tsx | 147 +- .../web/src/components/SourceCitationGate.tsx | 148 +- apps/web/src/components/SourceDisconnect.tsx | 11 +- .../web/src/components/SourceTimeAdvanced.tsx | 155 +- .../SourceTimeConfirmationDialog.tsx | 59 +- apps/web/src/components/StatusBadge.tsx | 17 +- .../src/components/SyntheticDataNotice.tsx | 21 +- apps/web/src/components/TechnicalDetails.tsx | 7 +- .../src/components/TestActionDraft.test.tsx | 138 +- apps/web/src/components/TestActionDraft.tsx | 68 +- apps/web/src/components/TestOrder.test.tsx | 191 +- apps/web/src/components/TestOrderSection.tsx | 95 +- .../web/src/components/TestResultRecorder.tsx | 174 +- .../src/components/ThothBlueprint.test.tsx | 39 +- apps/web/src/components/ThreadNavItem.tsx | 30 +- apps/web/src/components/ToolInstallHelp.tsx | 92 +- apps/web/src/components/TraceClosure.test.tsx | 189 +- .../web/src/components/TraceConfirmDialog.tsx | 89 +- .../src/components/TraceEvidenceDialog.tsx | 84 +- apps/web/src/components/TraceHistory.tsx | 240 +- apps/web/src/components/TraceImportDialog.tsx | 285 +- apps/web/src/components/TraceOriginLine.tsx | 25 +- apps/web/src/components/TracePage.test.tsx | 527 +- apps/web/src/components/TracePage.tsx | 661 ++- apps/web/src/components/UsageSummary.test.tsx | 30 +- .../src/components/WorkspaceContent.test.tsx | 4 +- apps/web/src/components/WorkspaceContent.tsx | 6 +- .../src/components/WorkspaceErrorBoundary.tsx | 40 +- apps/web/src/components/WorkspaceGate.tsx | 97 +- .../components/WorkspaceInternetConsent.tsx | 76 +- .../WorkspaceLocationNotice.test.tsx | 50 +- .../components/WorkspaceLocationNotice.tsx | 18 +- apps/web/src/components/WorkspaceTopbar.tsx | 60 +- apps/web/src/components/XaiDeviceLogin.tsx | 411 +- .../src/components/accessibilityRules.test.ts | 25 +- apps/web/src/components/answerLeads.test.ts | 41 +- apps/web/src/components/answerText.test.ts | 38 +- apps/web/src/components/answerText.ts | 43 +- apps/web/src/components/approvalView.ts | 104 +- apps/web/src/components/askedQuestion.ts | 4 +- apps/web/src/components/connectionCopy.ts | 74 +- apps/web/src/components/coverageCounts.ts | 16 +- .../src/components/effectDeclaration.test.ts | 37 +- apps/web/src/components/effectDeclaration.ts | 135 +- apps/web/src/components/effortEstimates.ts | 15 +- apps/web/src/components/evidenceUse.ts | 58 +- apps/web/src/components/expectedGrounding.ts | 5 +- apps/web/src/components/expectedResults.css | 63 +- apps/web/src/components/foldedOriginals.ts | 2 +- apps/web/src/components/followupItems.ts | 127 +- .../src/components/groundingTestFixture.ts | 96 +- .../history/CriteriaChange.test.tsx | 111 +- .../src/components/history/CriteriaChange.tsx | 138 +- .../history/DecisionDeltaComparison.tsx | 446 +- .../history/HistoryChangeSummary.test.tsx | 144 +- .../history/HistoryChangeSummary.tsx | 110 +- .../src/components/history/HistoryContent.tsx | 212 +- .../src/components/history/HistoryDetail.tsx | 275 +- .../src/components/history/HistoryExample.tsx | 33 +- .../components/history/HistoryTimeline.tsx | 173 +- .../history/ProjectReviewList.test.tsx | 68 +- .../components/history/ProjectReviewList.tsx | 138 +- .../history/ResearchHistoryWorkspace.test.tsx | 597 ++- .../history/ResearchHistoryWorkspace.tsx | 377 +- .../history/RestorePreviewDialog.tsx | 202 +- .../components/history/RevisionComparison.tsx | 74 +- .../src/components/history/historyErrors.ts | 3 +- .../components/history/historyPresentation.ts | 96 +- .../history/historyReasonText.test.ts | 46 +- .../components/history/historyReasonText.ts | 12 +- .../src/components/history/useRestoreFlow.ts | 97 +- .../src/components/hypothesisLinkText.test.ts | 128 +- apps/web/src/components/hypothesisLinkText.ts | 96 +- apps/web/src/components/hypothesisMatrix.ts | 13 +- apps/web/src/components/hypothesisReview.ts | 60 +- apps/web/src/components/hypothesisView.ts | 44 +- .../src/components/judgmentRecordText.test.ts | 114 +- apps/web/src/components/judgmentRecordText.ts | 126 +- apps/web/src/components/judgmentReview.ts | 134 +- apps/web/src/components/lessonText.ts | 54 +- .../components/localCredentialWorkspace.ts | 3 +- apps/web/src/components/memoryEdit.ts | 78 +- .../modelCatalogPresentation.test.ts | 86 +- .../components/modelCatalogPresentation.ts | 71 +- .../components/modelCredentialPresentation.ts | 257 +- .../modelCredentialStatusNote.test.ts | 173 +- apps/web/src/components/plainWording.ts | 18 +- apps/web/src/components/readinessText.ts | 10 +- apps/web/src/components/resultInterruption.ts | 56 +- apps/web/src/components/resultUsage.ts | 64 +- apps/web/src/components/returnFocus.test.tsx | 84 +- apps/web/src/components/returnFocus.ts | 7 +- apps/web/src/components/sameHypothesisText.ts | 31 +- apps/web/src/components/statusLabels.test.tsx | 28 +- apps/web/src/components/statusLabels.ts | 142 +- .../web/src/components/testActionDraftText.ts | 57 +- apps/web/src/components/testOrderInput.ts | 106 +- apps/web/src/components/testOrderText.ts | 4 +- .../src/components/testRanking.cases.test.ts | 106 +- apps/web/src/components/testRanking.ts | 167 +- apps/web/src/components/timelineTurns.ts | 30 +- .../src/components/traceEvaluation.test.ts | 82 +- .../src/components/traceInvestigation.test.ts | 90 +- apps/web/src/components/traceInvestigation.ts | 93 +- .../traceInvestigationLanguage.test.ts | 69 +- .../src/components/traceReasonTags.test.ts | 165 +- apps/web/src/components/traceReasonTags.ts | 57 +- apps/web/src/components/traceRows.ts | 90 +- apps/web/src/components/traceText.test.ts | 232 +- apps/web/src/components/traceText.ts | 591 ++- apps/web/src/components/turnSummary.ts | 50 +- .../src/components/useAutoRetryOff.test.tsx | 49 +- apps/web/src/components/useAutoRetryOff.ts | 13 +- .../useConversationReadProtection.ts | 31 +- apps/web/src/components/useDraftGeneration.ts | 21 +- apps/web/src/components/useHypothesisLinks.ts | 13 +- apps/web/src/components/useJudgmentRecords.ts | 35 +- .../web/src/components/useNavigationDrawer.ts | 28 +- .../components/useOpenAtLatestTurn.test.tsx | 16 +- .../src/components/useProjectManagement.tsx | 56 +- apps/web/src/components/useProjectSources.ts | 33 +- .../src/components/useReadAccessProtection.ts | 18 +- apps/web/src/components/useResearchSession.ts | 354 +- .../components/useResponseLanguageSupport.ts | 16 +- .../components/useResultEvidenceProtection.ts | 70 +- apps/web/src/components/workspaceSelection.ts | 214 +- .../web/src/components/workspaceSetupIssue.ts | 6 +- apps/web/src/i18n/ResponseLanguageContext.ts | 8 +- apps/web/src/i18n/UiError.ts | 2 +- apps/web/src/i18n/activityDisplay.ts | 253 +- apps/web/src/i18n/activityMessages.ts | 594 +-- apps/web/src/i18n/advancedMessages.ts | 402 +- apps/web/src/i18n/apiNoticesMessages.ts | 146 +- apps/web/src/i18n/bilingualRecord.ts | 8 +- apps/web/src/i18n/commonStateMessages.ts | 250 +- apps/web/src/i18n/connectionReasons.ts | 259 +- apps/web/src/i18n/coreMessages.ts | 145 +- apps/web/src/i18n/countFormatMessages.ts | 320 +- apps/web/src/i18n/credentialMessages.ts | 262 +- apps/web/src/i18n/finishingMessages.ts | 90 +- apps/web/src/i18n/followupMessages.ts | 294 +- apps/web/src/i18n/formatMessages.ts | 197 +- apps/web/src/i18n/groundingMessages.ts | 40 +- apps/web/src/i18n/historyFormatMessages.ts | 159 +- apps/web/src/i18n/historyReasons.ts | 85 +- apps/web/src/i18n/historyTitle.test.ts | 4 +- apps/web/src/i18n/historyTitle.ts | 78 +- apps/web/src/i18n/historyViewMessages.ts | 290 +- .../web/src/i18n/hypothesisActionsMessages.ts | 418 +- apps/web/src/i18n/ja/activityMessagesJa.ts | 24 +- apps/web/src/i18n/ja/advancedMessagesJa.ts | 27 +- apps/web/src/i18n/ja/apiNoticesMessagesJa.ts | 9 +- apps/web/src/i18n/ja/commonStateMessagesJa.ts | 6 +- apps/web/src/i18n/ja/coreMessagesJa.ts | 42 +- apps/web/src/i18n/ja/credentialMessagesJa.ts | 66 +- apps/web/src/i18n/ja/finishingMessagesJa.ts | 3 +- apps/web/src/i18n/ja/followupMessagesJa.ts | 39 +- apps/web/src/i18n/ja/groundingMessagesJa.ts | 3 +- apps/web/src/i18n/ja/historyViewMessagesJa.ts | 27 +- .../i18n/ja/hypothesisActionsMessagesJa.ts | 39 +- apps/web/src/i18n/ja/judgmentMessagesJa.ts | 36 +- .../src/i18n/ja/localRetrievalMessagesJa.ts | 154 +- apps/web/src/i18n/ja/modelHelpMessagesJa.ts | 12 +- apps/web/src/i18n/ja/oauthMessagesJa.ts | 60 +- apps/web/src/i18n/ja/parserErrorMessagesJa.ts | 54 +- .../src/i18n/ja/recordDialogsMessagesJa.ts | 39 +- .../web/src/i18n/ja/recordPanelsMessagesJa.ts | 57 +- apps/web/src/i18n/ja/remainingMessagesJa.ts | 42 +- apps/web/src/i18n/ja/researchMessagesJa.ts | 33 +- .../src/i18n/ja/researchPanelsMessagesJa.ts | 51 +- .../web/src/i18n/ja/reviewPanelsMessagesJa.ts | 33 +- apps/web/src/i18n/ja/setupMessagesJa.ts | 27 +- apps/web/src/i18n/ja/sourceMessagesJa.ts | 51 +- apps/web/src/i18n/ja/traceMessagesJa.ts | 57 +- .../src/i18n/ja/workspaceDetailMessagesJa.ts | 24 +- apps/web/src/i18n/ja/workspaceMessagesJa.ts | 54 +- apps/web/src/i18n/jaScreen.test.ts | 131 +- apps/web/src/i18n/judgmentMessages.ts | 130 +- apps/web/src/i18n/languageChoices.ts | 51 +- .../web/src/i18n/localRetrievalClaims.test.ts | 26 +- apps/web/src/i18n/localRetrievalMessages.ts | 302 +- apps/web/src/i18n/locale.ts | 95 +- apps/web/src/i18n/messages.ts | 132 +- apps/web/src/i18n/metricFormatMessages.ts | 480 +- apps/web/src/i18n/modelHelpMessages.ts | 34 +- apps/web/src/i18n/oauthMessages.ts | 246 +- apps/web/src/i18n/pickLocale.ts | 5 +- apps/web/src/i18n/plainWordingResources.ts | 336 +- apps/web/src/i18n/progressEn.ts | 299 +- apps/web/src/i18n/progressJa.ts | 309 +- apps/web/src/i18n/progressKo.ts | 81 +- apps/web/src/i18n/progressZh.ts | 302 +- apps/web/src/i18n/progressZhHant.ts | 302 +- apps/web/src/i18n/recordDialogsMessages.ts | 354 +- apps/web/src/i18n/recordPanelsMessages.ts | 426 +- apps/web/src/i18n/remainingMessages.ts | 446 +- apps/web/src/i18n/researchMessages.ts | 322 +- apps/web/src/i18n/researchPanelsMessages.ts | 382 +- apps/web/src/i18n/reviewPanelsMessages.ts | 350 +- apps/web/src/i18n/sentences.ts | 2 +- apps/web/src/i18n/setupMessages.ts | 256 +- apps/web/src/i18n/sourceMessages.ts | 292 +- apps/web/src/i18n/stateFormatMessages.ts | 313 +- apps/web/src/i18n/statusChinese.ts | 170 +- apps/web/src/i18n/statusEnglish.ts | 170 +- apps/web/src/i18n/statusJapanese.ts | 175 +- apps/web/src/i18n/statusResources.ts | 251 +- apps/web/src/i18n/statusTraditional.ts | 170 +- apps/web/src/i18n/testOrderResources.ts | 240 +- apps/web/src/i18n/traceChinese.ts | 44 +- apps/web/src/i18n/traceEnglish.ts | 46 +- apps/web/src/i18n/traceFormatMessages.ts | 203 +- apps/web/src/i18n/traceJapanese.ts | 46 +- apps/web/src/i18n/traceMessages.ts | 438 +- apps/web/src/i18n/traceReasonResources.ts | 172 +- apps/web/src/i18n/traceTraditional.ts | 44 +- apps/web/src/i18n/uiFormatMessages.ts | 340 +- apps/web/src/i18n/uiText.test.ts | 32 + apps/web/src/i18n/withColumns.ts | 14 +- apps/web/src/i18n/wordingRules.test.ts | 137 +- apps/web/src/i18n/workspaceDetailMessages.ts | 82 +- apps/web/src/i18n/workspaceMessages.ts | 461 +- apps/web/src/i18n/zh/advancedMessagesZh.ts | 12 +- apps/web/src/i18n/zh/apiNoticesMessagesZh.ts | 3 +- apps/web/src/i18n/zh/commonStateMessagesZh.ts | 3 +- apps/web/src/i18n/zh/coreMessagesZh.ts | 12 +- apps/web/src/i18n/zh/credentialMessagesZh.ts | 24 +- apps/web/src/i18n/zh/followupMessagesZh.ts | 9 +- apps/web/src/i18n/zh/historyViewMessagesZh.ts | 7 +- .../i18n/zh/hypothesisActionsMessagesZh.ts | 9 +- apps/web/src/i18n/zh/judgmentMessagesZh.ts | 15 +- .../src/i18n/zh/localRetrievalMessagesZh.ts | 121 +- apps/web/src/i18n/zh/modelHelpMessagesZh.ts | 6 +- apps/web/src/i18n/zh/parserErrorMessagesZh.ts | 18 +- .../src/i18n/zh/recordDialogsMessagesZh.ts | 23 +- .../web/src/i18n/zh/recordPanelsMessagesZh.ts | 7 +- apps/web/src/i18n/zh/remainingMessagesZh.ts | 12 +- .../src/i18n/zh/researchPanelsMessagesZh.ts | 16 +- .../web/src/i18n/zh/reviewPanelsMessagesZh.ts | 16 +- apps/web/src/i18n/zh/setupMessagesZh.ts | 14 +- apps/web/src/i18n/zh/sourceMessagesZh.ts | 15 +- apps/web/src/i18n/zh/traceMessagesZh.ts | 25 +- .../src/i18n/zh/workspaceDetailMessagesZh.ts | 6 +- apps/web/src/i18n/zh/workspaceMessagesZh.ts | 15 +- .../src/i18n/zhHant/advancedMessagesZhHant.ts | 12 +- .../i18n/zhHant/apiNoticesMessagesZhHant.ts | 3 +- .../i18n/zhHant/commonStateMessagesZhHant.ts | 3 +- .../web/src/i18n/zhHant/coreMessagesZhHant.ts | 12 +- .../i18n/zhHant/credentialMessagesZhHant.ts | 24 +- .../src/i18n/zhHant/followupMessagesZhHant.ts | 9 +- .../i18n/zhHant/historyViewMessagesZhHant.ts | 7 +- .../zhHant/hypothesisActionsMessagesZhHant.ts | 9 +- .../src/i18n/zhHant/judgmentMessagesZhHant.ts | 15 +- .../zhHant/localRetrievalMessagesZhHant.ts | 121 +- .../i18n/zhHant/modelHelpMessagesZhHant.ts | 9 +- .../i18n/zhHant/parserErrorMessagesZhHant.ts | 18 +- .../zhHant/recordDialogsMessagesZhHant.ts | 23 +- .../i18n/zhHant/recordPanelsMessagesZhHant.ts | 7 +- .../i18n/zhHant/remainingMessagesZhHant.ts | 12 +- .../zhHant/researchPanelsMessagesZhHant.ts | 16 +- .../i18n/zhHant/reviewPanelsMessagesZhHant.ts | 16 +- .../src/i18n/zhHant/setupMessagesZhHant.ts | 14 +- .../src/i18n/zhHant/sourceMessagesZhHant.ts | 15 +- .../src/i18n/zhHant/traceMessagesZhHant.ts | 25 +- .../zhHant/workspaceDetailMessagesZhHant.ts | 6 +- .../i18n/zhHant/workspaceMessagesZhHant.ts | 15 +- apps/web/src/i18n/zhHantScreen.test.ts | 162 +- apps/web/src/i18n/zhScreen.test.ts | 107 +- apps/web/src/narrowLayoutRules.test.ts | 116 +- apps/web/src/preview-timeline.tsx | 1 - apps/web/src/sourceDisplay.test.ts | 10 +- apps/web/src/styles.css | 1203 ++++- apps/web/src/test/jsdomTopLayer.ts | 4 +- apps/web/src/testSetup.ts | 5 +- apps/web/src/typeScaleRules.test.ts | 32 +- apps/web/src/vite-env.d.ts | 2 +- apps/web/src/workstation.css | 4383 +++++++++++++++-- apps/web/tsconfig.app.json | 1 - apps/web/tsconfig.json | 6 +- apps/web/vite.config.ts | 42 +- config/module-responsibility-budget.json | 33 +- docs/BETA.md | 4 +- docs/architecture/verification-profiles.md | 3 +- docs/releases/v0.2.0-beta.4.md | 51 + .../3b06e4f87d92_policy_digest_history.py | 21 +- .../7b06e5a9d3f1_a06_full_project_memory.py | 16 +- package.json | 2 +- pnpm-lock.yaml | 1097 +++++ pyproject.toml | 11 +- src/thoth/__init__.py | 2 +- src/thoth/adapters/connectors/http_read.py | 4 +- src/thoth/adapters/connectors/postgres.py | 4 +- src/thoth/adapters/connectors/s3.py | 4 +- src/thoth/adapters/evaluators/__init__.py | 1 - .../adapters/memory/relation_projection.py | 4 +- .../adapters/models/codex_account_usage.py | 96 +- src/thoth/adapters/models/codex_app_server.py | 33 +- src/thoth/adapters/models/http_rejection.py | 4 +- src/thoth/adapters/models/registry.py | 4 +- src/thoth/adapters/parsers/docling_pdf.py | 46 +- src/thoth/adapters/parsers/document_time.py | 56 +- .../adapters/storage/alembic_migrations.py | 4 +- src/thoth/adapters/storage/auth.py | 8 +- .../adapters/storage/field_measurement.py | 8 +- src/thoth/adapters/storage/object_prune.py | 3 +- src/thoth/adapters/storage/project_purge.py | 4 +- src/thoth/adapters/storage/research_queue.py | 4 +- .../application/commands/investigation.py | 4 +- .../application/commands/projectpacks.py | 4 +- src/thoth/application/commands/sources.py | 36 +- .../reducers/hypothesis_reducer.py | 10 +- .../services/conversation_router.py | 60 +- .../services/criterion_compiler.py | 4 +- .../services/evidence_graph_service.py | 1 - .../services/investigation_service.py | 4 +- .../application/services/model_settings.py | 5 +- src/thoth/application/services/policy_gate.py | 4 +- .../application/services/public_web_policy.py | 12 +- .../services/research_attempt_admission.py | 4 +- .../application/services/revision_diff.py | 8 +- .../application/services/sandbox_router.py | 4 +- .../application/services/sandbox_service.py | 2 +- .../services/source_time_service.py | 158 +- .../services/tui_session_service.py | 48 +- src/thoth/apps/decision_chain_routes.py | 4 +- src/thoth/apps/hosted_review_snapshot.py | 28 +- src/thoth/apps/source_composition.py | 16 +- src/thoth/domain/baseline.py | 6 +- src/thoth/domain/evidence.py | 3 +- src/thoth/domain/execution.py | 4 +- src/thoth/ports/artifact_ledger.py | 18 +- src/thoth/ports/evaluator.py | 1 - src/thoth/ports/sandbox_capability.py | 4 +- src/thoth/ports/thread_runtime.py | 4 +- tests/adversarial/test_anti_hardcoding.py | 4 +- .../test_atomicity_debt_contract.py | 4 +- .../architecture/test_ocp_extension_guards.py | 6 +- tests/architecture/test_post_d4_baseline.py | 9 +- tests/atomicity/test_permission_predicates.py | 294 +- .../test_policy_digest_history_migration.py | 21 +- tests/atomicity/test_project_governance.py | 122 +- tests/ci/test_ci_python_format.py | 29 + tests/integration/public_web_test_support.py | 1 + .../research_measurement_helpers.py | 12 +- tests/integration/test_a04_stale_evidence.py | 4 +- .../test_a08_recursive_improvement.py | 10 +- .../integration/test_a11_policy_integrity.py | 166 +- .../integration/test_conversation_ui_read.py | 312 +- .../test_historical_access_verification.py | 18 +- .../test_hosted_review_dispatch_gate.py | 16 +- .../test_hosted_review_internet_vs_model.py | 4 +- .../test_hosted_review_session_isolation.py | 6 +- tests/integration/test_hypothesis_full_rpc.py | 4 +- ...est_hypothesis_prediction_outcome_cycle.py | 4 +- tests/integration/test_model_settings.py | 1 + ..._public_oauth_memory_excerpt_regression.py | 4 +- .../test_public_web_evidence_gates.py | 3 +- .../test_public_web_policy_update_rpc.py | 4 +- .../test_public_web_runtime_catalog.py | 7 +- .../integration/test_research_history_read.py | 59 +- .../test_research_lineage_publication.py | 272 +- .../integration/test_research_memory_basis.py | 20 +- .../test_resource_scope_revocation.py | 52 +- .../test_restore_apply_atomicity.py | 18 +- .../test_restore_currentness_consumers.py | 6 +- tests/integration/test_restore_drift.py | 4 +- .../test_selected_evidence_query.py | 37 +- .../test_source_time_connection.py | 204 +- .../test_structured_context_pipeline.py | 3 +- .../test_user_activity_thread_read.py | 8 +- .../test_workspace_restart_storage.py | 17 +- tests/parser/test_document_time.py | 40 +- tests/parser/test_test_log.py | 4 +- tests/test_foundation.py | 2 +- .../test_research_final_producer_selection.py | 4 +- .../test_user_activity_projection.py | 3 +- .../unit/apps/test_source_policy_defaults.py | 2 +- .../test_connector_capability_routing.py | 4 +- tests/unit/domain/test_models.py | 17 +- .../models/test_codex_app_server_auth_only.py | 13 +- tests/unit/models/test_codex_oauth_model.py | 3 +- .../test_hosted_review_provider_budget.py | 4 +- .../models/test_http_rejection_metadata.py | 8 +- .../unit/services/test_criterion_compiler.py | 4 +- tests/unit/services/test_evidence_context.py | 4 +- tests/unit/services/test_model_route.py | 8 +- .../services/test_retry_model_mismatch.py | 4 +- tests/unit/test_hosted_review_snapshot.py | 8 +- .../test_public_web_policy_reconciliation.py | 4 +- .../unit/test_public_web_query_disclosure.py | 12 +- tests/unit/test_public_web_uri_boundary.py | 4 +- tests/unit/test_source_time_resolution.py | 174 +- uv.lock | 2 +- 635 files changed, 59556 insertions(+), 19425 deletions(-) create mode 100644 apps/web/.prettierignore create mode 100644 apps/web/.prettierrc.json delete mode 100644 apps/web/preview-timeline.html create mode 100644 apps/web/src/i18n/uiText.test.ts delete mode 100644 apps/web/src/preview-timeline.tsx create mode 100644 docs/releases/v0.2.0-beta.4.md create mode 100644 tests/ci/test_ci_python_format.py diff --git a/.github/workflows/product-check.yml b/.github/workflows/product-check.yml index 5df0d96a..115bc738 100644 --- a/.github/workflows/product-check.yml +++ b/.github/workflows/product-check.yml @@ -93,6 +93,9 @@ jobs: - name: Python lint if: needs.changes.outputs.docs_only != 'true' run: .\.venv\Scripts\python.exe -m ruff check src apps tests migrations + - name: Python format + if: needs.changes.outputs.docs_only != 'true' + run: .\.venv\Scripts\python.exe -m ruff format --check src apps tests migrations - name: Runtime architecture if: needs.changes.outputs.docs_only != 'true' run: .\.venv\Scripts\python.exe scripts/check_architecture.py diff --git a/Makefile.ps1 b/Makefile.ps1 index f2ac91d3..28997834 100644 --- a/Makefile.ps1 +++ b/Makefile.ps1 @@ -37,6 +37,7 @@ function Invoke-NativeStage { function Invoke-BackendLint { Invoke-NativeStage "LINT" $Python @("-m", "ruff", "check", "src", "apps", "tests", "migrations") + Invoke-NativeStage "FORMAT_CHECK" $Python @("-m", "ruff", "format", "--check", "src", "apps", "tests", "migrations") } function Invoke-BackendType { diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index 3e83b53a..e4caa863 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-11-12T08:53:39.354182+00:00", + "created_at": "2026-11-12T11:00:04.245678+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -23,8 +23,8 @@ }, { "path": ".github/workflows/product-check.yml", - "sha256": "ed0a5e6b47b7cb9ab44e8466020dd5dbb3b42a29413e505e6423f206978dfd7b", - "bytes": 15413 + "sha256": "df86ddfabb50266baeff7a08b850c653b072cbc2c6469ac7df6aad449ff692e9", + "bytes": 15584 }, { "path": ".gitignore", @@ -48,8 +48,8 @@ }, { "path": "Makefile.ps1", - "sha256": "f99bc21e4a811cd0f59fda9ae506fb36ab40b50548b30fbc98a06672173f6b60", - "bytes": 3012 + "sha256": "2c41986dbd0a19f0ae23755d42577f265052de3919889a3c54442a4b657cfd9f", + "bytes": 3133 }, { "path": "PUBLICATION_STATUS.md", @@ -91,10 +91,20 @@ "sha256": "e37bba3ad3fc4e6660f06662f5bbba215a998066221a3462493a46a288cb2921", "bytes": 105 }, + { + "path": "apps/web/.prettierignore", + "sha256": "b7ef17791d103aa712ff7f5d2073839cc4891ceb435c962f371668074b971723", + "bytes": 218 + }, + { + "path": "apps/web/.prettierrc.json", + "sha256": "6b380994ae171521d31a3e92447de7e80a8cfdff7ced1e4787a26ce98dcfe6ae", + "bytes": 24 + }, { "path": "apps/web/eslint.config.js", - "sha256": "c27f4d3c48f6123e1a3755599ef10542df3232665ae7880972a4599919e0234d", - "bytes": 825 + "sha256": "0e1ef1041b027e0f109c08420411612e02bb80d90491064c495013cce64643b7", + "bytes": 928 }, { "path": "apps/web/index.html", @@ -103,13 +113,8 @@ }, { "path": "apps/web/package.json", - "sha256": "a7dfe39164c338a07064d6db46ab3cefbc66c4c19afb6df445308c3a6f26e7b5", - "bytes": 1055 - }, - { - "path": "apps/web/preview-timeline.html", - "sha256": "1e2b911ca2370e14a697376180874f4f913bd339d686b24733d9879131374e2e", - "bytes": 369 + "sha256": "fd22f4845202e6f567db529f7c0bba840ccd4235f18f12b6956b0f2dd84762a5", + "bytes": 1183 }, { "path": "apps/web/public/favicon.svg", @@ -118,43 +123,43 @@ }, { "path": "apps/web/src/App.test.tsx", - "sha256": "873196460f98808096b798155297ed278277f9bf9aa3684b086acf51d3c91b14", - "bytes": 1079 + "sha256": "2cafac7414d1b01c8e6620e33b017ed3c3ea6b06275456c9fa88f7a8588057a8", + "bytes": 1095 }, { "path": "apps/web/src/App.tsx", - "sha256": "db7f800403c2c1e945cc84197fe7a4831b399f3a7e6d7f52c1e3a3bc1dc772fa", - "bytes": 1073 + "sha256": "279d012acbb590f5cac07eb898de059fa6171837ac90d8a2d66323b79630f471", + "bytes": 1165 }, { "path": "apps/web/src/api/actionDraft.ts", - "sha256": "e35d50411a3a6b1313bb005c9d5333429eba7c8de2b6836636947068e93616c9", - "bytes": 588 + "sha256": "6ae1c675d199c26c6f62ec42e7b25dc80339a9b90c055adf58ed772598d0c1ce", + "bytes": 671 }, { "path": "apps/web/src/api/capabilities.ts", - "sha256": "7374e906ee659d028b1ef235afc3be0112a197f7d8b3b2c0aa6a756e06d8696e", - "bytes": 2660 + "sha256": "2d69e2b57c1ca9ee4d2bb11144972124126dfe175821d274d2dbf306f922abeb", + "bytes": 2883 }, { "path": "apps/web/src/api/contracts.test.ts", - "sha256": "5979e401ee2100d15c963e7835ce8517346c7370f3e5e8e701751989d79f28cc", - "bytes": 6998 + "sha256": "598a6f32c21e42468131ba291de951c57859501c8e64eb38c102378463e4c7cf", + "bytes": 7808 }, { "path": "apps/web/src/api/conversation.test.ts", - "sha256": "fc1561aee7bcf6351c79fa9ed25552df0536796ed2a1f84ff6d5fde058c218a1", - "bytes": 4457 + "sha256": "70d656edc70bf232f345751be8c3e6c6c040bb409602e5fa6542cb6164005e06", + "bytes": 5415 }, { "path": "apps/web/src/api/conversation.ts", - "sha256": "49f1d269b2e6ac86bd94716eb6e26e9bf32ed0055ef339b4e9574b4841c43a20", - "bytes": 6966 + "sha256": "65d976bbe3256124c81acb84d9f18fced5d46cc09da81b45ca7b8e3176b6ff6a", + "bytes": 7696 }, { "path": "apps/web/src/api/conversationRefetchDiagnosis.test.ts", - "sha256": "cd2883a0ff96b305cdc3b1224a990d52bd8263ae01bc9b9492e95b0f0948e947", - "bytes": 4584 + "sha256": "19206b1321e22261ba4a07d6974df28bfecc0805606b1db383ede5594cb483d6", + "bytes": 5107 }, { "path": "apps/web/src/api/conversationRefresh.ts", @@ -163,28 +168,28 @@ }, { "path": "apps/web/src/api/expectedResultContract.ts", - "sha256": "d358ea81cd6628d0aa819d1a5b134b3479c8efdffd86b7b80d182866ea33ddb5", - "bytes": 592 + "sha256": "02e2f4e18763216146bee9d87e80aea152a9e77f094cff42665fd19a944de4a3", + "bytes": 600 }, { "path": "apps/web/src/api/expectedResultState.test.ts", - "sha256": "688d1879261fb55c59d3212eee132629c2a69ada95246f78ba863d06e6e23a18", - "bytes": 557 + "sha256": "902a169951e36fc76027072c8b640a910115eb95185a496044d21ddf8a3364f9", + "bytes": 595 }, { "path": "apps/web/src/api/expectedResultState.ts", - "sha256": "09781c7815f76a1a644d36aa73e725178a37220c9ebe82ff047a357c19d1bb82", - "bytes": 401 + "sha256": "ea8155fa15dca22c1eda16c26f33ab2da0a69dc726d31213422cf9419c154e40", + "bytes": 414 }, { "path": "apps/web/src/api/files.test.ts", - "sha256": "d94b8248e2eb161f09e33bb4d39391cd845269c274213bb4a5f5c6533b5a4a95", - "bytes": 9697 + "sha256": "3096d3b61def56dd28b06ec3d20836b387733c80d2f981a59a73dafb1584301c", + "bytes": 9896 }, { "path": "apps/web/src/api/files.ts", - "sha256": "f0a4aeea28b7b76390a172a0b1d9d90a465031ef0305dce54ce00a7fa9cc641c", - "bytes": 3739 + "sha256": "c53dfc6a7cab114cd7de495baa72e2be38639580ce11181cc338bf03c7ac9120", + "bytes": 3850 }, { "path": "apps/web/src/api/fixtures/iris-judgment-excerpt.json", @@ -213,98 +218,98 @@ }, { "path": "apps/web/src/api/forgetProject.test.ts", - "sha256": "2606ff8275fa8897157d87342cfcacfa0827f1128c6f12e306deabc88c385c2c", - "bytes": 1727 + "sha256": "e5b3e50b3343d42dcd52cca928e87aed1b2b32f0183b8967efd3fff8d2a1c5d8", + "bytes": 1739 }, { "path": "apps/web/src/api/forgetProject.ts", - "sha256": "c503e78a878e87047b4a4c7cd1c016d502e582ac9d6cbc47f4b4ad9b765ffbc6", - "bytes": 1956 + "sha256": "6529deba7efa02d59509e69309cbe15f1af0d2d5ff4347ed0045b8d7e8264aa8", + "bytes": 1997 }, { "path": "apps/web/src/api/groundingNegotiation.test.ts", - "sha256": "e1013bca0c87fa5c58d0912d6ca4d2c841740b469d1fdc4f9d9e6330038bbfed", - "bytes": 2826 + "sha256": "3027cefa028364d77529cd5ec6e115c47dc5995e012a418728ac6e333cc33395", + "bytes": 2958 }, { "path": "apps/web/src/api/history.test.ts", - "sha256": "b713c346b75d884bbea02747f8cfe7811f3f540d536bf1a9865d993e28e397b1", - "bytes": 3117 + "sha256": "b38ae7924482dff195676bba3385829337ce2a8b5bf99d3eed01a4cc56d38356", + "bytes": 3549 }, { "path": "apps/web/src/api/history.ts", - "sha256": "ffbd41d3f5cd0fd1261593d590435a4ef2396704855fb0576c2080b9f289a767", - "bytes": 7528 + "sha256": "0e0144b078d4514a1673431a9a605ba14a677cbbc652efc5e41880276d3ca6d7", + "bytes": 8448 }, { "path": "apps/web/src/api/historyComparison.ts", - "sha256": "5ae41dbe64cc2c8f23fa1b78cc7237178dce61f49e8a1f8c1941533393d24ac6", - "bytes": 1965 + "sha256": "5cebfde2c34ff1c854b16e8c78771e1d0d7a1d17677fa37a9a5ed6c922666ac9", + "bytes": 2162 }, { "path": "apps/web/src/api/historyHttp.test.ts", - "sha256": "7b9c55d4ed2f9ce457020f7f62b9cbf1222150afb4bcfb54a94ae243161641c3", - "bytes": 3395 + "sha256": "84ea88121477319d95e0fd443d6cd53c355c74fad2c4d3bec330f34dde017dea", + "bytes": 3688 }, { "path": "apps/web/src/api/historyModels.ts", - "sha256": "fbd648972568fc9c8d3d7a30ee7ea2c00e946eb80410f402e611af3315b96167", - "bytes": 3958 + "sha256": "44dbfab29d7aef737c4d5394e6a2880b1cf402b3a0795144214149acd0d88ea2", + "bytes": 4224 }, { "path": "apps/web/src/api/historySchemas.ts", - "sha256": "242be5c08ddf48d468dc8bc8e915a82dc36aceed274e2c5283880abb8004f0de", - "bytes": 4562 + "sha256": "0aa118e91af53668f649c175ecbce58506cd392adc3410bb2b0b5f931c9698d6", + "bytes": 4690 }, { "path": "apps/web/src/api/historyServerContract.test.ts", - "sha256": "0006704c5eb1f0f5bc7ad5cac744fafcb248316597cb3f2c9c2d8781ab8fa286", - "bytes": 3791 + "sha256": "6708eb4d827870602c26aab0e75e378bbeef4ff57e811eaddad871ff403ff246", + "bytes": 4234 }, { "path": "apps/web/src/api/historyTestFixture.ts", - "sha256": "13c56f8ba756533e22f16148a7383480cf0130dea4a6069feb2dd2afdccd48ff", - "bytes": 10787 + "sha256": "dc6e83b78fc14859482cb6a39da071c15f1c84a3299378992fcbf84cb1b12e7c", + "bytes": 12699 }, { "path": "apps/web/src/api/hypothesisLink.ts", - "sha256": "0bb463ad28b1ef4dba3d947a97995bb6fc7f5a22e1cc497cae63931ef9338a06", - "bytes": 1829 + "sha256": "ed47194c50d0f65617808fe72ba4ea092e2deb174c60b0267f569f68af94cc73", + "bytes": 2004 }, { "path": "apps/web/src/api/hypothesisSame.ts", - "sha256": "935f8967ee3bad79fb196b18ac5e0150610aee6517e073582fdab5e9327ff831", - "bytes": 1390 + "sha256": "cfbb59619ec8910ecc952af07ce71cac098359e8a5f7e2b237210a81037b351c", + "bytes": 1523 }, { "path": "apps/web/src/api/judgmentRecords.ts", - "sha256": "e3d3286e76442d5fffe110bda299264f7bc70673c2c7a6758a7cb3bea0366543", - "bytes": 3401 + "sha256": "c6ed8052cedd376d5a4d5a5308e81cbd48961f52786d56c091b799192303bf72", + "bytes": 3796 }, { "path": "apps/web/src/api/koreanParticles.test.ts", - "sha256": "c4195ffa70dc91cd494bf74f48e07683ad325cb07b0b237471c42d451044e7f3", - "bytes": 3968 + "sha256": "d3a241321e94d1170c6a20605fb9d134230efe7a57f1b78f7cc1ec4706cfd818", + "bytes": 4131 }, { "path": "apps/web/src/api/koreanParticles.ts", - "sha256": "ce26c6a2c7aefb1b78cb7d345394b289ac2a1e9b053fcf133cdd69a4d5c0d572", - "bytes": 2173 + "sha256": "ace98e852227ef879b1f36270c73cb1bad3cbea51cbecd8c723c4cdf2cb989f4", + "bytes": 2181 }, { "path": "apps/web/src/api/lessons.ts", - "sha256": "766cc09dd47f8624aa3c9dac94e692607b6ec6c97521b84a5f11a93edad91330", - "bytes": 1330 + "sha256": "6d14376f2f10ee53d74d7385f4c3a84f2f266eb94c9e3caec1d3a8a9294ef0ed", + "bytes": 1414 }, { "path": "apps/web/src/api/localRetrieval.ts", - "sha256": "8bf7d103747457d42443c1ab2148d33749d8602e2b20749ae24efcbbd74ffdc6", - "bytes": 13080 + "sha256": "5c2ad477a2e7f3c4516d74b6d42f4e58597a7140de5c4861a9fa0f0d1c9ba6bb", + "bytes": 13556 }, { "path": "apps/web/src/api/localRetrievalInstall.test.ts", - "sha256": "7d290be9689543bb72781bee84dc411357bc0b72b3bf5f0c7c0390db48591391", - "bytes": 787 + "sha256": "254e3329122af9ce2c6cbe3d1085479cfac0f68502ddf564bdf2d020377ea844", + "bytes": 805 }, { "path": "apps/web/src/api/localRetrievalInstall.ts", @@ -313,63 +318,63 @@ }, { "path": "apps/web/src/api/localWorkspacePersistence.test.ts", - "sha256": "38c5717309058387d653f03f438913ee94176a7bef67c0f939b8e6d5f63a6983", - "bytes": 7017 + "sha256": "46931de96cd33219f0aae4b04b98b6abc982ff7a5d4989060ba9f535079e5a96", + "bytes": 7357 }, { "path": "apps/web/src/api/localWorkspacePersistence.ts", - "sha256": "abe5ec7d93dfb8093961f27e800e697933154027b28415276aeea79e2a734dd2", - "bytes": 11535 + "sha256": "c55908350dc4d4bc79fc0a44dc31cc85f75f4159a2b56ccf321a00927f2ca6c6", + "bytes": 12705 }, { "path": "apps/web/src/api/parserError.test.ts", - "sha256": "1a1211dbd9693593b259d84c7d53450a0340a2cd7f3949cd8aaa01fb2748dbb3", - "bytes": 2751 + "sha256": "fdac86c94ed48da1a2907b70061392b2ec2ee597c6e697fc2ee47e6329347310", + "bytes": 2849 }, { "path": "apps/web/src/api/parserError.ts", - "sha256": "da6b62d462be50eac00fab21aee2e92a7001d1bd9b1fd8a81012e35449c63608", - "bytes": 1126 + "sha256": "e68817d156ccb558a406781f6f034c2d9c3ad59bdbb3150e318cbd7368b8219d", + "bytes": 1161 }, { "path": "apps/web/src/api/presentation.ts", - "sha256": "465295ae990306613051deb1abc644b8528e4eaacfe8746db27a30557fd78aea", - "bytes": 908 + "sha256": "23d74b0a9acc32a8dcb6d1675d52494f4da735439e90fd9a4fbe9b81bd4bae8e", + "bytes": 955 }, { "path": "apps/web/src/api/progressEvidenceLocations.test.tsx", - "sha256": "9bcecca32cfa75bc2a242a190d37394066c34e450fadbd86a4d9266266c476ce", - "bytes": 8037 + "sha256": "237a54ed01c6521f394491f7bc44dff4774ba34ccdf5b76fc4856e946a005e34", + "bytes": 8631 }, { "path": "apps/web/src/api/progressEvidenceLocations.ts", - "sha256": "c957c55dc7a7f16a761544e2d7dcf36f544da7dbdc149956ca21135afdda4986", - "bytes": 3233 + "sha256": "f6918ae5fffd52868f7eb1e9f3594be785171a2610f917fd35e1a13a5a1bc6d2", + "bytes": 3365 }, { "path": "apps/web/src/api/projectActionFailure.ts", - "sha256": "7e7322fecbd82e01443f61aea8346e9a6a4a6e1c6a2e7130677f4d2385365973", - "bytes": 984 + "sha256": "59dda0072d3a7be769e2a323497c13067596ca89b5f060b3df64da1e7e32c08e", + "bytes": 996 }, { "path": "apps/web/src/api/projectCreation.ts", - "sha256": "f096ff7604b82a8e89012bdd78724845c081a4f172b118b4eeeb35f97479086b", - "bytes": 375 + "sha256": "652ec6d7397e8f19475f5b25d7c8e767caeea7deefb1911909ace2a54dae0d25", + "bytes": 413 }, { "path": "apps/web/src/api/projectDeletion.ts", - "sha256": "fee05833cf621b845302633a85d3114ce6a59e97cc58c90147bd15551964bf3b", - "bytes": 1695 + "sha256": "35f42b1c4dc85d42550c47d3df633a40e6095ad9c3fe9ac00d57c2e77213cf2f", + "bytes": 1701 }, { "path": "apps/web/src/api/protocol.ts", - "sha256": "467bce7e71a9e7158774f675c211ec90ce7932bc752735e2d5d7f1778a597aee", - "bytes": 1304 + "sha256": "d2987155aa88acf42f93873eb3971358f6be74928b77f253937787f57a33c145", + "bytes": 1314 }, { "path": "apps/web/src/api/publicWeb.test.ts", - "sha256": "9cde3fb6f8d5e64f3efc154fc6d291ca2638f5ba678563de4e20047d8941a69c", - "bytes": 1910 + "sha256": "2495a36180cc693b6031112da0cca1aa9f5228a865902ac9b14ce1b177f2ab0e", + "bytes": 1935 }, { "path": "apps/web/src/api/publicWeb.ts", @@ -378,48 +383,48 @@ }, { "path": "apps/web/src/api/readAccess.ts", - "sha256": "390436f6ce64e7fd1535c628955a91c889ea0353259bc33348aed2ff193c0659", - "bytes": 863 + "sha256": "000a7dcb99b29f70e7772d18acfb4827f4425dd9e7698947a303217c2eac22ab", + "bytes": 892 }, { "path": "apps/web/src/api/recordViews.ts", - "sha256": "c449e4b22a88ec53223496e43f612078954788e3f4418496f2bf634f018cd72c", - "bytes": 3910 + "sha256": "88929276a98e860744fcbfd4f2d08226505a56670682156c7491c1b51d06814f", + "bytes": 4977 }, { "path": "apps/web/src/api/requestLanguage.test.ts", - "sha256": "66f3499b111fbe17069716c43fe3c6ae5cb36163edba5a31168144ee65d0720e", - "bytes": 1759 + "sha256": "ba0509307fd58aeb47ded11b93c5f1de5b20b4afa391855255adc6d180ce0040", + "bytes": 1805 }, { "path": "apps/web/src/api/research.ts", - "sha256": "7b71132015d5f4310c5d011f43c2d2fe27550117c42d781106a7a884fa6770b8", - "bytes": 11957 + "sha256": "e79692e07d59b63ff11e1332cf1b20fe708a5ca29f481a5ba90f9aba6c1653cf", + "bytes": 13065 }, { "path": "apps/web/src/api/researchFollowup.ts", - "sha256": "2ceee4e426e6130538356d0194725e62797c0e2d923e6b1de18a1c1ee7d30cd3", - "bytes": 8170 + "sha256": "5a47c087eb1a752ae51553b146793c28e3a07f0876c52cb140e26676d141c299", + "bytes": 8561 }, { "path": "apps/web/src/api/researchProgress.test.tsx", - "sha256": "6e9d7175cede4c8951a1e1ae850b27591c27b3bee1614634b2158a4e13f1112b", - "bytes": 13450 + "sha256": "28fc97e141b14a09b89b69a73be5193e687f7d6ee5ee43c8294adf4db6010bae", + "bytes": 13725 }, { "path": "apps/web/src/api/researchProgress.ts", - "sha256": "554a96c5bbbef465ac74d51a96b35ffd5684054727c20a363679d984dc133181", - "bytes": 14943 + "sha256": "4df4cbc452fdbb8f4e0039847e0656398b51d54e9bd494af6bebea6dd7db19f5", + "bytes": 15124 }, { "path": "apps/web/src/api/researchProgressCharacterization.test.ts", - "sha256": "3e8643260280bf70b2ecfb836f37b6b9ec58fcfe138685821244c3313ece70cc", - "bytes": 32390 + "sha256": "3d9b5b43b30524f9dac738c0300838ee2d7ec2267faa2f8ae0eebd6c62f681ab", + "bytes": 34041 }, { "path": "apps/web/src/api/researchProgressText.ts", - "sha256": "6bce616d471e53bcd6010bdcc80734aca0c9e6da797fe5a4a42423dcb9da2fd8", - "bytes": 2470 + "sha256": "4cf1bb5d5afafd7b2a682bca7dedab6fa1dc566a17b4f2f219713be310788ba2", + "bytes": 2592 }, { "path": "apps/web/src/api/researchStatusRead.fixture.json", @@ -428,248 +433,248 @@ }, { "path": "apps/web/src/api/researchStatusRead.test.ts", - "sha256": "6e5ece71720a3d2fc2c56578ad345dc0ffa24a076a8d27de98309a580295da89", - "bytes": 3497 + "sha256": "744ca7bd88ff50a8539ef280ccb314df9e8b213c5c1e0f60b565061ccc79b640", + "bytes": 3625 }, { "path": "apps/web/src/api/researchStatusRead.ts", - "sha256": "326c894c5c6ae4b345afd9feabfbdd2a209741139ac6b6fa119888b96fc449ee", - "bytes": 2217 + "sha256": "07cb63f2ac7a8f4116a3ec1f400480223950d399d076b64f2b94b814af4f7949", + "bytes": 2320 }, { "path": "apps/web/src/api/responseLanguageDefault.ts", - "sha256": "606cbb9b87628420561ec2e180cf339ce7279192a579abb090fa780a5a4992b0", - "bytes": 2920 + "sha256": "e41179129ae1fd45bc5b0d1bcf4874b363d0401f57cc3ec873891de675087b98", + "bytes": 3002 }, { "path": "apps/web/src/api/responseLanguages.test.ts", - "sha256": "0d95dd9bacf520b981b6054c68a9dbdb44f47b277efd302896d144c9999bc82d", - "bytes": 3176 + "sha256": "6c3eafbd18f7466fce5e9914f60aa51fe2ecf922218ac3b522de249ae536a06f", + "bytes": 3343 }, { "path": "apps/web/src/api/responseLanguages.ts", - "sha256": "3b90d25f3007c5525a486e8ac7002ba7d21718c1fad6d9552eb15bf33a46711a", - "bytes": 1321 + "sha256": "575b62c01bca6a235655462c08a35d47d8b615724396c4e40ff253c6dffbf791", + "bytes": 1325 }, { "path": "apps/web/src/api/restore.test.ts", - "sha256": "3be583e0164d621e730783908b9b8ad8d4b4f03cb33acf4d9d4f368af8734cc9", - "bytes": 2665 + "sha256": "c395f88db4132680f1f14e3aca42c23f656bf54af463ec28edfddbe9b2672549", + "bytes": 2899 }, { "path": "apps/web/src/api/restore.ts", - "sha256": "c24781bc4e370071c6f9d43243a10dead5b00f295b6428f998fa3a8bed7d5e1c", - "bytes": 7198 + "sha256": "963f7cdb433ce2abd5dfee2fa6f0de5b132a0abbbcc7550712adce47275f2a9c", + "bytes": 7748 }, { "path": "apps/web/src/api/restorePending.test.ts", - "sha256": "bf60662f26a6cacea2890ad600402bf04651eb00cb7a5e550731cf2fc8005286", - "bytes": 1416 + "sha256": "cfa690adc32f3dec1ed33a4d8880919a299d473af15c4a6947ce5c3d759a8021", + "bytes": 1490 }, { "path": "apps/web/src/api/restorePending.ts", - "sha256": "fb8be50e8ec3746e16e2d30de7a986f165a792be11f40b4f4f9f80a5cf11f49e", - "bytes": 2046 + "sha256": "89786f0a224ba7d1ed3a2109b00f2721346ca08d6a18206defa1172027542fb7", + "bytes": 2207 }, { "path": "apps/web/src/api/resultEvidence.test.ts", - "sha256": "4ce2f6102016c86ad2d1881c0362ecb458502d5045cf2274b4b15d64b9bb9e07", - "bytes": 8096 + "sha256": "a646f2d3d3a2fd6f54998271345bdf40dc9f545d40bdea2792ff6d4fab464993", + "bytes": 8481 }, { "path": "apps/web/src/api/resultEvidence.ts", - "sha256": "9a8911d113e4061226b443bd19dcb1911dc41141dbb115f8be7985b7d4e3f249", - "bytes": 9992 + "sha256": "309132212197807e98761f1e15273aaca9a35e31ada2d9795f26e12221a442d3", + "bytes": 10760 }, { "path": "apps/web/src/api/resultEvidenceIdentity.test.ts", - "sha256": "d7b609f666956244cf99affd3457ec3d1f244ab3c8d5b8e462243e4906659bd7", - "bytes": 2053 + "sha256": "6e1feba8f317a65db99fc74bf765f44e0b4dc9a5fd1cb3ae4eba3c3c9bcc2364", + "bytes": 2273 }, { "path": "apps/web/src/api/resultEvidenceIdentity.ts", - "sha256": "7a1ce9cf49128806639a4d8ada09ec225393731ce5b16fee1783dafc5acc96cc", - "bytes": 1117 + "sha256": "5eced84674d5a774f9bfbff08e51a943d611f597821b75c601ae7a4b4647a3f9", + "bytes": 1154 }, { "path": "apps/web/src/api/resultEvidenceRuntime.test.ts", - "sha256": "0ab224cd47a485f159a5fedac254c9991cfe6cc69f9504ac02f8c9c9030b07de", - "bytes": 5818 + "sha256": "729ca4dd4246f1a9b1164049ff95e1abcabd5f70ee585647715ff2e54047b3b3", + "bytes": 6175 }, { "path": "apps/web/src/api/resultEvidenceServerContract.test.ts", - "sha256": "ff97c094eb2aa32e4bab7233b013241415e7cfdb845374dcc1f2c041d81b70b7", - "bytes": 1824 + "sha256": "d935bed9ba26c680e0b1643161a91abb8e515df4b0f260001c28aace4287ceeb", + "bytes": 1970 }, { "path": "apps/web/src/api/resultEvidenceTestFixture.ts", - "sha256": "4e98a83e57071a4c8d698e4ff83a81996541299abaf367153074984032cb8d8c", - "bytes": 2298 + "sha256": "4b860825e9678a6bb19e904ded4342f48a336bb1bf7aa3ff744e530a66e8fd30", + "bytes": 2889 }, { "path": "apps/web/src/api/rpcClient.test.ts", - "sha256": "15696d96c2db4a52d346008bf23effcd80f5b1af8327e701bf823fd3a6aa86bf", - "bytes": 3407 + "sha256": "03ee038c488f646c7aa05e7d0cc3f4ff4595281bec6f5829f95535f2b2f2af34", + "bytes": 3649 }, { "path": "apps/web/src/api/rpcClient.ts", - "sha256": "12f5678729977b7c30d7fa9b9235a9e42924ab6d8514df5c1837a15fe15a2bcf", - "bytes": 5366 + "sha256": "0a5884f1e900f1eaab9fcd9f3d59b1e024182ad8ef9c0b6a9e44e0381f2f525f", + "bytes": 5650 }, { "path": "apps/web/src/api/rpcTransport.test.ts", - "sha256": "bdad07ce594d2ab0a4ff347be7bde0e99292addeb960fc968204b17303183059", - "bytes": 2444 + "sha256": "a7268e77e89ea3755f6bea22b95d269125327c20c48cd31780320cd642c14cfb", + "bytes": 2625 }, { "path": "apps/web/src/api/sourceBindings.test.ts", - "sha256": "dfa8b1e9e4232228b186d8135b63bd4c10c7a10db6817e6f0349026891be04e6", - "bytes": 2781 + "sha256": "4af25528ae66b027c93062c3e7e94b66dfa3ce23f8cc2da7be443ae64b8ea04f", + "bytes": 2867 }, { "path": "apps/web/src/api/sourceBindings.ts", - "sha256": "ad7a97f03d5d8b5ef21bf02a78f2206e684bab80fdd2f3091b0a1e4481b665cc", - "bytes": 2353 + "sha256": "576425c7b03c0fd6b17f4603c7877d8c8accd0b82433ad22d7165813b16878ad", + "bytes": 2395 }, { "path": "apps/web/src/api/sourcePolicy.ts", - "sha256": "0f77ba848deea718c6e487e36e4182d6042579f43a41a1e3eb7956fc113e6a00", - "bytes": 1551 + "sha256": "f0d533c761e2e247191976e5693796fd0d04bf49244a5d84cf5325864505d436", + "bytes": 1887 }, { "path": "apps/web/src/api/sourceTime.ts", - "sha256": "653ba002f47828df8104bd451a7e2c929a32972560cbf1f1e1223b7afc640bc7", - "bytes": 2894 + "sha256": "07b6591000643fc42bba1b4476afcd279fc343258178a86cf3c298388248fa49", + "bytes": 3042 }, { "path": "apps/web/src/api/submissionPersistence.test.ts", - "sha256": "e994a680cd955479530f2bf26905f6a2bf6089bb2324c8e7d0971e71d9a8970c", - "bytes": 1777 + "sha256": "e05ad958deecca61716740c8f2e4d237457cf127abc2ea4b462547caeb4898c8", + "bytes": 1829 }, { "path": "apps/web/src/api/submissionPersistence.ts", - "sha256": "15e8e976473543b7d711e9d1d03d05f542fad6a4e630b41ea98e1eb90dace3ce", - "bytes": 1672 + "sha256": "f434c5179c5e4a1062e0f3a7f58ea42b4ce9bc11850217b4717a50ce8f51a013", + "bytes": 1723 }, { "path": "apps/web/src/api/threadName.ts", - "sha256": "c6fa278a70f0058255af371359f8c56720696c9a48836ef4af01f43c4d2983fc", - "bytes": 281 + "sha256": "39c6587798019941391c6de3ddc9e12ab4214a7d0cbd2ade442e270529588372", + "bytes": 286 }, { "path": "apps/web/src/api/timelineExample.ts", - "sha256": "1d8ac2a17ffa2a86720c472ce953d89bb2e1e5d99d614e5b86ebec1a0b2d4cbc", - "bytes": 8913 + "sha256": "75fdc6200f686c295eee542af1903a7d0bfb73b0c961ec41ddf4151a8a474c43", + "bytes": 9366 }, { "path": "apps/web/src/api/trace.ts", - "sha256": "39f426e38364313f5fcf028abd542a7fd2e397cdea797fb5eb2e079c80d70636", - "bytes": 5312 + "sha256": "680f6ba9d00056dfff7dbdf78998735720a0cefa13f82efb979b3eac23661272", + "bytes": 5643 }, { "path": "apps/web/src/api/uploadTypes.ts", - "sha256": "d0cb6292986348f493ad07aa3ad7da1cac8b9802c25ee505eff5138d960a11f2", - "bytes": 2480 + "sha256": "95d122006fdd86a423f43b566f8f5f9377635cc52baccbe165fc3621c577bef2", + "bytes": 2549 }, { "path": "apps/web/src/components/AbsenceChecks.test.tsx", - "sha256": "1c653dab4036dfcd6ad4b26b1d4803bd8986ecf89497f75ff6de3845b6839170", - "bytes": 3921 + "sha256": "d16fef725e0536e43439350a8482ea3a3b1d883c8966ad77b5322e1776bca67d", + "bytes": 4256 }, { "path": "apps/web/src/components/AbsenceChecks.tsx", - "sha256": "aeae6af6da7f9493430df897c91910f9a0a664c4366fbfba554210da095bf879", - "bytes": 2420 + "sha256": "633b2cf79eae9c7949c32ba5e6288e77d0e2d219e162d96cf85a542e49e88e33", + "bytes": 3007 }, { "path": "apps/web/src/components/ActionCompare.test.tsx", - "sha256": "05970a448c26f20a08897aff452a932a8caec1a116b4ddefadc795b3cd7d986a", - "bytes": 5175 + "sha256": "496349162c0fb35f15fe345caf7e1317c64636fc91b453be463694997e7bd456", + "bytes": 5296 }, { "path": "apps/web/src/components/ActionCompare.tsx", - "sha256": "1a3100723911d7996b8978cfe588e8002d00c7e1465896e7e300798d4f637dca", - "bytes": 7913 + "sha256": "cd59774abf7ec24d4175a9bdfa6391997643961d56c964aefa8c75029cd244d8", + "bytes": 10336 }, { "path": "apps/web/src/components/ActionCompareApproval.test.tsx", - "sha256": "263b69b8545050ab59f872605bb7e3a67655712f683a26046984954957da6da5", - "bytes": 3171 + "sha256": "6ace2f6e077cc51ef4acae0664310cd911f2c6ce7d085c07076a37b868fec71a", + "bytes": 3439 }, { "path": "apps/web/src/components/ActionCompareLive.tsx", - "sha256": "7bfa054898226f661b707d38238db40794e523818d4d00accb59294997e82149", - "bytes": 629 + "sha256": "077f8e89bda5e0b789c188c5a5ae2bf68460584beca0afa665d378c1497dac36", + "bytes": 678 }, { "path": "apps/web/src/components/ActionCompareStale.test.tsx", - "sha256": "f173e32970071d5c9ce6ce01b3ef9de50cb8f6030b1165605391dae075964028", - "bytes": 2371 + "sha256": "a07cadb4f449fe9ae5f30b98061f746113941292b7cc1be89fc1005983376775", + "bytes": 2458 }, { "path": "apps/web/src/components/ActionEffort.test.tsx", - "sha256": "6c853309bae39c97c85d6b315b5706a93981df9bd8501e8edf1632403dda2f72", - "bytes": 5361 + "sha256": "c1ec435b498a61aaf35b463691e63877e0e29eb2ec0ae6ddfb5361bcf56ef376", + "bytes": 5725 }, { "path": "apps/web/src/components/ActionEffort.tsx", - "sha256": "aa141c01fa78ac53c9b7b48917811262b012b5fb2bac40fa23afc4d8b8d6fab0", - "bytes": 7298 + "sha256": "7e3cc45ba1c69be82e0db005326eb50a39ffd325617218c17a46efb2cb19ca7e", + "bytes": 8857 }, { "path": "apps/web/src/components/AnswerBody.tsx", - "sha256": "2585378ff73ce37e67619e1ef98578d97d44b582514293386482c5234dd23b17", - "bytes": 1698 + "sha256": "128e8cf51515e210027208c8a8294c02cedf0a0bb196dfc9f924f8bed2a5392e", + "bytes": 2061 }, { "path": "apps/web/src/components/AnswerCitations.test.tsx", - "sha256": "e99ef91de9538f951746e7ad686eade831bbcf9c1a46a4fb4aebff9cbd79ea84", - "bytes": 6503 + "sha256": "2d57c6a3392d590448bfc2d83e9c510fcde710faa2c726ddf46b67af9d883dcf", + "bytes": 6878 }, { "path": "apps/web/src/components/AnswerHold.tsx", - "sha256": "f1c97c24eb7d599af5d100dff4f4166e3e7aea66c3ad681a6884e172f4787b5b", - "bytes": 1473 + "sha256": "20afd5dce96f1c333ef1a30f026a08963e38e256d332bdb9097a4ff42e3d3019", + "bytes": 1590 }, { "path": "apps/web/src/components/AnswerReadability.test.tsx", - "sha256": "d13f3aefac9ce16e5d2b22f7ff67c599dd4806006b9290850843a25830fa8431", - "bytes": 7996 + "sha256": "8de8cdeae8a781a5b3183e9def3e3c9bf4661d1f66b1fb36b4051de75edfe94e", + "bytes": 8341 }, { "path": "apps/web/src/components/ApprovalCard.test.tsx", - "sha256": "b3427e26f27c43168ec3af555be2f8034b099df35c45b127560931d171aac29d", - "bytes": 10073 + "sha256": "185a9c2dbbe65b40eb46304c8bb52c4b26a63780ae3c6422088391ce6079bdd1", + "bytes": 10934 }, { "path": "apps/web/src/components/ApprovalCard.tsx", - "sha256": "8b23817eedd59397eb7620e7056f1ee5b877d05d2f4f9dbdb286a841662cec2d", - "bytes": 4041 + "sha256": "e8cecc633005ec58a4ab64b5f513d0ef427495de9d313965f62ff0f51d17c1f7", + "bytes": 5042 }, { "path": "apps/web/src/components/ApprovalSection.tsx", - "sha256": "bf08777238edd106d654d8ae7df18ad5c83cb561178498a8b4f36f652551be64", - "bytes": 3539 + "sha256": "595723abcd7a87b6db733d19295433e7bd3e6b1c422cede497abf17b2fa2286d", + "bytes": 4144 }, { "path": "apps/web/src/components/ArchivedProjects.tsx", - "sha256": "cb52bc40c6695899cab85a49639ea903abcb6835775e4a63a6863677357a187c", - "bytes": 5399 + "sha256": "22be9ff855a897c6eff241c6bd2abcb165ae400b6c1aff70f49e220a2850f429", + "bytes": 6385 }, { "path": "apps/web/src/components/AsyncDraftAndCitationRegression.test.tsx", - "sha256": "07efd926f074b11cdc16338e631389b0247e2f275078c2ed7f0cb9fe64d9aa6c", - "bytes": 5597 + "sha256": "77c77f74f1d9b89e8f433c782f8d34b5033693a3402588804340accc51ff79f6", + "bytes": 7104 }, { "path": "apps/web/src/components/AutoRetrySwitch.test.tsx", - "sha256": "fd5f1d658ecd4ee2edeadb5247fa41cfb91294a7313e660326c7936ec091cdc2", - "bytes": 3922 + "sha256": "cf4234aa079ab22f761cde3da61e86b7a15e0c9cf2b8c8335a357cb066aca175", + "bytes": 4096 }, { "path": "apps/web/src/components/AutoRetrySwitch.tsx", - "sha256": "7d2260493556bf8867347bfc6ac11d6bf5b147470f1704a430201dc894ae4558", - "bytes": 1686 + "sha256": "fce3a8e643c96cdd56feccd5dacae1192f150253bd1d0fdcae4895a47f233cba", + "bytes": 1885 }, { "path": "apps/web/src/components/Brand.tsx", @@ -678,658 +683,658 @@ }, { "path": "apps/web/src/components/CapabilityWorkbench.test.tsx", - "sha256": "16fb85837d55221ba833a49b2aa4284ea0379132f995d4ca8ad0b3d2d90d4832", - "bytes": 7546 + "sha256": "d79554b86b0546ecd1956e56a243c8f448d858b8cc4bbb9e908bd3f823c9a57b", + "bytes": 8264 }, { "path": "apps/web/src/components/CapabilityWorkbench.tsx", - "sha256": "3fb5fa29b51d9f96e1e7b5958029ee46dbfe5e88750ff6457988ca066c94cf48", - "bytes": 7655 + "sha256": "2be17b70f1b52ffe0517c76201d1a4eb25c4f23cab623f65d95bcff73806d607", + "bytes": 10038 }, { "path": "apps/web/src/components/CheckedObservations.test.tsx", - "sha256": "4df7de4dc7054ce32db715bb2881094da6fee10163f7ee486545d5a8822cf4a2", - "bytes": 3537 + "sha256": "ed22b8e15dd00a9261b724471614d2597c60ae9a5d192d3a24673d7650af58a3", + "bytes": 3802 }, { "path": "apps/web/src/components/CheckedObservations.tsx", - "sha256": "d7f109b14d859791db51d8681e0f188c72a23a668e121c2ee9ec0f4c6ecfc7f4", - "bytes": 3034 + "sha256": "1030beaeab29a996e445a54f3859f05703014858efebb0cf7bdc96250d2bd1e2", + "bytes": 4114 }, { "path": "apps/web/src/components/ClaudeCodeHelp.tsx", - "sha256": "0cbcaee43f82474fabd37c9c63a46a989d8753b78c19b216d10e461c0000fa80", - "bytes": 499 + "sha256": "f8c8e7e00534b04db132d8cef81c3d75ea97decf22349408305e88ce5219983a", + "bytes": 535 }, { "path": "apps/web/src/components/ClosureMark.test.tsx", - "sha256": "6b0f20f51ad531d3d93614d42532ba8589c3ec9759e0fcb39bb9b153a3b75295", - "bytes": 3672 + "sha256": "26244b412d35d9da63c4e4375094a6b73b1fb8fd856ae8e8004e3217af9248c1", + "bytes": 3829 }, { "path": "apps/web/src/components/ClosureMark.tsx", - "sha256": "f91e3925fbd261d3419928a7e56e786b3aac0a2b866619d4e35b5bc9f6d96bd5", - "bytes": 1974 + "sha256": "81f230db43dec6adac6beef443d6c3bbb11eaa0c8f4deffcddc9c3c951970165", + "bytes": 2457 }, { "path": "apps/web/src/components/ClosureRecorder.tsx", - "sha256": "7595ce6fdf29ea84c77c833382e8fc3f120cce6f992ddc4e5ed572809d6d04a0", - "bytes": 4341 + "sha256": "7e13684f86821c15a560409e8da4601dcd4bdc35c3e10c60ec6e371f4e8dd89e", + "bytes": 5437 }, { "path": "apps/web/src/components/CodexToolHelp.tsx", - "sha256": "3885de8a8bb716122c15267df56b132fa94234d121193fa9b829e74cc1886092", - "bytes": 497 + "sha256": "d5062870a8dbdf2e601044414a395a86e5629ff3a23368ebf35441dafc3a81f1", + "bytes": 533 }, { "path": "apps/web/src/components/ConfirmDialog.tsx", - "sha256": "a067f479569fae667b62abe0338a1854c4368f063c3e37e2efbdc133f0ad61c4", - "bytes": 1592 + "sha256": "ad0e08eb979aafe5c6a5db5f952f1c118ce9232e751b26c6e8683b587b5dd19c", + "bytes": 1611 }, { "path": "apps/web/src/components/ConfirmedFacts.test.tsx", - "sha256": "7a8eb8e7bbd0e85a06c9ca7a100a1ced37fc6f71e1a3a44c71ac970b89ea0599", - "bytes": 4697 + "sha256": "1ac91f8b4c60d1f7a72b0d539bc5f02bd50d38b6fc1b1a6cb60c75fc2b8926b3", + "bytes": 4827 }, { "path": "apps/web/src/components/ConfirmedFacts.tsx", - "sha256": "38448e96054a958fff7de6b013fba9c5544edabc889fa478abbd59dd2e46b7e1", - "bytes": 3155 + "sha256": "6ef770af709422c6217e95c8e28ad469b7c6b71b1a2e6e33834dc7f6a16dc7f7", + "bytes": 3545 }, { "path": "apps/web/src/components/ConnectFailureInWorkspace.test.tsx", - "sha256": "10764debaada2c1714a24b285d371fbfba96b40b55f8e091a03939f0d915ba75", - "bytes": 7518 + "sha256": "6e842bfad199f4bac1ed1845f786677832915ad84d6661d64e05bdef8541cc73", + "bytes": 8522 }, { "path": "apps/web/src/components/ConnectedSourceCard.tsx", - "sha256": "2b8a516f96ace07f1c6b567164158cf938b23e0a0c75cbccba66f8b43e200115", - "bytes": 2146 + "sha256": "abd80377d2a187fda01087800d3912c7e92c42b66eb10acaff5435fc33f9331f", + "bytes": 2424 }, { "path": "apps/web/src/components/ContractV3Screens.test.tsx", - "sha256": "cc58a42347636f4d117d503d4f7495606cff79c5541327049f6ff543104ffdc0", - "bytes": 8555 + "sha256": "51ef0d6bd4e5d7a2946364442577ae6882eb8542fa698de804cdcf140105d2bc", + "bytes": 8907 }, { "path": "apps/web/src/components/ConversationEarlierRound.test.tsx", - "sha256": "03c9fc9d85345b51721aaf68481f616ee7b4227e4de4a14f4c958467229b1a71", - "bytes": 4994 + "sha256": "3dd43fd4a224ea81e81e2fda1a9bf4283c16e2742009b3a968dad480a9b8a38f", + "bytes": 5292 }, { "path": "apps/web/src/components/ConversationRunningTurn.test.tsx", - "sha256": "695fc6abf91ecfdc7f4f0a39645db3e41d2ac9ccb5377948500a4a9857b4a83d", - "bytes": 6581 + "sha256": "dc303c36e98647b184b1ff651e980e4e634c6c420e9a08e6921a907c7a549a81", + "bytes": 7128 }, { "path": "apps/web/src/components/ConversationTimeline.tsx", - "sha256": "8136396921dc0b61bf737ad57460c4b897326ebc069d9b24543d0c4937f3fa4c", - "bytes": 14619 + "sha256": "36f5e36c1f60855023b331abc2b04efb36a0e3b390fd4c9c2b389440edee4dbe", + "bytes": 16658 }, { "path": "apps/web/src/components/ConversationTimelineAutoRetry.test.tsx", - "sha256": "e05aa667cf229ac13079d734f1640881107f437dde34609d9cd455ca8b7cabc5", - "bytes": 4391 + "sha256": "50e083c876f500f64d88d2feda5aaea3ff8d5a0ca02ddba92329bcdae95f4eb9", + "bytes": 4842 }, { "path": "apps/web/src/components/ConversationTimelineLegacyEmpty.test.tsx", - "sha256": "cece4bdb55c1272d1846fa28cf8788f060f519d2cbe439c52d7ec5080dc53859", - "bytes": 2504 + "sha256": "3dc026d35365ab613a588ae8f3b59eaf1f5267c396a609ec883a7e8c3f319fab", + "bytes": 2615 }, { "path": "apps/web/src/components/ConversationTimelineRefetch.test.tsx", - "sha256": "a3c38da9b9f267c8cdb03ae7f32f8182af14e1535a9fe0367ad00ba20ba6e57e", - "bytes": 10359 + "sha256": "7b28df77268cf65cd14187deb6bba470d160ebdabe9c510537b7a29b353df662", + "bytes": 11397 }, { "path": "apps/web/src/components/ConversationTimelineSynthetic.test.tsx", - "sha256": "f2555226a0c32a7416487f81ac817118e0ba22ebe3b6355ed17008600a0561c8", - "bytes": 3879 + "sha256": "f04d903ac567c5fb8ed832606c6bf5040be527fae4196775cd03801ec4081e7e", + "bytes": 4028 }, { "path": "apps/web/src/components/ConversationTurnView.test.tsx", - "sha256": "7098c53ca1dca8fecee5937b30c1ca4c34d1bebb156ae7717c995b0d1819c7d4", - "bytes": 7338 + "sha256": "2c80a628ec424178e3a4328439c05917b8f60b4613bd0b639165db4046588b2b", + "bytes": 8068 }, { "path": "apps/web/src/components/ConversationTurnView.tsx", - "sha256": "408c1fb7c86f0afc1348ef5898287a4862a989d2127fae5bb07f43a9d91cc9a9", - "bytes": 3793 + "sha256": "3b0f7b39a17bb2ea5aaf4aed3c583a9d81d4ab3bf6b0b603af74cdd9ee989fc1", + "bytes": 4401 }, { "path": "apps/web/src/components/Disclosure.tsx", - "sha256": "a4276000ac565b5f51798a28d0f2fc2ac73493c2b7206ea68a1c7f48548a8fd6", - "bytes": 659 + "sha256": "53187e7aed978a5b4ec610195e447e0021c87f533936d51cea4521372cc64d2c", + "bytes": 765 }, { "path": "apps/web/src/components/DiscriminationRecords.test.tsx", - "sha256": "296deb6f6993012c5a31a42acf7f51e0ac2d381305fe4787149839c98e77065c", - "bytes": 7175 + "sha256": "41a32f0a47e90d07b098250b0ac6e61697e22bfd82f47c882a623e12e612b206", + "bytes": 7670 }, { "path": "apps/web/src/components/DomainRecords.tsx", - "sha256": "19752aee19b8894a20df5c6ab8a84aa681aec53fd0391f628780cecaceab34ef", - "bytes": 3827 + "sha256": "4b44e7c3502ba616121f6895e56f7bc3633ca8647a75b1be3e8f65190ad1be1f", + "bytes": 5030 }, { "path": "apps/web/src/components/EffectDeclarationFields.tsx", - "sha256": "39b6979d7faf4812145a32d4e3f283e0dff8e400d89c5eb9a9ea87c38f10d980", - "bytes": 1432 + "sha256": "4fda8d29b233110da5e751ae5f15158f5b07785fc8c99b79a2fc979cffa1f5b3", + "bytes": 1680 }, { "path": "apps/web/src/components/EvidenceRail.test.tsx", - "sha256": "5e4067422e6c06824b676281a1d73f3da661cd71a584e3cfbd7593db0f2f2ab2", - "bytes": 2364 + "sha256": "f53c4bbda2dbaa77b6de7d8aa20f08a5f81dea9a8de33343f666151cfbb73566", + "bytes": 2560 }, { "path": "apps/web/src/components/EvidenceRail.tsx", - "sha256": "bfea1fc7080953fd63d72054a5e34e9708c060689a1427b2f1d5d3648adf3c66", - "bytes": 5112 + "sha256": "6177f5c79e8b1950547efe575298088921b0a9ed880911d9a0a44902c2799342", + "bytes": 5520 }, { "path": "apps/web/src/components/EvidenceUseSummary.tsx", - "sha256": "4ac88a4545af939ed287911107b1a8ab72e2a1c577b768209026c13c84d9fc25", - "bytes": 1235 + "sha256": "fbef14c947ff9727de83072f61b9649d606225d88762a1a222f0e4b0c30d03f0", + "bytes": 1483 }, { "path": "apps/web/src/components/ExecutionHoldNotice.test.tsx", - "sha256": "9241cbe3d0115ccadcbcbc90c6dab56526ef5eb18fba6412bf887aec1ac2cb4d", - "bytes": 8452 + "sha256": "b2fcd172fd10bdbaac17f60411e0e163766ba9c205d73fff1dc5e7f511d4d8e2", + "bytes": 8921 }, { "path": "apps/web/src/components/ExecutionHoldNotice.tsx", - "sha256": "5444174743d902e1c97a86bf29d35c793318497fa4834849f5e3f9b8acf19c93", - "bytes": 5350 + "sha256": "a378dae0fc32df6148730ea1ba305195ff691601c3f64833491a7b05466278ea", + "bytes": 6312 }, { "path": "apps/web/src/components/ExpectedResults.tsx", - "sha256": "ee13e530af53d34d678b9dc583db6608be9455e0027d74666724535cc445197f", - "bytes": 3031 + "sha256": "1da45125c80edfb2124a851691eed05c93076b897c9d029ccb227f2179a73214", + "bytes": 4079 }, { "path": "apps/web/src/components/FileConnectionPanel.test.tsx", - "sha256": "31939fe160bf28062c677dadced11aca7bbe164024bc179fe76bc5f63312dfda", - "bytes": 16126 + "sha256": "2dd0adc28944c70f22227e58919357c8d8f16cd03f0687707999aa1f504a81f8", + "bytes": 16851 }, { "path": "apps/web/src/components/FileConnectionPanel.tsx", - "sha256": "3aa25b36baf537cb883b1ba7f078639a1ffca8ee2e2287d21a992e13289a24f7", - "bytes": 10943 + "sha256": "8d42ebbd697349fd02fecfbee5d433d0731bb994f18d14ddbe6bb94212cc0487", + "bytes": 12633 }, { "path": "apps/web/src/components/FileConnectionPanelParserError.test.tsx", - "sha256": "f0aa6794ac4f95425328a9be9cac3e4454203228548abdcb675699db5a27b58c", - "bytes": 4096 + "sha256": "37edc06ba5b418bda0e91c6742760dff93245542f8c5dfee44486771733ba349", + "bytes": 4306 }, { "path": "apps/web/src/components/FirstRunSetup.test.tsx", - "sha256": "e9a0b2637b2063e01913e668804996039111e874dfbf3fc465dd426e38acc70a", - "bytes": 100718 + "sha256": "194d8b726b2a494bfee3e34c74f809511de80c69d6153b00fe1de3f478560c7d", + "bytes": 109202 }, { "path": "apps/web/src/components/FirstRunSetup.tsx", - "sha256": "54a2e46203e7f1ac45d1b200725346c7d26b936d367eb664a3a0ef96f03f39e9", - "bytes": 16893 + "sha256": "2d872dc919a2a1b2bcb4f62b9a2e4818500b49a444077f76ec81dd1e0fa4a890", + "bytes": 21281 }, { "path": "apps/web/src/components/GroundingDisplay.test.tsx", - "sha256": "5f007c12a1a2d9631a7e1ce60f8a86e0c43801cc7a66aa569f535515982872be", - "bytes": 2898 + "sha256": "d8306552efbd2b7821b2e3a3dab7e30b9d770c3e76d97de1548d3b27d5ad2365", + "bytes": 3229 }, { "path": "apps/web/src/components/GroundingNotice.tsx", - "sha256": "e9d96ac6a17d801142554edbc9934325e519209ad41645952e391ba587c5608f", - "bytes": 648 + "sha256": "d8028e4c992dfe6c2082a17c29138e6ec2ee4bd258de8e617fcaa94eb0a5ba30", + "bytes": 708 }, { "path": "apps/web/src/components/HypothesisCompare.test.tsx", - "sha256": "fcce6c8fd20a89b844518a0395a66cd8afc8f334bb60d6fbfe2cd61234d8de8f", - "bytes": 8536 + "sha256": "7f68151e5386cf744592b083a3d223ad5383de9048dc508e6e437eea180c770e", + "bytes": 8851 }, { "path": "apps/web/src/components/HypothesisCompare.tsx", - "sha256": "44856caa4450d160966db974320dc998c657084dd20c7efc8c59c8c5ffe8eae7", - "bytes": 9561 + "sha256": "5470ba950e4af6752cf8d3729ae7f6090e886da4989446dba33cd9c4a3639d11", + "bytes": 12884 }, { "path": "apps/web/src/components/HypothesisCompareDraft.test.tsx", - "sha256": "1b41360c2c32fc6f6d3783904395315567ea02e7fbc9bb0bffe83350580093c5", - "bytes": 2517 + "sha256": "f611b1acfca5392395caeb8284f4d873e18a60070c4be946d30b028f414a6b9e", + "bytes": 2672 }, { "path": "apps/web/src/components/HypothesisCompareLive.tsx", - "sha256": "839cea04e84b76ead5bb847701172877b8259496f96210ad79d39224b0b1637e", - "bytes": 4400 + "sha256": "1fb3ba2daecf10544be4fb2de9269f444faac930a10d58e7f5e985dc9bd3c60e", + "bytes": 5296 }, { "path": "apps/web/src/components/HypothesisContractSwitch.test.tsx", - "sha256": "8f59233c433408a935bce18447b3cf0358c2843be5662e193e2886df5d6ef6a3", - "bytes": 4119 + "sha256": "036831fb8fcc8dd3292bd67ca19bef49e231a3665b457da41c75b8cdb638256f", + "bytes": 4317 }, { "path": "apps/web/src/components/HypothesisContractSwitch.tsx", - "sha256": "04ddd61b8cdda61e32387efa4edfd33a1e91cac122045894be11851053dde53e", - "bytes": 1763 + "sha256": "638e8c73bb26bf16171c0d3cbb617ae07b3d77b71339fab20a315ac38fade1dd", + "bytes": 1986 }, { "path": "apps/web/src/components/HypothesisEvidence.test.tsx", - "sha256": "39ec43303ba37231722cb609424b1eb10fa1f417cc624dceb789a5ab37c87f05", - "bytes": 13956 + "sha256": "9ab344e073ec62a8d2666e4044c628558d0df987500e394ccb83bcd3f0859e2b", + "bytes": 15192 }, { "path": "apps/web/src/components/HypothesisEvidencePanel.tsx", - "sha256": "5b6de517c2d6c6d503b055384021fbc752ba07bc70acf81b957db9e49a8fcb15", - "bytes": 1685 + "sha256": "9295dc729fd9a38affd38b2665fff5d21d9695f0cb92d60151e71a4095616a82", + "bytes": 1965 }, { "path": "apps/web/src/components/HypothesisFields.tsx", - "sha256": "ba326db7af1c356752cb319cda73e8f4a4b7fa1bd992a6031aa94f383dcedb9a", - "bytes": 2104 + "sha256": "78c71a09f94d6d98ff13d3fd31c94a5cf8086d34a0c4484dae4dea92a8e3a3e6", + "bytes": 2446 }, { "path": "apps/web/src/components/HypothesisLinkNotice.test.tsx", - "sha256": "7b2490d25984332094e7fbd8be8b292b138ed1f45d3ff5610a049d4c00d75d1f", - "bytes": 9213 + "sha256": "b8f5c5d4db9a082ef8e430938c0c90ff7a3d2406916d13eda55d37a431693197", + "bytes": 9573 }, { "path": "apps/web/src/components/HypothesisLinkNotice.tsx", - "sha256": "1f3273487e64bcdc7312d779b6ed5e50ab6f24696644645c7aebd86bacc7825a", - "bytes": 2690 + "sha256": "cbfecf93001ad7475cb4e577c5684aa577baa0be50f61107921b077173702902", + "bytes": 3109 }, { "path": "apps/web/src/components/HypothesisLinkScreens.test.tsx", - "sha256": "02f0b441af246a666addb63bdd717f395c88479e8d296353c96de55c4824b049", - "bytes": 3093 + "sha256": "c949b134b49c8596b522080697186b73362cf604653a4e84727a613f62adaba2", + "bytes": 3216 }, { "path": "apps/web/src/components/HypothesisReview.test.tsx", - "sha256": "9ed16b30a10acb02c7f31013c67e6133c9f0536f5d6d8a9615122800ada654fa", - "bytes": 8119 + "sha256": "ea78d2ff94b0846f20f07b5db9a399e5521f3040e1b8b1857f4cc237317e4c4a", + "bytes": 8615 }, { "path": "apps/web/src/components/HypothesisScreen.test.tsx", - "sha256": "6634a3640d05342515a3fdc6e652f62e993e95df6f653f86f5c4246fd9742471", - "bytes": 10652 + "sha256": "68b5fcea35b5250e0f182ce66d13f80c401612b969ee2bdbe89cac9cfba6d5fb", + "bytes": 11191 }, { "path": "apps/web/src/components/LanguageScreens.test.tsx", - "sha256": "9811f97941d1ed77931328b64cab200d4bfc38aabb3501c88a72a2a1860ba5e0", - "bytes": 5504 + "sha256": "a8823e7e1310ef6e6eddc104cca61b1ab25ee3fb3403287635d91bb8ca264053", + "bytes": 5724 }, { "path": "apps/web/src/components/LanguageSettings.test.tsx", - "sha256": "b918f89be63b350f73898d912c512c6f76a4c44173e7997996fbb2359ab429ba", - "bytes": 18466 + "sha256": "b3dd7872f4580cd6ff83a9959a358375906a8317b982ab0c29a35b592846f0ce", + "bytes": 19505 }, { "path": "apps/web/src/components/LanguageSettingsCard.tsx", - "sha256": "ab61dda20da3ba2ae6420b48fc75965bf27899a3c8b0796dc57f85bc98153c13", - "bytes": 2870 + "sha256": "1ea8ebfa8532698e747b8be1bcec330a68debaf152ddd17c9616f072389f3599", + "bytes": 3342 }, { "path": "apps/web/src/components/LessonRecall.test.tsx", - "sha256": "32520ec35f4e8f350b6f008a7c2a98888eb7a6e0ccc8610d3768f3f77050296c", - "bytes": 6831 + "sha256": "918d75f5b3546998eeca98f0c4cacb58ca03384e7bdb3926853401513f29f3e1", + "bytes": 7841 }, { "path": "apps/web/src/components/LessonRecall.tsx", - "sha256": "b6602023fc895bb6633136452b3e82549b0a76b4bdc08c4e80775ade8bd7ff92", - "bytes": 2091 + "sha256": "fb51bf39595fa57b313ccc1e08e48ca4161084050c07d4d3b3607dc852d5a2f7", + "bytes": 2578 }, { "path": "apps/web/src/components/LinkRecheckDialog.tsx", - "sha256": "f7139e3bce1dfead3d663cc6b35ea0c6e0b3af820c010b4cdc5f380cb12026cd", - "bytes": 3907 + "sha256": "e66ad3408299226fe867f02bfd7d4d8d5581feebaeccb9194230301418a92383", + "bytes": 4743 }, { "path": "apps/web/src/components/LiveProjectWorkspace.tsx", - "sha256": "12e5701eb723a065204cef20d37476503d8125be00d5c087dff4b1e458deaa0d", - "bytes": 28033 + "sha256": "55ffb72028d07ec2509750f5964550100994f1fd96ef1c82209a5fe28f5ce799", + "bytes": 35489 }, { "path": "apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx", - "sha256": "1e5837f0ac55a66537e1da61918d9e9cc65062136439817430ba01586b6a5f7d", - "bytes": 7732 + "sha256": "8e15c4f33ae7a34d49051d1b7782773fd5d49a67725b30a38e2e42187a578817", + "bytes": 8401 }, { "path": "apps/web/src/components/LiveProjectWorkspaceInvestigate.test.tsx", - "sha256": "44728f19f1c4f79f96fb94d6221614fb990963448206f09c982d4066d144a92c", - "bytes": 11595 + "sha256": "7012dc2c93d36908e0ca155603ea04083300f212bf9ec7e473eb2421e83dcc4a", + "bytes": 12730 }, { "path": "apps/web/src/components/LiveProjectWorkspaceReadiness.test.tsx", - "sha256": "bbd428b57403d28b64cafbc4e0f82c0e3b3296c7f62aa4f7ed3fa119d0665fc3", - "bytes": 8276 + "sha256": "af63a05d54639230e3c48143b23b792d511659bc80bd4c8c63dacfa5d7b8bcdb", + "bytes": 9111 }, { "path": "apps/web/src/components/LiveProjectWorkspaceRestart.test.tsx", - "sha256": "c2acb9dfd8885153a4a3392090ce742ed999b5c1a63adca5da024749a6e1abc0", - "bytes": 43945 + "sha256": "82ae36f7f93f184cbb0e8daa7717911db53c1a498f12b250834ab116dfa656e0", + "bytes": 47124 }, { "path": "apps/web/src/components/LocalRetrievalSettingsCard.test.tsx", - "sha256": "f1b02c08d2998748876f30d84daa6a881fc4475fef17e9e569fc9349b0aacad1", - "bytes": 41354 + "sha256": "d12109500b9671049dafe427bd6d07bde2307719aa4bf84fcd3532af6f93b733", + "bytes": 43000 }, { "path": "apps/web/src/components/LocalRetrievalSettingsCard.tsx", - "sha256": "0367452313cb4bc80c3577dd817d99e45dbb4c6602eb53f8d50c247788f47fb8", - "bytes": 24584 + "sha256": "3940b7ef33eca802c8e315e124e16e4014c8d633e09941bde1ffc24283c1bfda", + "bytes": 28333 }, { "path": "apps/web/src/components/M7LiveReadDenial.test.tsx", - "sha256": "cbd3c44db6daa194ace574bf14a357ee2b34c5a23257fda3484f99904f06c877", - "bytes": 12994 + "sha256": "2a37e838d4ee85e91137ca8ec2d63c8b2da2fb457a300921a7212f7b2d1bb9bd", + "bytes": 14769 }, { "path": "apps/web/src/components/M7OwnerSiblings.test.tsx", - "sha256": "b9b504e23f258d747a5d8e152ba07815103fdcf5f0e4d646d3b6b71a45e11cb8", - "bytes": 14761 + "sha256": "a55e6130d55f03d7051674b660b557478d48337c8ad1ce8543143878de7489d2", + "bytes": 16329 }, { "path": "apps/web/src/components/M7ReadDenial.test.tsx", - "sha256": "b4adb23006bee5ee86fa5adea26149d2ea253ca302ccc9032f4e6eeea3032916", - "bytes": 12304 + "sha256": "b6fef56cfea0e710974b39f52c1d43fd186c052c8ec95cc022f97ac9dfc2ff8a", + "bytes": 15566 }, { "path": "apps/web/src/components/M7RemainingOwnership.test.tsx", - "sha256": "798ee0761fffcebbe394d9ddbc9a695caf53ff8151b53b3e3c77e1b76945230d", - "bytes": 17284 + "sha256": "8317834c7569295dd8c9e6992038c0a12503338e37e643ea0df1ebe3ff7a854a", + "bytes": 18860 }, { "path": "apps/web/src/components/M7ScopeBoundary.test.tsx", - "sha256": "691942b71196c3790d2801def080d7dcd492dc8362f5727162bf65fe425b288f", - "bytes": 4844 + "sha256": "c11bd5485e30c3ca111dfb9fd8743988a0359b719f4cfd81acf45d9f88bae5ee", + "bytes": 5139 }, { "path": "apps/web/src/components/M7StateOwnership.test.tsx", - "sha256": "ac31b0eb3c3bf60c463a080520ae2bd154ddb7b64bc4f27f8cec5e2dd14c10e2", - "bytes": 14978 + "sha256": "5936f27a734f823de61ae9fc1ceae3e87f270448d84f3f9301cdc311c2cd46eb", + "bytes": 16672 }, { "path": "apps/web/src/components/MemoryEditDialog.tsx", - "sha256": "7aee50c3685b58327767cac435474ebe0d32eb88c4360494908ab4838a096f3a", - "bytes": 2829 + "sha256": "a4225c056bf53f6993b1dc3abd3a151809416086d23d2f4586b6ae95e6489a08", + "bytes": 3546 }, { "path": "apps/web/src/components/MemoryInjectionSwitch.tsx", - "sha256": "798038719832ce305f45661f2cb26420a80d3f69946e7e8cf3e4162e31e5e3ee", - "bytes": 2308 + "sha256": "09996ee2454320f97c6de48b888acfe0f6e91c07b61e920ee4868a05978be914", + "bytes": 2564 }, { "path": "apps/web/src/components/ModelCredentialPanel.tsx", - "sha256": "9416b4bdfac6f4c1f5ea67c2d46d66fd96bbb054ee7cd795fbd12f896c79870e", - "bytes": 7209 + "sha256": "d4b8a248031ea586d5625bf3600a7cec90f3b200d47ee288337ce5a21a63747a", + "bytes": 8210 }, { "path": "apps/web/src/components/ModelSettings.test.tsx", - "sha256": "0b7dcfe90074e28a61dae40fe6a38c1a88ab8a6fbebd2fe08ae87870f560507e", - "bytes": 28719 + "sha256": "f13b2db303de19e511aad15972c5281582976fe5bca0480b584848ba6020b97a", + "bytes": 31280 }, { "path": "apps/web/src/components/ModelSettings.tsx", - "sha256": "0757d29a8dcddc0302d93749b7b3395766e44fde1131ae14a022dc5a857cf104", - "bytes": 10724 + "sha256": "47892ceac94dc13d860689e91a58c209317683909739dd9871c34622b669a2f8", + "bytes": 12292 }, { "path": "apps/web/src/components/NavigationDrawer.test.tsx", - "sha256": "06a669a705a06426260af646f6160fab438411c3264142d2d66c4829e5499f26", - "bytes": 11617 + "sha256": "9f7cf1082c4a87012a819952e894bf626176cd48438013de5ef533751115087f", + "bytes": 12675 }, { "path": "apps/web/src/components/OAuthLoginFlow.tsx", - "sha256": "c5fe884c283a1698982d39a52083aeb79ca3592f61e6681a97c40d7bbac35966", - "bytes": 18361 + "sha256": "465fea02e008ddc6ee1334bbc49cfb1521811d8448848984ef821b2fa832987a", + "bytes": 21390 }, { "path": "apps/web/src/components/ProjectActionNotice.tsx", - "sha256": "2fa033b5c5f8a1511445eb3c860f0a5c1b16c4524b52541a2329bd8faad310b5", - "bytes": 577 + "sha256": "ee839fdb51347a1da131e0054bcab119b4302e728f378e9b79ff61201e25c3ad", + "bytes": 608 }, { "path": "apps/web/src/components/ProjectArchiveRestore.test.tsx", - "sha256": "8b7a0227a4702fcdb272822a930fc622371ad0a926200b3a39955cfe8213c8c0", - "bytes": 16946 + "sha256": "2284484f5437aa9f77df3258fd9b30b9bae3ea98096dcc38077bb5fde9143557", + "bytes": 18442 }, { "path": "apps/web/src/components/ProjectForm.test.tsx", - "sha256": "6bcf342d5e95785ffa4970491fdd39f219b43bc131ac266896406a247c04d154", - "bytes": 1607 + "sha256": "5248de8aac879bfe32e107fd39efcdf63da51d7bd4f1b95a2c3a070ca1fd3dee", + "bytes": 1665 }, { "path": "apps/web/src/components/ProjectForm.tsx", - "sha256": "9e767d7ebe9428945219d1a34d9772a44c6584ad921fa00a4b2a20497dcef33f", - "bytes": 3838 + "sha256": "3a4c0d599de363e4e78ae0a60509fc1921e605bffd6eeca0945da00668143772", + "bytes": 4334 }, { "path": "apps/web/src/components/ProjectLanding.test.tsx", - "sha256": "7320a8b86b880d32d0bf66d06452c0ca1ecaf72b743fc5c5c76786b9c1758cea", - "bytes": 1103 + "sha256": "83f642a6c2d6422be071372b0b9557b47af9b29172ad96cdba4f2782fbafdb10", + "bytes": 1137 }, { "path": "apps/web/src/components/ProjectManagement.test.tsx", - "sha256": "5008e8fe33ce588e3c93adb50de1bfac50db769d36487cb7a7101dcd2c567554", - "bytes": 13755 + "sha256": "7bbfc2ae9dad7e4aed3614f7ea0d2dbdd66a012d870b4d14a0905266fa9a49be", + "bytes": 14508 }, { "path": "apps/web/src/components/ProjectMemoryPanel.test.tsx", - "sha256": "d09f0e62b7f7e00ffccd5853a0b1e2ba5be564519926c4642cd272768f7ac24f", - "bytes": 17389 + "sha256": "9ee0dcf7f8b52a00cb4d2997fd6f477814ca241792db1e8cee62d797b6568642", + "bytes": 18543 }, { "path": "apps/web/src/components/ProjectMemoryPanel.tsx", - "sha256": "7aecdfa4c4744131e32f9b4248b681456327633b942950efafa661cea8d98397", - "bytes": 6268 + "sha256": "a26221ccf42758f41f99f27ceb9ebb3f79546e816a3b215f4306a4607f083df6", + "bytes": 7739 }, { "path": "apps/web/src/components/ProjectNavItem.tsx", - "sha256": "dfd32ef3fd63949ae31509bb3a558cf0a55f86ee13e45c7be698036467fbf7ce", - "bytes": 1721 + "sha256": "8da10b7e29e66e898ae560712a863eab155c1d2947ce773f2d9eb744dba54528", + "bytes": 1951 }, { "path": "apps/web/src/components/ProjectPermanentDelete.test.tsx", - "sha256": "2944322e29a8969f4d429b1209a9103596dd12ac0bc4d820ab5843ed1373900d", - "bytes": 12584 + "sha256": "c5288fb1beaa2cb5da94798c0055ad8cb9915665837e7c2a2a90737f8840b993", + "bytes": 13276 }, { "path": "apps/web/src/components/ProjectPermanentDelete.tsx", - "sha256": "a676887bae1283d3350ef0d1fb66bcca55582d609d467a0b5b80f12979d3c57b", - "bytes": 3095 + "sha256": "9a6201b8a063fc8e89fb5bd225c0f742473f777d3b0f724e429bbe0c9a74dfc4", + "bytes": 3769 }, { "path": "apps/web/src/components/ProjectReadinessCard.tsx", - "sha256": "c58cf2436e88b254585bce41aac6506c9185bf0d9482973fa73b7a6c09d4c247", - "bytes": 3386 + "sha256": "fabfb0c488d721f68e9d15abf857685a1dcf2e3ed6d5dc60ee4eb6a329bed29c", + "bytes": 3780 }, { "path": "apps/web/src/components/ProjectSessionPages.tsx", - "sha256": "3ac79479989184e9a69b18ef3e99f41adde096a01c9c7301a52e13bf8808d083", - "bytes": 10736 + "sha256": "ba30f1536e7d578abade97edcb8b376686aede3d688ee7751d9d23a4b6029c86", + "bytes": 12920 }, { "path": "apps/web/src/components/ProjectWebSetup.test.tsx", - "sha256": "19bf24b5e1aa2ba577a6e0aa0874a34b64e18f4be7a276c50be7039303e27962", - "bytes": 8357 + "sha256": "49b3053bde61faf55f8738445b5447ae8715f2a34a1a7b4440800eecbf3df079", + "bytes": 8388 }, { "path": "apps/web/src/components/ProjectWebSetup.tsx", - "sha256": "9124a46f5bf4a38c433adfb5f826a9a93097fa7ac18444735a2bb2500e3b0473", - "bytes": 6655 + "sha256": "8765924ed6bd663f793b4e17042b0f70ca574e270f0a951b27c57a2d4b723996", + "bytes": 7016 }, { "path": "apps/web/src/components/ReadableList.tsx", - "sha256": "a92d79cc6453308c2da2aefd2ff34d52b1e469c578f3a62855912779a460cc23", - "bytes": 1232 + "sha256": "aecdf7dde040b7e7c9f09b4156d532641cd208d53a225de694bd5021b2e7b4f0", + "bytes": 1373 }, { "path": "apps/web/src/components/RecordInspector.tsx", - "sha256": "5d1f49240f07f81ffc0d294caae26a3abfe37c0674e44de46059fce59f4e41e2", - "bytes": 1769 + "sha256": "536c93c422479885a6b46d4efe054381660b3aa4cc6ef1ae0a7c608b26b92efd", + "bytes": 2080 }, { "path": "apps/web/src/components/RecordedStanding.test.tsx", - "sha256": "8dbccad7188e7b6531ec3e4876bc6c7be0343a32c01d9137253f6623fa808cbb", - "bytes": 6376 + "sha256": "0ee1c9d2a72e1943b4c60adcbbfcca1f56da0cbb7100ae7d2773405b4da77d90", + "bytes": 6749 }, { "path": "apps/web/src/components/RefutationConditionsPanel.tsx", - "sha256": "3592de59fa44f421774cdd017ed55f95efdc90c62080edba755ba705c9cc20dc", - "bytes": 3467 + "sha256": "e12c8d66db77ca37bb951f9f73f60aa93ff5806b8fe1e9d2573816ea79904cf7", + "bytes": 4140 }, { "path": "apps/web/src/components/RenameDialog.tsx", - "sha256": "fa06451e1faf5d610428cd71a56367730c231ef4e94336adaca2ea1aa60c43a2", - "bytes": 2181 + "sha256": "cb0db1672bc6b9b8021f85f42433e6ec32a84aeff5fdaa2e3e4d9741b7099511", + "bytes": 2355 }, { "path": "apps/web/src/components/RequestLanguageFlow.test.tsx", - "sha256": "99a9f70f186ad24d073cdf22ccfcb3e8fcc1e904a1df1aca3caa868e2d5d6b24", - "bytes": 4630 + "sha256": "1c8e3b7e39398a1141cc04a69412bc6e78efd0be6905d2f23638a947a82a3bdf", + "bytes": 5158 }, { "path": "apps/web/src/components/ResearchActivityStrip.test.tsx", - "sha256": "2c0e55f727fbae8f1c8a33f192743ce0344af406a59069069b15e19ccf7829e8", - "bytes": 5115 + "sha256": "527c784d12485e0900f20e6772462585cf6901d34eeb2d8178ca50651232491f", + "bytes": 6098 }, { "path": "apps/web/src/components/ResearchActivityStrip.tsx", - "sha256": "32a14e8f90833cb43df126499387be3df04840717104e1a62316e0ee61c0e836", - "bytes": 9719 + "sha256": "0433cacd394d9176563b079d14dc1a76c0cbdc51ca723f926a5256de4d3d4e4a", + "bytes": 11170 }, { "path": "apps/web/src/components/ResearchCancelledPresentation.test.tsx", - "sha256": "b851dad7f54f494625719f6a6da1f0419706723bae4b0c77410553d9e16c4899", - "bytes": 3003 + "sha256": "2e334be1041a5024830f46a1fb176483a1c158d7342b3e1aa3992ead67c68148", + "bytes": 3144 }, { "path": "apps/web/src/components/ResearchComposer.tsx", - "sha256": "08aaa902b280612188a637ad19fa7bdb763334a75a13b88d6f56366347698903", - "bytes": 9361 + "sha256": "f3728ef6941cd5c5687218e54b053613364c5b479bd0c36a10be03dcbdc7efeb", + "bytes": 11302 }, { "path": "apps/web/src/components/ResearchDetailPane.tsx", - "sha256": "29047d0c9974f89b9179b052a8e02cc038bfdaf9ef407303c19c9ba13a3919d5", - "bytes": 5300 + "sha256": "7fb74efbd7498e597c58d1d21f135472050bed41e460ca448afd2f85ff8c2435", + "bytes": 7538 }, { "path": "apps/web/src/components/ResearchEvidenceDetail.test.tsx", - "sha256": "59bc0277811633a703c92aac1d34b28ffe0b1a92a5f5f40058067cd0558d7da3", - "bytes": 11490 + "sha256": "52512abc92a55888801befb354c933c085b6b320f2f5ef62a5dde4b84ac6e318", + "bytes": 12400 }, { "path": "apps/web/src/components/ResearchFollowupSummary.tsx", - "sha256": "acb7eea96e3f9e9fb97d7a9acb2dbad1fb44075c5606bcaa24189c02794981bd", - "bytes": 7271 + "sha256": "29301bcedf47f8e843b528955b02d1cea930a385a5cf5c88e1e0325986add591", + "bytes": 8912 }, { "path": "apps/web/src/components/ResearchHold.test.tsx", - "sha256": "63fd95bb3e2e9f442a1189cff79b906c16d6ceec9a8e7ce8ef4d44de3868fe6b", - "bytes": 7648 + "sha256": "48fe09bc194ae34375dcf9c38bcee715628fbc0352328bf0f1f536aa22286afd", + "bytes": 8161 }, { "path": "apps/web/src/components/ResearchLiveProgress.tsx", - "sha256": "8e4e5ba3c13972410180e5d1d7540d33dea5705d6d8427974aae0cc542c9a51a", - "bytes": 4342 + "sha256": "2a0b552197738dbe1d636206b72c6be560c8372bd304928b7b450b9bc36d561c", + "bytes": 4956 }, { "path": "apps/web/src/components/ResearchProgress.tsx", - "sha256": "23c07314317363431cd7cc328c47839cb1006272cca952ea56fef3cc74f2e0d2", - "bytes": 3143 + "sha256": "d42fc3baa6c9b64e8df4e4954e3c726f5a3b29e051bec871cca2a185e03d06ab", + "bytes": 3771 }, { "path": "apps/web/src/components/ResearchProgressStop.test.tsx", - "sha256": "12cc74b145fc2d557fdc57a8821d1ecb92567fd1ac2fccbbc6359fe0bfd74acc", - "bytes": 2432 + "sha256": "c3cc05dd211c2d0b467e02a090d0a011d67ef08f85b86a8f31413261ef75a66e", + "bytes": 2495 }, { "path": "apps/web/src/components/ResearchResultCard.test.tsx", - "sha256": "9c6784e23bd9a36d718c237e042afcd0215c5873e6f8eb2c2fc4eedbb4c50545", - "bytes": 8842 + "sha256": "0ed191a1f6acae3bea3d89506ef21a4da9ead0e76eed3595f985502e08046f6b", + "bytes": 10469 }, { "path": "apps/web/src/components/ResearchResultCard.tsx", - "sha256": "2b0c9f3492f68b79f12cc64ed370d439dc925577175da94b1aaf0e0574223813", - "bytes": 9512 + "sha256": "9e45591a694922f89e050507a60d3af4bcbdcbe19cc2de9420f68b5a73c681aa", + "bytes": 12455 }, { "path": "apps/web/src/components/ResearchSidePanel.tsx", - "sha256": "bcdb3d9bfe291d06ed3c6e647f7aa983150c1755dca86520f706470bf9c68c12", - "bytes": 2898 + "sha256": "63d5311660e193e54fa359d036c4bd11ce8a52f13bbae7cf8ef9fa70c3bbaaad", + "bytes": 3043 }, { "path": "apps/web/src/components/ResourcesPage.test.tsx", - "sha256": "e867dba8d2646895313bdfebe65244abf38ce6a469a0baad355a6d9f93c3328e", - "bytes": 6747 + "sha256": "e2ea57c35ef979817fa108e0281b1399705406cababca125e295f7b06c1532f5", + "bytes": 7179 }, { "path": "apps/web/src/components/ResultCardOnce.test.tsx", - "sha256": "0b9ddeb1633417558b061bbd50eaf2becc0f54daa2ccf6487da1c91b83fbeb54", - "bytes": 13886 + "sha256": "db94ffc0819a7128835b1d4ce4191a65381979e4d794f6a799052b74ad8207b5", + "bytes": 14377 }, { "path": "apps/web/src/components/ResultEvidencePanel.tsx", - "sha256": "07047caa788c375c59e74f854f9f5d34000a85397880c0430f6fef59cc9569e5", - "bytes": 4777 + "sha256": "8ce8e749af5e0d06897710853d6a9efe0841a95a51c45a371d53a4da93ef7de1", + "bytes": 5694 }, { "path": "apps/web/src/components/ResultExample.tsx", - "sha256": "003a535a68cc39c3c0ee3c0ba9f3fccd53acc1026b95391ac1ac1f38a5aa47f8", - "bytes": 7881 + "sha256": "b8e44e4666896ada6fff52a286240fca77dd12d65309a9cc652e47e0c773314d", + "bytes": 9388 }, { "path": "apps/web/src/components/ResultHoldDetails.tsx", - "sha256": "6b378960876ac027cf61328b3fd9fdf4b35a57b6927f0e04ec71b081b13969e4", - "bytes": 3742 + "sha256": "eeaa8427a896c1aad9923684dddd1ce620a155808ef1138d6d0d8924f97e81ce", + "bytes": 3992 }, { "path": "apps/web/src/components/ResultInterruptionNotice.test.tsx", - "sha256": "589f24bc300065afd763c3255e2e65bbb8afcf6edf66f206a3fa1646a5949f73", - "bytes": 9261 + "sha256": "a80dd69ffcc1bac7a4975978f98eb5b6a52057cf8ef7f578338a49f41ee3c27d", + "bytes": 9611 }, { "path": "apps/web/src/components/ResultInterruptionNotice.tsx", - "sha256": "2c5841d49f7cb494f6b87eceaff79af5ffdadeee5a30847cda32105de28c5e89", - "bytes": 4955 + "sha256": "36455548677e3bd8ac4dd24d17214903ad70dc44aea387c986430f57b4b8844d", + "bytes": 5550 }, { "path": "apps/web/src/components/ResultScreen.test.tsx", - "sha256": "3967378888eee232dbc23ba5d9aa66e84476fe8659c5f1824a7e2b0f38162307", - "bytes": 13672 + "sha256": "8d10e6dc6f7d51402845ddb41025d425c5713b7f680f29de50410e4ebfa58129", + "bytes": 14446 }, { "path": "apps/web/src/components/ResultStatusStrip.test.tsx", - "sha256": "9f7cadb74497a4eca64b3270b8b47df7e5b19c4bd9f0bbaf0028a3133ec0d243", - "bytes": 5904 + "sha256": "759ff9f7dd0782e9aade4413bdaefe316406d62f27300a574152d568983f5388", + "bytes": 6280 }, { "path": "apps/web/src/components/ResultStatusStrip.tsx", - "sha256": "ecd8c5684dfb45b7034e05e15b41dae097284ff4ac32efee910208b6385d62dc", - "bytes": 2273 + "sha256": "cf6a2ee8e0e1a87b7f46b747b8f74e684e3bb1e5062b9bbaa40fab962f1e883e", + "bytes": 2391 }, { "path": "apps/web/src/components/ReviewDialog.tsx", - "sha256": "ef32ed2fc5646873d0113a0db1e9a22223442a0af153f77bf9b71b4bf0b4c2fd", - "bytes": 2600 + "sha256": "dd753ddf8ef60287a1734853686329d53328bcb7afdb1b1e60aa684f756dfaed", + "bytes": 3203 }, { "path": "apps/web/src/components/ReviewStatusLine.tsx", - "sha256": "e9327ead26e3c7677673244c297896d8866c95c9600b82d0ae1e72b0816574ec", - "bytes": 1746 + "sha256": "7010052dadaa6d33a17b4f9f6e16cd52a06da3a21c1555a85b4bb8d510e46dbf", + "bytes": 2096 }, { "path": "apps/web/src/components/SameHypothesis.test.tsx", - "sha256": "b970b46fe1090830dbde8aa196b498c99184c2e1dd8a7c7a73ca14e3fdaf8a18", - "bytes": 9634 + "sha256": "7bb960dcf89811ed41e0984cc195f26abbd8b81018018dc03366cbd792bbbd89", + "bytes": 10237 }, { "path": "apps/web/src/components/SameHypothesis.tsx", - "sha256": "7903291351348081e58962e3589f63a929ee70e3f3b53d2249486a0bc897b056", - "bytes": 4538 + "sha256": "0fc1f3f0c2461ad0b5512bd4ee6861d799644a2c8e3b23bcb656613c5d24262c", + "bytes": 6000 }, { "path": "apps/web/src/components/Select.test.tsx", - "sha256": "5541d260758ef0a8cee89ddb461848ed89c0ae33118fcd8f832c950165e9c900", - "bytes": 1353 + "sha256": "fd34c552746ee5110cfbb37ec0f7b1bf0b18d9b9d326226e340a9c3d238cb872", + "bytes": 1406 }, { "path": "apps/web/src/components/Select.tsx", @@ -1338,8 +1343,8 @@ }, { "path": "apps/web/src/components/Sentences.test.tsx", - "sha256": "ea1a23a11154e20d43e42bb7fb98778998c4708637feb2a6d07e8391446d56a2", - "bytes": 1710 + "sha256": "0d083b0b2dd5402800297f657d2e78d9fc8ea5112f015880de6aa03440b3b064", + "bytes": 1755 }, { "path": "apps/web/src/components/Sentences.tsx", @@ -1348,73 +1353,73 @@ }, { "path": "apps/web/src/components/SourceCitationGate.test.tsx", - "sha256": "ae24400610ed28eb75a4e2509e8dca502554bb723dec5ead64c9e6983330d856", - "bytes": 6013 + "sha256": "0e90cc74339b20259358dcbe0974b45a919b555401775655b213925bf51290f6", + "bytes": 6425 }, { "path": "apps/web/src/components/SourceCitationGate.tsx", - "sha256": "272610de919bc475dc22007fe1a8c7a99c6f5320bd1164ac4c73e285990feffb", - "bytes": 4881 + "sha256": "3806a6b8cfda4abbf7c8c3c139a7ea2717af89d710118ae46d1d67e3731a8535", + "bytes": 5391 }, { "path": "apps/web/src/components/SourceDisconnect.tsx", - "sha256": "faa7eb847aade55a8b3305dff75bb10906b43f89bd53094917f50fb4c7344764", - "bytes": 3668 + "sha256": "8fd6f421902bfb8a1dfb223959d12678189e93e5fcab42e65a1b0a06ae3af0fb", + "bytes": 3681 }, { "path": "apps/web/src/components/SourceTimeAdvanced.tsx", - "sha256": "0683561a5721cd80fee8e3269bef1eef49602679caffc31d89319cb413126db5", - "bytes": 3761 + "sha256": "dbed6cb503c62edb4838486f7a7cbcd034303c868658d642107e7b41d87334a0", + "bytes": 5619 }, { "path": "apps/web/src/components/SourceTimeConfirmationDialog.tsx", - "sha256": "ece207a799e850d282e27fc6e938f96facce580f0bad4ac665be31b54ea977a9", - "bytes": 952 + "sha256": "b0b2a11fc2c2cd0636ceafe0e11c11736c3fcb1875ebe0c715fe61b81d986975", + "bytes": 1179 }, { "path": "apps/web/src/components/StatusBadge.tsx", - "sha256": "81b1347acf2649b9ed597cfef593aa8f5b1793342ee4ca39fd267bf160bced39", - "bytes": 792 + "sha256": "d2a6d94a37c84d83ff67f64e350000ed908e02d94d3ba2abb260aac06be076d5", + "bytes": 884 }, { "path": "apps/web/src/components/SyntheticDataNotice.tsx", - "sha256": "6d2fec5ad66dc60538f3996c23dc4d38050b7045be912d2661c85efa174e62f4", - "bytes": 1007 + "sha256": "47eb06aab227485f3926c2ee6ac5e565bd87b73ad4b306a6f8b30c063c3c5a35", + "bytes": 1086 }, { "path": "apps/web/src/components/TechnicalDetails.tsx", - "sha256": "486b4211123d26d7b48416a86acf41fed6e9696d429962dd5a465700b08f35ed", - "bytes": 888 + "sha256": "7cef4199c8e26d803773014491c55f444254c337691db1db0e4c177a4c2852fb", + "bytes": 907 }, { "path": "apps/web/src/components/TestActionDraft.test.tsx", - "sha256": "c612ada0927f1bf6fa2b74416cc47bc4847d2d91922382cc930338c6ee013f54", - "bytes": 8549 + "sha256": "839ace4951fd657058f16186b6a858ee8844fb3f284a5ceec1c58d4a8a896ab8", + "bytes": 8903 }, { "path": "apps/web/src/components/TestActionDraft.tsx", - "sha256": "4d67a028b09d6d0bd1975646f2737db861420c1c1b8a67e9c6cbe55641ca06f3", - "bytes": 2521 + "sha256": "85698fcdbf89756c2fd17609c2d0906e9a628e9c9282318ddd63340126b5305e", + "bytes": 2809 }, { "path": "apps/web/src/components/TestOrder.test.tsx", - "sha256": "9e20a618f4b73e9cb77ee0d242d0fa0d153f4f6db81133c8565277c82ad09a3d", - "bytes": 8572 + "sha256": "06b00bd48b85edf0c406406623ef5aa28bc688a62e5dfdb88a5de7e7e94e77c9", + "bytes": 9303 }, { "path": "apps/web/src/components/TestOrderSection.tsx", - "sha256": "99365cbdbea3cb93d33f9870aa26bbe2099a8c6bc13632c88d09b765f649758e", - "bytes": 2370 + "sha256": "e7b94eeccf24768ed514557f2d6a9192967a6a922064355ee57bc96aa5f38a76", + "bytes": 3132 }, { "path": "apps/web/src/components/TestResultRecorder.tsx", - "sha256": "a16642d5e4242d889a0eebc0691bdceaf0d99aeb0a69b64324a6531648ce2ab5", - "bytes": 4221 + "sha256": "351242b6fa7ab4ecb577f94a25c4df4a44db9e735a77061953983fa7cc46ed31", + "bytes": 5352 }, { "path": "apps/web/src/components/ThothBlueprint.test.tsx", - "sha256": "0113ee9e811618c39ae9d614efb9809d43fa9cbaf1fa4e032202646fa0f92c05", - "bytes": 1674 + "sha256": "e5876199b6525ea9a3b20471bf68c8ef71d7f727a611cc639ddae431032b2767", + "bytes": 1807 }, { "path": "apps/web/src/components/ThothBlueprint.tsx", @@ -1423,143 +1428,143 @@ }, { "path": "apps/web/src/components/ThreadNavItem.tsx", - "sha256": "b10808182d89fb7ba1ce7dc8b584b869f3bb461dd9cbbdf59c7cb538fff851e7", - "bytes": 1986 + "sha256": "4c8ce9884827b996504bddfa0b21c8a973fe5114d57fe2c877189357aaebefae", + "bytes": 2163 }, { "path": "apps/web/src/components/ToolInstallHelp.tsx", - "sha256": "f96db4ace9d71e4448087cf5c81ebd873b694c5908d622c3f457a747b807a5bc", - "bytes": 2894 + "sha256": "456a7839927f1de2c0f49a7f1149e395abce2b8b202ea53d212fd1e9b71a569d", + "bytes": 3222 }, { "path": "apps/web/src/components/TraceClosure.test.tsx", - "sha256": "40257b5f3d6d5f20519ff3d016a8139ad943733a65da20cf4c2265693d576544", - "bytes": 8558 + "sha256": "a82a4d307c205982a18dfb1b56a21958fd81037072714865c716074210c8f4ab", + "bytes": 8993 }, { "path": "apps/web/src/components/TraceConfirmDialog.tsx", - "sha256": "17fb00cb471ab28a7485a417f083ac3ab3cc73d6d306b96920aab2cf4618e679", - "bytes": 2042 + "sha256": "b872c022ec044bc8f79ef8dd05bc89de5e7464c943894e1ca2a8ca8c69b6ab52", + "bytes": 2485 }, { "path": "apps/web/src/components/TraceEvidenceDialog.tsx", - "sha256": "ec442c1f4d12dd63a544b15abdea4ee136cecbd4825ffda813447df27f26997f", - "bytes": 2493 + "sha256": "5eb1a178d5089e93a5585fd3174f325e9bb3cf19a971644baf68189c5bb1a3b8", + "bytes": 2933 }, { "path": "apps/web/src/components/TraceHistory.tsx", - "sha256": "6c22718e448490a2b787301554ea1a2c63507bb99bf7ea83d77c70f7ddf41b75", - "bytes": 5813 + "sha256": "93a5a5b430826af7bc2570a303aa31fad3357246dd7d7bbfbd74940552cde0b1", + "bytes": 7316 }, { "path": "apps/web/src/components/TraceImportDialog.tsx", - "sha256": "3718ebe97833f9cfb9a093c9911e2d6653d5973946577d6906ed8902ded3388f", - "bytes": 7716 + "sha256": "c9c7b267af497c94ceeea1bbd0dbf51baf3d0904e1e94111d44046fb991f8e3a", + "bytes": 9500 }, { "path": "apps/web/src/components/TraceOriginLine.tsx", - "sha256": "5c4541df58c2edb2235479ecb47a27cdad10f0ff852de15b2d8946cea0e2a655", - "bytes": 970 + "sha256": "3b467ddbb631e14d533f831adf90e4292988a755fd07e9b72eb806892a2b8a78", + "bytes": 1038 }, { "path": "apps/web/src/components/TracePage.test.tsx", - "sha256": "ef3a25b3cab2a5b2115afe4eeaf403f43533a138b9b4335310b806af33d69ae9", - "bytes": 30782 + "sha256": "552bf78f7786d5d59de2724f5193b7b082d915b450d93224f426c61576d998b7", + "bytes": 32046 }, { "path": "apps/web/src/components/TracePage.tsx", - "sha256": "0611867b5b627b5ac5b371d74f9fc4efaaf6a4b9eec348dfcd81bdc32e36f3cd", - "bytes": 16822 + "sha256": "30af525af1fa8e98684ca9b095e9e7e9ee41ab60794bbf272c386688a25ddbb9", + "bytes": 22856 }, { "path": "apps/web/src/components/UsageSummary.test.tsx", - "sha256": "dd3ea89f53d26e7e52490e3410066acf093f809381218941be91a628077c0ccc", - "bytes": 1299 + "sha256": "2f8f9b00a300e2833e9edb5a288529a282815838f84b8b24790d2c120a0980f0", + "bytes": 1424 }, { "path": "apps/web/src/components/WorkspaceContent.test.tsx", - "sha256": "7473a034baafcc7964095986e46b48e0ceac098692bb7103d8451f222dced7a4", - "bytes": 2522 + "sha256": "2f28dbee402ff5bf079581222aa504b6834b50f47a7d528bca5614d207e84624", + "bytes": 2531 }, { "path": "apps/web/src/components/WorkspaceContent.tsx", - "sha256": "9862a1afc1e91b699fb9c53eb03f711f75f0aa0f13664476e96956fb3fa1f933", - "bytes": 735 + "sha256": "beb2837d21d39a3665b3dfe9af067cf7bc197016fc1239c8694eb8d2777e2410", + "bytes": 742 }, { "path": "apps/web/src/components/WorkspaceErrorBoundary.tsx", - "sha256": "bb7f12762abbd5603fb991d58ea021206c48bb5b84eb01989c4e5c645ed2bf34", - "bytes": 1061 + "sha256": "838b2b58b6d1982ad4ae626b966a11ce96341911c569f0a4f8f9e2591e8f9c97", + "bytes": 1268 }, { "path": "apps/web/src/components/WorkspaceGate.tsx", - "sha256": "f6d8ab3312cafe1ac9a87f79f9c8913a702a6b8330508a20c07f284d2273a6ee", - "bytes": 4374 + "sha256": "038988d1b4c0a10463c2bb50b42d85237a2382bfd72acd85094d2394931d7c72", + "bytes": 4728 }, { "path": "apps/web/src/components/WorkspaceInternetConsent.tsx", - "sha256": "c401c77cbed99e84da691cf51a8ff8c12a750d3479cb31ddd9516fbfe2d4a290", - "bytes": 2431 + "sha256": "e6e5776f1ded1022838548f8068a9b446abd0dbd1900496eaab6dfa15ec439a4", + "bytes": 2748 }, { "path": "apps/web/src/components/WorkspaceLocationNotice.test.tsx", - "sha256": "2004942e5287061c090ee755e5aa7336ac512a10f2d11733d757b488507096df", - "bytes": 2699 + "sha256": "28f9c343fc0a26e27c7802678d74231f0b5bdcf9408b2be42a2be1535169cdcf", + "bytes": 2786 }, { "path": "apps/web/src/components/WorkspaceLocationNotice.tsx", - "sha256": "1df2ab32bfee5878e17ae7464c8fdadbb5f4192357d53078c3c26e9f818d9977", - "bytes": 599 + "sha256": "47b9875251d863ca92e2abbfdf020cd65f876ee516654bf666fec4c4cf8fd5f4", + "bytes": 643 }, { "path": "apps/web/src/components/WorkspaceTopbar.tsx", - "sha256": "5ad5cf984d158091501c56d51fee1096e4eda7e8cb0ff64d6082056a1d25f35b", - "bytes": 3696 + "sha256": "e9e9dba4bcb2588880f60945f81ba73343fb91058b6d9f0e0ff3fd77d8ea5c98", + "bytes": 4026 }, { "path": "apps/web/src/components/XaiDeviceLogin.tsx", - "sha256": "3406ed5f6055ce25a529b0735351de002af35860bf32b375c74653165dfa3c9c", - "bytes": 13312 + "sha256": "6318e343063222ea7edaab3f00d3d0fc74ad14e6f17866e75469abe538a0c6f4", + "bytes": 15153 }, { "path": "apps/web/src/components/accessibilityRules.test.ts", - "sha256": "cd63623caacad6b3178b5c7b598e4435ef2f82d9b5190c5e1f1fea0e745b2c93", - "bytes": 4412 + "sha256": "6c60fa36746fb8c9e497d8c94c0df5f3c9ffe6fc5850752086c001e83a048aa3", + "bytes": 4460 }, { "path": "apps/web/src/components/answerLeads.test.ts", - "sha256": "e384a1ac74e6a21bdacc40ccb4526ef742653988b755c9866528629f73aecd0a", - "bytes": 1989 + "sha256": "a073ba5a4b052785c58910046ee4ea2567f8917b94f037c94a89cb2b9cec9fcd", + "bytes": 2080 }, { "path": "apps/web/src/components/answerText.test.ts", - "sha256": "0c49d8c9a771e20f2b1cab22ecf67da4dc759ac9252f07c050dbe57677c89fa2", - "bytes": 2377 + "sha256": "ac2eabd610a140f23bc746735092d0b31665320e5de8417d641a56f36e810c52", + "bytes": 2476 }, { "path": "apps/web/src/components/answerText.ts", - "sha256": "24a9bf8d7b4e8d1a3dea368d544b98a3dd06b541f2d591a2f38248be83738812", - "bytes": 3974 + "sha256": "5a0228cac03e2dc393db3744e764126351b1bc6246bbd91b7713ccd946225e23", + "bytes": 4080 }, { "path": "apps/web/src/components/approvalView.ts", - "sha256": "47ef9029b8cf6d69396eaceb28f7bcdbdb71cfd0d1f53e3e40b560f11cce75ec", - "bytes": 3661 + "sha256": "8d37f2af467d907f919829ab5fd36ddc650431b66e46b86135cba08307a78677", + "bytes": 3924 }, { "path": "apps/web/src/components/askedQuestion.ts", - "sha256": "75d60da5fb821ed1257761ef5ab9ace3757570cd3224c6c9301781b0a69b6fc7", - "bytes": 834 + "sha256": "b0b26dd0fa9eb656fee6d4c5ba3f8a6fdaec291e2d9b41906cf07a8e4cdc9ad1", + "bytes": 839 }, { "path": "apps/web/src/components/connectionCopy.ts", - "sha256": "dbdbc8431276aee27b6e3dd83654974e91f9c856ac0207d94c5073ff4f125a0d", - "bytes": 6511 + "sha256": "9cb0dcf2e3b5c68cf665e7184799e6428b6a735ffb9aacb6bdf3c33327bf3471", + "bytes": 6642 }, { "path": "apps/web/src/components/coverageCounts.ts", - "sha256": "4ce4b0786e2aa0ec6f352341fb7e83f3c8837eb8ec269c67c368b482b7998950", - "bytes": 1408 + "sha256": "6f50a79a370dbfccd195a496572132b6a0282fe738efcc82571b59e99eed096e", + "bytes": 1436 }, { "path": "apps/web/src/components/credentialAuthMethods.fixture.json", @@ -1573,198 +1578,198 @@ }, { "path": "apps/web/src/components/effectDeclaration.test.ts", - "sha256": "39c9f83cadfae85c050432d68149e9998e8f92eafa1bb0ad65150bae07c3c9c7", - "bytes": 2001 + "sha256": "37ae0da201c3e8ba0e4528dbacbfd2ed6ed7354d740660b4c1edc765eda25c6e", + "bytes": 2077 }, { "path": "apps/web/src/components/effectDeclaration.ts", - "sha256": "3730221e731b9dd008202aa845e1ba10dab1d20125d2e635ddc2043a1db30254", - "bytes": 2829 + "sha256": "96f527188527beacaff9e0ce9a7b392a120abe9105f1c03286b803cb48364246", + "bytes": 3437 }, { "path": "apps/web/src/components/effortEstimates.ts", - "sha256": "19057d4a4ba4673f6e8686c48f794e50c29d50d4a74a81a628002e52baf7903f", - "bytes": 434 + "sha256": "6018114998469b1c41de8acd2cf112d75a635ffff50e1edba5d9282631feb2c1", + "bytes": 460 }, { "path": "apps/web/src/components/evidenceUse.ts", - "sha256": "1f3e67d7693b043365a352bc9cf09be1e1bd0271cf682e6bca17629fb44ba687", - "bytes": 1968 + "sha256": "d700f9853d7bcb00b267aed236e43cb549e95618d067e1397864e2ae866a89cd", + "bytes": 2106 }, { "path": "apps/web/src/components/expectedGrounding.ts", - "sha256": "47575101411cce926a8476822ff18c836d5ef4c32d93155b19eebdd40fcd68a5", - "bytes": 987 + "sha256": "e38cf09a58679b970473d44791340caa72e9d1942aaef14f32fb16cd97f213a6", + "bytes": 1011 }, { "path": "apps/web/src/components/expectedResults.css", - "sha256": "28dd556732f4f63122ddab9679ba6f766be937e04e634bde111b8cfc98a75a3e", - "bytes": 887 + "sha256": "23088506642720789ce8de8a27d07e97ce4ef39b26c64f9c1dcb6d243c3c11fb", + "bytes": 949 }, { "path": "apps/web/src/components/foldedOriginals.ts", - "sha256": "9d7951af8f3b56427d5ecbca3bb6b01d8cbe2b27358b3c167c1e1d26aeadbb42", - "bytes": 400 + "sha256": "3537198f191030c6fbb75af87d6b042852d2db8257ed5b5dce3ba8842166082c", + "bytes": 402 }, { "path": "apps/web/src/components/followupItems.ts", - "sha256": "26290f1174395273ae71ab833ef7cd3c96040a1c01cc6f44691684ec91d1c77d", - "bytes": 6466 + "sha256": "8ac8fbddcb2b05bf2cd2ffb6d73dcd37dc4c6fe5c312bcc5cc02098259577dab", + "bytes": 6743 }, { "path": "apps/web/src/components/groundingTestFixture.ts", - "sha256": "21f0cb78c8977cb41a19423d5486e9902b877657f9c8a8c9ee7ba64d67c9a878", - "bytes": 1944 + "sha256": "db08aae28f48d54b232eae41aa1030191e588cf6a54d9c0c6c945fe82c08c50c", + "bytes": 3269 }, { "path": "apps/web/src/components/history/CriteriaChange.test.tsx", - "sha256": "89ad421e14754fa59fcd1d722a24e8c2143f842530602af38dbff8caa5302cb9", - "bytes": 4511 + "sha256": "286c0263b9fd4de08b3b5dff80fe10272c6704f09a9716d0cfdd552b7c470091", + "bytes": 4901 }, { "path": "apps/web/src/components/history/CriteriaChange.tsx", - "sha256": "b24716ae7274b7aacf455619eaab685f8a1057454e90b33d94e86dc791299544", - "bytes": 4285 + "sha256": "754f7ede00ff591cac43c9b724f5c2cd9e50d98f1365e62ba8f8f0d6b8ed6313", + "bytes": 4867 }, { "path": "apps/web/src/components/history/DecisionDeltaComparison.tsx", - "sha256": "50e301fb9ee38d201acb82d01f5204fabde07418da91d4b31a93723b45b6d6b3", - "bytes": 12583 + "sha256": "fe0c89bebccd879944e1907be65d9f387e69a79d973cd6edd018f209ff4a96d0", + "bytes": 15522 }, { "path": "apps/web/src/components/history/HistoryChangeSummary.test.tsx", - "sha256": "667774c50ec600d9e2cce95f49adbc5bf808fff26a7ab669ce5e3cf014596d80", - "bytes": 5378 + "sha256": "ae784cc27ae66dd583efce0a566bd848ffafe21bf8efc1b5092cb6252105ac2a", + "bytes": 5862 }, { "path": "apps/web/src/components/history/HistoryChangeSummary.tsx", - "sha256": "08708d4c4caaf5432ae6ea48b8e0de8a76ddc0e5b6c37758984bd41cea8d030c", - "bytes": 3219 + "sha256": "dd16e344748cf79c540fcf218411d498c740b54247985ead597b5855a541eaf5", + "bytes": 3886 }, { "path": "apps/web/src/components/history/HistoryContent.tsx", - "sha256": "8569c49b0d7d02f744c96f205417a514a4778e391f8682d334974c7deb9dc7d1", - "bytes": 4962 + "sha256": "077b916945668c274380d1c07e561a378943506b97963c5d43c69a9218ab0bc7", + "bytes": 6180 }, { "path": "apps/web/src/components/history/HistoryDetail.tsx", - "sha256": "b733a7025631e02e31c5ba559294cf0da25121ac7b1810f68bcc7b0d0705b993", - "bytes": 7932 + "sha256": "be543a743a3e75ff2db0c27d149a58169abf7da42af2070cd74d20e8cab9ce4c", + "bytes": 9970 }, { "path": "apps/web/src/components/history/HistoryExample.tsx", - "sha256": "b58d83841d65018a121edad185f0c0be2739e98ab8c6812a3dd795a07e640691", - "bytes": 1150 + "sha256": "346dd6f02a47f33cec03f147312408b778a44220e2a379660357853ecb26da73", + "bytes": 1309 }, { "path": "apps/web/src/components/history/HistoryTimeline.tsx", - "sha256": "8c60c1574aaa4c609d8892702e1912a8fe4e2e286c44ec5b196fe4a4c416c9b5", - "bytes": 4284 + "sha256": "248a17c489ad7b740bcd6b0a8ea4835f43cba1abdc751340d16a4a3e4d00fd2f", + "bytes": 5756 }, { "path": "apps/web/src/components/history/ProjectReviewList.test.tsx", - "sha256": "269a2c42262c4a916af2006610b9b311e627f3204d02c611fab6bf5ff6318eaa", - "bytes": 2439 + "sha256": "214b9af1d86df7da5aec72fc1ce816ed536f6a3e5c7962a4d20d66fd63b99efd", + "bytes": 2601 }, { "path": "apps/web/src/components/history/ProjectReviewList.tsx", - "sha256": "941f126398b6a5f28bd5ad68a8852a5d97d32d421a82daa16a5c1cef777d2832", - "bytes": 4555 + "sha256": "b24e71d47fcc84ac808c06cf65b7ea9a835b4e150ce5ba615309b639bca2d9a5", + "bytes": 5476 }, { "path": "apps/web/src/components/history/ResearchHistoryWorkspace.test.tsx", - "sha256": "7c2d2fd5d7cc4c9773c8ad69d2d71cf4d61f61ba4e05eb56bc583c6b7a02f5cf", - "bytes": 32288 + "sha256": "0781b59510790b2aa75e705154ea1dbe870e6aaf9a77d96591da867c580f22b9", + "bytes": 33972 }, { "path": "apps/web/src/components/history/ResearchHistoryWorkspace.tsx", - "sha256": "1c0b7f73e066c4428df11d6296c7efef9e21efe3e98abc580bb19f0e779daf25", - "bytes": 11127 + "sha256": "2a9f8c7e872987b8d6132c9c71aca922ea92aac10b98aee2fabf626d10be4f6a", + "bytes": 13555 }, { "path": "apps/web/src/components/history/RestorePreviewDialog.tsx", - "sha256": "c520934ebf4ed8b268f5662198f510da2c32fac3ef8caf078145b3356a6d7057", - "bytes": 4575 + "sha256": "53b07bc9517dfb6ad2119316b83bac2f98ebac1e1db9deda0f8fada85eea4af3", + "bytes": 6122 }, { "path": "apps/web/src/components/history/RevisionComparison.tsx", - "sha256": "ff4a077d96edf6f285e4473f95043d7caf32df852f3e10b553587a5d2e6fde9f", - "bytes": 2066 + "sha256": "f9e16fecb44484bdb5c811061025c5e78d88de034a2e13c9624efbe23bcf6f5d", + "bytes": 2562 }, { "path": "apps/web/src/components/history/historyErrors.ts", - "sha256": "81a8d40613add31f418937019daa23d1d318d54e6678df2ff513594b8baa43fd", - "bytes": 935 + "sha256": "246e877bb9d3006ace1d4bb73b220cf79d07210f461577f1201a03ff236f864f", + "bytes": 939 }, { "path": "apps/web/src/components/history/historyPresentation.ts", - "sha256": "0990d172887c03729158d5a3de60b4ddc1d99726456db70d1314fbb643ae66e5", - "bytes": 2740 + "sha256": "059069808cfb987d78ddc17917c0cc81e5d0aea896b84add4daa523a11626bdf", + "bytes": 2987 }, { "path": "apps/web/src/components/history/historyReasonText.test.ts", - "sha256": "07efcebfe9c4cbe73040274e6067139e7d99dfa5bf8a316a0e7c02b292e9d9b0", - "bytes": 3020 + "sha256": "23b57020a097affae83b762e4d30fa843f1fb8b9e9a15ae6803c23c876f9cdaa", + "bytes": 3146 }, { "path": "apps/web/src/components/history/historyReasonText.ts", - "sha256": "597441e7e57106b0bf3b5a8319ef0a8eade847ae3ca3361923dfeba9d7111d5f", - "bytes": 1101 + "sha256": "c3c7851b45b8f0dd26088855fd9fb556cd4d8be5b92b4fa7d997e404912626d0", + "bytes": 1154 }, { "path": "apps/web/src/components/history/useRestoreFlow.ts", - "sha256": "e7fb66c43b97b78437df61b6c61e3a899eb0426bf0475fbeccace89119220be4", - "bytes": 4420 + "sha256": "54afcf39feebf864260c0c8d43e0850e398befb750282bc7fa710437a75947a7", + "bytes": 4662 }, { "path": "apps/web/src/components/hypothesisLinkText.test.ts", - "sha256": "06a88e70295f0faef86914f0e4861f1f4665a2ec0fea751be5a77da5076895ec", - "bytes": 6777 + "sha256": "47b26ef48f3cb76bc09b6f006d255d3c682c699214d5bd60cf4cf8d4fccddd7b", + "bytes": 7359 }, { "path": "apps/web/src/components/hypothesisLinkText.ts", - "sha256": "23035bc9e3c5f771bb85b75888916e96f363e9955cdc02e6d553798e4b28167b", - "bytes": 3904 + "sha256": "14f611c1eb123700e3833b83de56dfd45b7954ada8f63eace337c51fd7a4ced4", + "bytes": 4160 }, { "path": "apps/web/src/components/hypothesisMatrix.ts", - "sha256": "6a6f03497c5c6dc01673229c23d6b760e0e4ad8eac7d573d01b002bbd729dcd0", - "bytes": 1192 + "sha256": "5f6ccf06607a8e849b4d82dbd18ea75ea6f806f901d673a1ffb21e8837810b8a", + "bytes": 1229 }, { "path": "apps/web/src/components/hypothesisReview.ts", - "sha256": "f25513b9b8c806b0611b1a5e9bf8c466b33ca1b089fcb8d1ec2dd296283c1fa1", - "bytes": 2760 + "sha256": "1aba69b7e3aa0e1de21809fe8cdf62c847275841968bd135303e352999d1b432", + "bytes": 2907 }, { "path": "apps/web/src/components/hypothesisView.ts", - "sha256": "969c306a974f52d60c6bee634ed3eb021938e142912a48f8da4ee4a89bd9448b", - "bytes": 4492 + "sha256": "a15299045ab59525b084cf90420b7073bb9442f838ce63b0b54692be2d91f621", + "bytes": 4639 }, { "path": "apps/web/src/components/judgmentRecordText.test.ts", - "sha256": "ae34a9cf1424ca150cbe4e58404cf3ea88bced41b7a04d5863dcd1211062e5b2", - "bytes": 6263 + "sha256": "1c10a744f4f7d0a23c9561de2113ea60d3d584b75b1c124a5ac6dcd18bc8607a", + "bytes": 6614 }, { "path": "apps/web/src/components/judgmentRecordText.ts", - "sha256": "bb8d298f5e9a28586cde049996d2e92661fefedc82c23266f92c5784c92dba2a", - "bytes": 5335 + "sha256": "55754967b5bc5c97fe4264cc074f0d8efe7d59c5ece160fbf3d1fa5be5d890ae", + "bytes": 5675 }, { "path": "apps/web/src/components/judgmentReview.ts", - "sha256": "3ddc9dd38914ae496750c953476890d24b32133df314849cc394089e97106f4d", - "bytes": 4044 + "sha256": "56422f044dffd1807496a6d19ee20c63d0e34dfc6247067b861f544efb4d3f0c", + "bytes": 4399 }, { "path": "apps/web/src/components/lessonText.ts", - "sha256": "7842c35ae6248c187d10b0d07fde7bdd87f5136098be85fc5e153b061988e811", - "bytes": 2116 + "sha256": "771f14623e1aab6191e0b94a5ac3b57fb90a22a0f5b4ff048fe6882ca0448c48", + "bytes": 2344 }, { "path": "apps/web/src/components/localCredentialWorkspace.ts", - "sha256": "e219b8695eb51419c4e502647dca6aa564516ec6baebdc0fb7b6435e30f9149f", - "bytes": 581 + "sha256": "8acf2abc4f19f0dffa65044036a437b010b20a92034ff9fb4b789394613ac7f6", + "bytes": 585 }, { "path": "apps/web/src/components/localWorkspaceReady.fixture.json", @@ -1773,103 +1778,103 @@ }, { "path": "apps/web/src/components/memoryEdit.ts", - "sha256": "96eb638d1d7d434d4acdfae3ec6e9c7a91bc4f4f671a67440200c0bd66cadc3e", - "bytes": 3349 + "sha256": "aa87e7b74413205fc66ee1b523d5cf69384717abea15ef42d96390da855def6b", + "bytes": 3524 }, { "path": "apps/web/src/components/modelCatalogPresentation.test.ts", - "sha256": "b5d2fa2e8a1d3647de8fd04f166bbb0570099c5617b20a367977a30d7236f4ec", - "bytes": 2765 + "sha256": "54a7a63d70e47e51aefdb4e2f6f95f0867aa9eb9ef45195329a1c2d6ac86f77e", + "bytes": 3025 }, { "path": "apps/web/src/components/modelCatalogPresentation.ts", - "sha256": "7a842972c5dded5be41a54020912ba137e0ed192ff14d2e718f5401cde725f26", - "bytes": 3885 + "sha256": "3a0197684311b74977726d150d66188873222566896aac3d985d81865afa5ecc", + "bytes": 4019 }, { "path": "apps/web/src/components/modelCredentialPresentation.ts", - "sha256": "ab778ff37851019fef1200c1edaa2dbcf918a108c05b531b99b506f374c4ef43", - "bytes": 19044 + "sha256": "43f6196ae4ceeb459a6aa410ac3774c1c648fc6bcf233401ee2ff51aca68faa9", + "bytes": 19559 }, { "path": "apps/web/src/components/modelCredentialStatusNote.test.ts", - "sha256": "e2579d70af8ff4c90dc41adf6e3d2885c5b512314f0ccefb8545509c1ed4fd34", - "bytes": 6881 + "sha256": "1ad1a6e7ef0d07c23c3c0159b9172d8538c99bb2351b160c1f018003e6ac2785", + "bytes": 7272 }, { "path": "apps/web/src/components/plainWording.ts", - "sha256": "4ff4f9fc2fa12defb67a31d8cebd2f57a01589255112bd1a18d4f8298ee06e20", - "bytes": 2568 + "sha256": "53e6e8548a6b9d592c79c17cad7d06789077c5014a8f12622fa0550caced0ede", + "bytes": 2591 }, { "path": "apps/web/src/components/readinessText.ts", - "sha256": "9ea89510f70bf7e8daa3050b39ac13e3e239f64cd99abd00edd6fd948f4ca740", - "bytes": 438 + "sha256": "aa1f2bd4cda23ed5decb569dd752bc384fd71dbcc2523f59b0246a428b39f9b5", + "bytes": 450 }, { "path": "apps/web/src/components/resultInterruption.ts", - "sha256": "805e54c14c27a494713e7f82f68077ce3285752514a7b82326bec180d62e9d5f", - "bytes": 3920 + "sha256": "489898fcf1dc29917e0e3f326fb12b64b398573feb3faf5d3dc37732ce7f6aca", + "bytes": 4023 }, { "path": "apps/web/src/components/resultUsage.ts", - "sha256": "35260383dd0b21e5c6bc582d122ad2380b5604d0b8d181d6c01e7a16ecce1f85", - "bytes": 3401 + "sha256": "19c04780d90d03297c96a165049de461b1ee9b7f5cd2d1531c37ceea36b671f6", + "bytes": 3594 }, { "path": "apps/web/src/components/returnFocus.test.tsx", - "sha256": "fa034e65795ffb8e43728989b5849ec1307f4ca1650d6f0ff68ee48055476092", - "bytes": 3951 + "sha256": "e6cfd5891848c095e6e651a2f1e2ea4480d845a292bcf97fece87501fbc9e9fd", + "bytes": 4193 }, { "path": "apps/web/src/components/returnFocus.ts", - "sha256": "4269aa688a214a2954a60fbe4cbb19be5cbb1a33e37538be13f4e0f437bc1f86", - "bytes": 1644 + "sha256": "a695ef3d7648ebd99206d263163f2361b0011d6024c89cc6e5f31d8acdd5badc", + "bytes": 1668 }, { "path": "apps/web/src/components/sameHypothesisText.ts", - "sha256": "4b989096704ad8dae5ceee7cfac9b4cdd8545b4b3fb46a8156ba6ebb2d192e9a", - "bytes": 2515 + "sha256": "909965ab8f0cd588edf9251bda05fb11e6f6b2ca44e3ee6b81f4ca72dd95632d", + "bytes": 2563 }, { "path": "apps/web/src/components/statusLabels.test.tsx", - "sha256": "ca6ff4d5c735e2da417140c0621df722669681f36559b92cc077cd73ccc3715d", - "bytes": 2656 + "sha256": "42b737c616bd24d64df6da2e713e961cf0856db6525fcccdc9bf756280a2cff2", + "bytes": 2721 }, { "path": "apps/web/src/components/statusLabels.ts", - "sha256": "630268df7acbe8a94cab2d5d875d40280aa362e59829888595ed425073acdd47", - "bytes": 3993 + "sha256": "5a8e2e82eb6343658c58ace706a581c355ebe1974a81ac08b38e25c36ce74323", + "bytes": 4372 }, { "path": "apps/web/src/components/testActionDraftText.ts", - "sha256": "fe8a5cbe6f37a16bfabcdf894bbebf25051068de07e58eb8cf696df46f0619e5", - "bytes": 2757 + "sha256": "6e4075a6a8fe1dac9969cfb11162b3dffb35a58655dc459fe559be3a20ca2660", + "bytes": 2873 }, { "path": "apps/web/src/components/testOrderInput.ts", - "sha256": "934fe4b0a0061bb954414cf90a9f9d3318e58cdeec853349b12df0693e2fc76e", - "bytes": 5108 + "sha256": "92e1d702e6597cf444985106c3e6d82182f4e5c2243286e8c54cdb89d68de23a", + "bytes": 5534 }, { "path": "apps/web/src/components/testOrderText.ts", - "sha256": "28613dfc0f4d9401d1d21509dd9482f206f4794a269d0bab76257765f2a73665", - "bytes": 1059 + "sha256": "fc08f828d6e2f99bd957a3d86f8232581b55f41148945c60077c98bc9148fa5b", + "bytes": 1060 }, { "path": "apps/web/src/components/testRanking.cases.test.ts", - "sha256": "755293b520801ebd96aaaf67a6d488c8d8eeea9b107b06ac5745a1f940a7c649", - "bytes": 4156 + "sha256": "c4db0166501ed3f81e4b4dfe57028a071f0418106216a918a9b1cbe116936233", + "bytes": 4575 }, { "path": "apps/web/src/components/testRanking.ts", - "sha256": "c1ce4bfb28cfef8006a43061e007c3040c9cef82a26ed37dd3c9593e50a917a5", - "bytes": 7198 + "sha256": "9adaba1db408372508bbaa90e56e199b48dbc8915e2ed7636976f9828e1ec55f", + "bytes": 7769 }, { "path": "apps/web/src/components/timelineTurns.ts", - "sha256": "6d5f6805d56074400b32b68319745c2047429a033a2924c4d8a544eb2b28fafd", - "bytes": 1936 + "sha256": "f046de7192270926030899318988dac9ac4b2c32cc22d8d0c4b55c8f3fa8bad2", + "bytes": 2006 }, { "path": "apps/web/src/components/traceDemo.fixture.json", @@ -1878,93 +1883,93 @@ }, { "path": "apps/web/src/components/traceEvaluation.test.ts", - "sha256": "ba2d696efbbb582c6bf62999191ee55eaf8ee41bd8e6ae3be0474a34124b41fa", - "bytes": 4692 + "sha256": "4dc53419e206e3421f696c445e8fe5551ce3985d1f6efdab79b2001dfacda78e", + "bytes": 4956 }, { "path": "apps/web/src/components/traceInvestigation.test.ts", - "sha256": "03671342b27f78c15f4d5652ae5aee939942b0741d0d925ed0b81f4a4b8bf630", - "bytes": 5865 + "sha256": "3416a15b067145901fb997c80cd6434c2128be0ae1d038f22bc87e6af31937ff", + "bytes": 6230 }, { "path": "apps/web/src/components/traceInvestigation.ts", - "sha256": "f62db84d1bd0d758b8865761ddcd03370e1eb08229431d53fb94921452cd3c6d", - "bytes": 4395 + "sha256": "abe0fd2ca2421a7bc8f7a4f2231cc3db10581ed1e2eb11b4a516e344ef74fa7e", + "bytes": 4676 }, { "path": "apps/web/src/components/traceInvestigationLanguage.test.ts", - "sha256": "9e6be03379f835328ba8fc65c88610bb67ca2c966170f63ddc0c042345d462a2", - "bytes": 4124 + "sha256": "d230dfe8e7f1475fca3fb2cf8af512bb6d2bcf7e935634acf30c1dc14edd6382", + "bytes": 4235 }, { "path": "apps/web/src/components/traceReasonTags.test.ts", - "sha256": "0c645f6f882e9ffb9d34205120315d4b9f096fa1762c47d50692b5aa40ce0c8f", - "bytes": 8881 + "sha256": "7c519393d6815514bfe842a552884e3679fdd116a8c3e60f965e2c91be3f3e6b", + "bytes": 9643 }, { "path": "apps/web/src/components/traceReasonTags.ts", - "sha256": "aa958b2ccda81c837c353437dcc840298908c115beda63a3960fb139841c571b", - "bytes": 3457 + "sha256": "860650670e39fc0c86ec0690eea98e0d898d5b57495c2f6b35131f194b36ac17", + "bytes": 3565 }, { "path": "apps/web/src/components/traceRows.ts", - "sha256": "117aaca971f997b8190a7cd917c0acf98f0634d9a86b9f553ca80b2e7a2fb77d", - "bytes": 3218 + "sha256": "ce46d007100427a83d3152857d032afae78d16f1468e4006ea3e33de4c1e8a12", + "bytes": 3512 }, { "path": "apps/web/src/components/traceText.test.ts", - "sha256": "8aa77719d7b3e9592276aec97837cdc7865c0061970953c30f49e7bd06c3f9bd", - "bytes": 14992 + "sha256": "6dd902b48a25a30a00760f78899163f0e484126d8612587eeaff573ad4b62daa", + "bytes": 15858 }, { "path": "apps/web/src/components/traceText.ts", - "sha256": "c0c2bafe1878cf2b5adddb8560d4e0faae9ec522392bedf490a39e00b4c37aa0", - "bytes": 20905 + "sha256": "190fe0334884818e75024f54bb969e5f9426b0097d3bfd58d733efb349df8c76", + "bytes": 23031 }, { "path": "apps/web/src/components/turnSummary.ts", - "sha256": "b35e6d55520140469bbbb8d5addc252d8f53169e9c5fc1f7028b1497adb1f732", - "bytes": 3159 + "sha256": "28ca01be5473dc7953f622896c8c89b2bbd25699d102ad0968784bffe7daf69f", + "bytes": 3316 }, { "path": "apps/web/src/components/useAutoRetryOff.test.tsx", - "sha256": "39394b5340692d851250b68d0929b0d3c4480f9fb5c1fe1a99a4536a6bbaf59c", - "bytes": 2813 + "sha256": "2ed53401eedfdfa8953f546aea104481c69edf81a6f41622972d3fbc57aebded", + "bytes": 2885 }, { "path": "apps/web/src/components/useAutoRetryOff.ts", - "sha256": "a57e246ee6232e4bb94bd20e8d597547d075267392c1673981de70ccfdfee73f", - "bytes": 835 + "sha256": "95197dc620f7cbdce22391f37da7a8898179b3daffadcc125bcc135896d0065c", + "bytes": 878 }, { "path": "apps/web/src/components/useConversationReadProtection.ts", - "sha256": "0c42491f874c817b54ddedbf5b73ae96d5eac3095aedb39efb51fbd9db1a1c0d", - "bytes": 1669 + "sha256": "8da4e92afe923a5c90948afe76b7b4f7f9288312dcd89fb79d2aac6042a54ae3", + "bytes": 1752 }, { "path": "apps/web/src/components/useDraftGeneration.ts", - "sha256": "da58e344da83734b1c453b42f6ff5a587c6d67be043a247c26d4e1e8d8940b67", - "bytes": 921 + "sha256": "ff3f433892697f0aa1525992c7207b543735c087865b25d20f7d781a4f16d69b", + "bytes": 999 }, { "path": "apps/web/src/components/useHypothesisLinks.ts", - "sha256": "02f64c0aa50f219fc2a070fae11d4fd6d925673b956def071da5979c76212c5a", - "bytes": 810 + "sha256": "d0294c94a2ead7c67d3478aa1dc0549e3952e83d283680fe611017f67fa04f31", + "bytes": 836 }, { "path": "apps/web/src/components/useJudgmentRecords.ts", - "sha256": "460b0f47cb64f71e11923da747b77c2186ef299181ea33d3c8f86eb96f143184", - "bytes": 2365 + "sha256": "fe80952c6e5b09300d8df1df4a2a9181e9316031469e380b806970a813e1c21c", + "bytes": 2450 }, { "path": "apps/web/src/components/useNavigationDrawer.ts", - "sha256": "6c4fbaf3ae858673ed4844a738d28373c686ea7e941ccf7478f611c3925213e7", - "bytes": 2323 + "sha256": "e04beffa8857c332d87e5e29d3a26e807eb5d5f0bef79efe90fc6324a2c4def2", + "bytes": 2688 }, { "path": "apps/web/src/components/useOpenAtLatestTurn.test.tsx", - "sha256": "4f36af77b87d04dd960bd3512ff992c129c0ff2787fe47f4946c935bc37573d4", - "bytes": 2945 + "sha256": "f31464deaa1f4463cba21b7644b71016baac84780b94b8c85371559a8dff7d61", + "bytes": 2965 }, { "path": "apps/web/src/components/useOpenAtLatestTurn.ts", @@ -1973,43 +1978,43 @@ }, { "path": "apps/web/src/components/useProjectManagement.tsx", - "sha256": "35fb09177cb72aac2e7319b881851c8b7deb58295d69f12ca47825cf16f9e043", - "bytes": 5564 + "sha256": "db1c39c36c625ddabfd715dbfe1244637a48402a453a977762d1352df2bb371f", + "bytes": 5774 }, { "path": "apps/web/src/components/useProjectSources.ts", - "sha256": "5b42eaaef1ef50a7193ffad997ff4edd59c4c9583b54e257e4fb3b94561a1281", - "bytes": 1163 + "sha256": "e15d529e71c9df85860937c2a937991366f8c86a8fb71a628312ef052736231a", + "bytes": 1251 }, { "path": "apps/web/src/components/useReadAccessProtection.ts", - "sha256": "380496b8711bcb1ed22fcf3b5acc85096eb96381fcda3b69e2202e38c122416b", - "bytes": 1403 + "sha256": "e542319d20a3841dd4cadd80b0d02eb97ef8c8a480c1865e79eff78f1056c64c", + "bytes": 1464 }, { "path": "apps/web/src/components/useResearchSession.ts", - "sha256": "7dafd2feafcd3b45149dee656ba4f3ee7764d5f370f03d6df1322a39fae32abc", - "bytes": 15205 + "sha256": "17c26f28ab0de7ca0f894bd7210af2b3177dd1400c500fe49b01f5f3b7b7054e", + "bytes": 16711 }, { "path": "apps/web/src/components/useResponseLanguageSupport.ts", - "sha256": "bf469cbcbe84e6dbe7e4ffd96413b2dcec5853be88625ddeb1eb61fb6afb0669", - "bytes": 1325 + "sha256": "9116e8a6d6dd5d99a2da0a33f08675076e72930c5899851958b4856a4b460ed0", + "bytes": 1352 }, { "path": "apps/web/src/components/useResultEvidenceProtection.ts", - "sha256": "4d58a66d76517539d3696e602ea10631f9f9a7a5825782cb4797d67e0e058c6b", - "bytes": 2010 + "sha256": "8e5b5b5911a108d9e1693b81e0092d7c2ba3b313edd986b96fa92da87b528214", + "bytes": 2267 }, { "path": "apps/web/src/components/workspaceSelection.ts", - "sha256": "87058f033e3135ed8433e4604d48b2abc59d0932c1998e0cc29800ab43d74320", - "bytes": 8928 + "sha256": "7206b6f4b09be263f19faa5e21e05b9e726a7df86bad2207414bd15251f2c6a0", + "bytes": 9619 }, { "path": "apps/web/src/components/workspaceSetupIssue.ts", - "sha256": "088ccc1551d13ee650c6ba0acc8a5808a011bdb389c16af722ad4676f1eee171", - "bytes": 905 + "sha256": "ca6d29dcf0137d3fa9b38795b215944056e03c0cac3baca54b17bd3e8550e9d4", + "bytes": 913 }, { "path": "apps/web/src/components/workspaceTabs.ts", @@ -2018,298 +2023,298 @@ }, { "path": "apps/web/src/i18n/ResponseLanguageContext.ts", - "sha256": "733b183f5ac03018d9ecc5be54f9bb8c6491e3292c30aad96e53401847336308", - "bytes": 605 + "sha256": "45fdb944f771c9177cf481a6a189f7313bf4d137f9be785da07089f0de1dab46", + "bytes": 612 }, { "path": "apps/web/src/i18n/UiError.ts", - "sha256": "64d31f714f844a1d69683fa76b17141efa0655364cec7bf9f3979311a74ffbb4", - "bytes": 389 + "sha256": "19ae3898aabdcb47ee30179d44e1b4389a1cdb5c1300e5397cb75dd4f5949a2f", + "bytes": 391 }, { "path": "apps/web/src/i18n/activityDisplay.ts", - "sha256": "bb8a8a338e268d45f9daaa7e403998a1207bf00cb3acb14f334e5585779803d5", - "bytes": 8830 + "sha256": "0fdfbd23f2d0090e6c493a349c1b2486cf4dcd8d4baa543ceef7231a05ba3f15", + "bytes": 9618 }, { "path": "apps/web/src/i18n/activityMessages.ts", - "sha256": "f8b5b823f86b2d09d80ff68f2689ac07a228dce9c42d8a53dcc2b13745b7354c", - "bytes": 17901 + "sha256": "777501cea995acdf33b9de6c1a3c9a690fe22a9c6186adda7323085d1c56109b", + "bytes": 17458 }, { "path": "apps/web/src/i18n/advancedMessages.ts", - "sha256": "f66bf00e1a3f2394a14695b5331b2a9485dce2743c4c5f326a83fc4fcaa3f1a0", - "bytes": 13945 + "sha256": "4ded4b0ebadb3c639c2113d4880fe59c812187d0cb64f14370528b479dfc1d8c", + "bytes": 13646 }, { "path": "apps/web/src/i18n/apiNoticesMessages.ts", - "sha256": "91437790ffadd57ec2dbd2fa27ed8ec4829d2ecac683e747295fa33945142e10", - "bytes": 5703 + "sha256": "84e9a77672baed79f1d9167995f6074503db4d2405774dda4800722d40f47ed7", + "bytes": 5596 }, { "path": "apps/web/src/i18n/bilingualRecord.ts", - "sha256": "91e7633001b002536deea2ae560d82a4b36a6494bee0508cd7476a328495b9e8", - "bytes": 622 + "sha256": "7215136abe266b6a413c9985c6c4f1b8ebf03c41a39d7d9007df3cdd82b896be", + "bytes": 632 }, { "path": "apps/web/src/i18n/commonStateMessages.ts", - "sha256": "d932cfa9d75cf0095eabb21d53d829dfcb95cb072f8d6e576c650ebd16a6b5fb", - "bytes": 6105 + "sha256": "920026ea0ca1ce00d6bb5a754aeb335a8ed50b6ab9c065389f023a2ed455cb43", + "bytes": 5920 }, { "path": "apps/web/src/i18n/connectionReasons.ts", - "sha256": "58012595415f8d8754b875a46c30df817c2509b61a94f4df5f13cc2528d9371e", - "bytes": 19907 + "sha256": "923f04187c767145420fc05ae0e7cb35153eb7f818a6f55aad2147352a5c0662", + "bytes": 20167 }, { "path": "apps/web/src/i18n/coreMessages.ts", - "sha256": "396c1ba18a443566230bfe8001814cdeeec1fa9c3d619eba4d502af77a832251", - "bytes": 8226 + "sha256": "023c836462deb25e8bb249e7a7b00f79ea482b9d9e9729ead7309419780eef03", + "bytes": 8545 }, { "path": "apps/web/src/i18n/countFormatMessages.ts", - "sha256": "0e98dad151c5957478089a6385a09187341ee350303485c503df2433c607d3a4", - "bytes": 15873 + "sha256": "c18ab4c6bb66b8b975c3ecea32b7962bcad0e7baa356a3da07618c74df1e7803", + "bytes": 18075 }, { "path": "apps/web/src/i18n/credentialMessages.ts", - "sha256": "2fd1801d0ed40743b4559b8b50a2c1f6cf2ce7871c5ec8a9f4a01a52fd63d1c6", - "bytes": 11913 + "sha256": "2eed37845616fd864fc4b7c05bd198ceef9dfd4e0fc460f3e7e1cc5172666513", + "bytes": 11719 }, { "path": "apps/web/src/i18n/finishingMessages.ts", - "sha256": "330637e1f0280426eaa920c3a156f99b7f63b90be1e192dd84e7554ca294139a", - "bytes": 2844 + "sha256": "a881632ae0ae6b6cd96d3bf658d6e08500b71a078680486e4df080c326b40bc0", + "bytes": 2779 }, { "path": "apps/web/src/i18n/followupMessages.ts", - "sha256": "fadfe623ccb16dc130e4d0d56dab7851911ebce271ecffd822b11ca566eed65b", - "bytes": 11832 + "sha256": "cdf8fcffcfa0b16cd279fc8ce5d4bc7acf1fb11c4a6dab86114fc5df0ef281e0", + "bytes": 11614 }, { "path": "apps/web/src/i18n/formatMessages.ts", - "sha256": "40d9cc45b3273cb3f31151e622a745327650548df4758f7119ec9a993995eedc", - "bytes": 9435 + "sha256": "03871bdbbf67e648f191273a47c6818f5278bca665aeaf07ce8e47f2fa9c59fb", + "bytes": 10082 }, { "path": "apps/web/src/i18n/groundingMessages.ts", - "sha256": "73a85960e328d7c0440af8508fb17bbd4125c0874627fc6bda0d8db2814147bf", - "bytes": 1366 + "sha256": "e65db59296a189e5af1381051f3dd627aab438bc8af7398c1c3b05ad2bde8c20", + "bytes": 1454 }, { "path": "apps/web/src/i18n/historyFormatMessages.ts", - "sha256": "9f359465717a5b9fba3d566596ad15f5967f5eb97cf07873a2a486d8df7c9e01", - "bytes": 7664 + "sha256": "a4021335d62c367e0a5c4cfa39579136233a0fec55e5699fd7476cb87c066c5c", + "bytes": 8300 }, { "path": "apps/web/src/i18n/historyReasons.ts", - "sha256": "3e12ab0a43f4c9a345a24d6c42c9677fd24754058466651662590b7176d3d75c", - "bytes": 14893 + "sha256": "ed9c486ee5f8d6de7fa95f4983e2a1633f02e1fdce826f1ee5a4b0cecf66867f", + "bytes": 14859 }, { "path": "apps/web/src/i18n/historyTitle.test.ts", - "sha256": "876b4883145701061fa459600a6c4009929ac9ac17b33eed763c30134d3f2e06", - "bytes": 785 + "sha256": "72e630b3cd932190dd425a8cfaa8ee7d8da7bff6f9015e59bf7d21625d2ee36c", + "bytes": 794 }, { "path": "apps/web/src/i18n/historyTitle.ts", - "sha256": "0b3e08f522561a699ad1d4c1d1655afc5f03dea5437c8fa642f0bba5a49effa3", - "bytes": 4206 + "sha256": "618d25378d90b5342a2ab3e31f96bb031eb96dbf11016273507a1b206c66b8e2", + "bytes": 4319 }, { "path": "apps/web/src/i18n/historyViewMessages.ts", - "sha256": "4f714eb34f91b1f8b9129e533ffb7068166d2038d12f31b056ec012c160bc657", - "bytes": 9765 + "sha256": "ea05c75ceb9d41b12841117cc7363cf5e21441855f075a8ba900b4a9f898f0e8", + "bytes": 9550 }, { "path": "apps/web/src/i18n/hypothesisActionsMessages.ts", - "sha256": "ba851afe8055b847e7f3ceca01483ea09478d70d98ca2cb264766ec1259f6b0b", - "bytes": 15660 + "sha256": "08bda913bef5f29f9bca63c57baea97ccac4815f5f2d6188db3ad5ca4d4f0913", + "bytes": 15349 }, { "path": "apps/web/src/i18n/ja/activityMessagesJa.ts", - "sha256": "3c461b1649ee9445eaab377b63e03b0d383d5567e274edad499828515360bcdf", - "bytes": 10175 + "sha256": "8229364a914ae70b42a8e61dfef60c6afcc579ba20c48edd1c0243a11fd35b6a", + "bytes": 10207 }, { "path": "apps/web/src/i18n/ja/advancedMessagesJa.ts", - "sha256": "034a8f7076b64dce1913175f05ccce0dbd226551b0a2fc1fecf41ec2d8905f1b", - "bytes": 8175 + "sha256": "ddc8f69b5a8285a05dd6f9ee17403dfafb8b4065f6888ccbf739d534fd62d987", + "bytes": 8211 }, { "path": "apps/web/src/i18n/ja/apiNoticesMessagesJa.ts", - "sha256": "e24784808bd10e8055d1e3db7100de1b8568bb2315b7c6a663ce6fd4158add25", - "bytes": 3444 + "sha256": "0af007f5c29070bf340b380855f4ca450adfaf193305916adc63331aae8b8633", + "bytes": 3456 }, { "path": "apps/web/src/i18n/ja/commonStateMessagesJa.ts", - "sha256": "7acb8c7c34516bae67833ac2bdc2247effe9a6fec1b83081ec8789b84e9d1f23", - "bytes": 3468 + "sha256": "46a06abc8d09a0e1c5e5a202269eae90e17608edf545bd0915d1c45011f1b0fb", + "bytes": 3476 }, { "path": "apps/web/src/i18n/ja/coreMessagesJa.ts", - "sha256": "02f63b491ea88e39573a80005a1e1a2d891c65ac300d1e601d99327858d7fe18", - "bytes": 5302 + "sha256": "52116795c842644fa662cc70590a8de8de4c5d58b4aa394d2720c8751e6b6779", + "bytes": 5358 }, { "path": "apps/web/src/i18n/ja/credentialMessagesJa.ts", - "sha256": "2f5fa12449f03db6481ad3559de60b259df3de7611b382b358a48110a8ae554a", - "bytes": 7485 + "sha256": "2d606a5bb18bdae248229ed4c9918951d100164798a96e09d285f7924b2dddce", + "bytes": 7573 }, { "path": "apps/web/src/i18n/ja/finishingMessagesJa.ts", - "sha256": "0699a21042ce8fcf56b1febc2c8f66b995f109c21bbb73c4e65254fa4207e87e", - "bytes": 1790 + "sha256": "0a1a9ba7caaa7584a937e70b4f21f9fa05d7aa67629998ce26919747535834a2", + "bytes": 1794 }, { "path": "apps/web/src/i18n/ja/followupMessagesJa.ts", - "sha256": "0b42204b034daa9f665f75815d8ded23639363873f21b8d1eefea12e2098fc7c", - "bytes": 6946 + "sha256": "042dc8a4a2a826a871cc9503571cc9a3a126cfb389d958376b6a92d89f1f2421", + "bytes": 6998 }, { "path": "apps/web/src/i18n/ja/groundingMessagesJa.ts", - "sha256": "91b6a5098c79bfd7cf08bc6a65db877321060e2567f25b7bbb8b42283f57b500", - "bytes": 963 + "sha256": "4e294bad1caedd40748b310aaa4980b16145ea2acdd9e0acaea8c6438b98bb25", + "bytes": 967 }, { "path": "apps/web/src/i18n/ja/historyViewMessagesJa.ts", - "sha256": "b73cfd2580549ed4dd463200cfd20ad47dce01febc02028f321f90c013ee5695", - "bytes": 5602 + "sha256": "034df4362dd156153debe4dc34d591db1393eecfc85f0ef63194f9bc344e2db3", + "bytes": 5638 }, { "path": "apps/web/src/i18n/ja/hypothesisActionsMessagesJa.ts", - "sha256": "c78357bfcf7c7340386f28e63e013a38eaba625ec9891ca12a670b95bd21c4fa", - "bytes": 9225 + "sha256": "4fde249d8f6a584efaf3a9e36ba6033367e885ce18904b3815bb1d57e16d2e26", + "bytes": 9277 }, { "path": "apps/web/src/i18n/ja/judgmentMessagesJa.ts", - "sha256": "972ad42fee6d0cec839475494b606550a1b806a99966889b5e27e79e1e7c97e0", - "bytes": 3997 + "sha256": "680a4d4c745d35ff7826f3a8ed14d8468699cebdc60f42e539410c76d3101c93", + "bytes": 4045 }, { "path": "apps/web/src/i18n/ja/localRetrievalMessagesJa.ts", - "sha256": "d5a390a05b93a8bc3184e6b4aec1788efa465e741601998c5565477ee89f4dbc", - "bytes": 13361 + "sha256": "0de26cf52e0f8ce24c553a35093ed69ae98ca0eb07f02bb068a88797e0ea2292", + "bytes": 13382 }, { "path": "apps/web/src/i18n/ja/modelHelpMessagesJa.ts", - "sha256": "c02724548bc3e5b88d9d4fcd727fe5474e5927a2284e27cd3c5d58c1740102d2", - "bytes": 1665 + "sha256": "daca312bca5726544782d9aab28b771d055f53e405a16398fb4338f0fb409ba3", + "bytes": 1681 }, { "path": "apps/web/src/i18n/ja/oauthMessagesJa.ts", - "sha256": "b3999ee8a848434bead62181e38f27099091b502eaa26e191607662d4980d663", - "bytes": 6574 + "sha256": "5f1441401d1efd627e306a460eee7182d5c7790f3e02df1d64e0359ae3e05b56", + "bytes": 6654 }, { "path": "apps/web/src/i18n/ja/parserErrorMessagesJa.ts", - "sha256": "3aac61f80dd1a40a0ffb22b0197ea11ecc5acea758361439ba4aa724bdb44554", - "bytes": 3771 + "sha256": "71a64f72846368775588d9a8e49c20b4aa03e8ab351c8afd317c6a9d8e7ca8e5", + "bytes": 3843 }, { "path": "apps/web/src/i18n/ja/recordDialogsMessagesJa.ts", - "sha256": "8be80d4578e637156dd32f190f1f39ec0ee9cb0d878f8f398415ff0abe5640b8", - "bytes": 7407 + "sha256": "0ebcb321cc8767b4a502a8cabfbafaea135452cb09927b9e8b4f3c3e44c0c056", + "bytes": 7459 }, { "path": "apps/web/src/i18n/ja/recordPanelsMessagesJa.ts", - "sha256": "d26bf1d147326707b72bbcaead3a5ad0a31f4a4427794281e873f5c1f8d968ae", - "bytes": 8876 + "sha256": "932b124f51633409f5f035c15ebb5078da877865febd5385f9af5fab621c8303", + "bytes": 8952 }, { "path": "apps/web/src/i18n/ja/remainingMessagesJa.ts", - "sha256": "182bcc4bbe4ba170c9196d4bb238b45e9fc1b0ec065350d36a7f952b380d7abc", - "bytes": 9234 + "sha256": "78e78aadf20f197a8469bd4cd0e6f11113f16429f7de5f335d2795b566287e74", + "bytes": 9290 }, { "path": "apps/web/src/i18n/ja/researchMessagesJa.ts", - "sha256": "1dd8c063e12034f650760eb210a6ba4ea20ce5928be824867ba4ca626526ff6e", - "bytes": 7008 + "sha256": "c50bbc11e05b034eebb5506c0329666b6a31bbef518016b8dda5bc49ee6e1b7c", + "bytes": 7052 }, { "path": "apps/web/src/i18n/ja/researchPanelsMessagesJa.ts", - "sha256": "9b941b1089f98a0a905632d6b53adb1b6ae441529afdf237abb74a2957f1d8b4", - "bytes": 8003 + "sha256": "1566137756535dedc8d516c5d6777341b5b66387a17031754db2e9a4df66f80d", + "bytes": 8071 }, { "path": "apps/web/src/i18n/ja/reviewPanelsMessagesJa.ts", - "sha256": "a5e7265b19f8cd3704a4dcddcf60b7cfb4863a80c75e394c5f164a88d5b92ecf", - "bytes": 7612 + "sha256": "82ea3dc565ea7a48638b3d9df46224e425ec8e74ce8941529a6ed9e5622966bb", + "bytes": 7656 }, { "path": "apps/web/src/i18n/ja/setupMessagesJa.ts", - "sha256": "d549cc682d7edc1ea777cd1d2cd709b79bd5c7a60f08df07e08a5f1ca04125c4", - "bytes": 5670 + "sha256": "4c51b332794873de543ffc17e1cb7cdb2173983d3e948626d8302e32035ef8a2", + "bytes": 5706 }, { "path": "apps/web/src/i18n/ja/sourceMessagesJa.ts", - "sha256": "ef05e9d8e225824aec9d8a9b397d380a5b017093219722f4118d90646921ec02", - "bytes": 7799 + "sha256": "7d2fd3edd39e5b3053bedb7846081586e1debe923460375e7aa945c144a0cd98", + "bytes": 7867 }, { "path": "apps/web/src/i18n/ja/traceMessagesJa.ts", - "sha256": "408b1091ed2b99ef1c87c9cc5b4a9a4f252ec5ed8291329da023cd2b1fb1c2d6", - "bytes": 10282 + "sha256": "cdf65da24ea0ee3ce16a4519d39b2a01855a8a968a9b2ca4b4556fbe55b3097c", + "bytes": 10358 }, { "path": "apps/web/src/i18n/ja/workspaceDetailMessagesJa.ts", - "sha256": "d68a6d078258343eb6a5326b21a4f3097d3d707195a22b2e2c3243fa4f3b67a4", - "bytes": 2754 + "sha256": "8b739c04f460bfe2e333fa121bc6ea7b2faed3988bdd1e2da649fa258d2cdb0e", + "bytes": 2786 }, { "path": "apps/web/src/i18n/ja/workspaceMessagesJa.ts", - "sha256": "213e1cf46aedd471a9ce95b50d78fef67aa2aa7587cb3aa0725a731cbed8e2f4", - "bytes": 9621 + "sha256": "acf70df5a77eb7f863eb0b3eca1c9fbeea4c54fb64eb6411515fd3d1a15b1982", + "bytes": 9693 }, { "path": "apps/web/src/i18n/jaScreen.test.ts", - "sha256": "e831ebcae3af8774dba98e16add469386cb121082976c1dee347fdc72c631702", - "bytes": 8699 + "sha256": "7428eea0e8d8b73b7223f02552e8917507be5ba2a5359ea6dcd84c420f077b62", + "bytes": 8968 }, { "path": "apps/web/src/i18n/judgmentMessages.ts", - "sha256": "3660bf09405d4eb1353a693a9b886a7723a3619f23960da69ae8fdb5a20715e0", - "bytes": 6778 + "sha256": "ea901af6165ffa1ec89638bbd6fc7a09cddfa9d851ad2f69717f7380a09a21cb", + "bytes": 6683 }, { "path": "apps/web/src/i18n/languageChoices.ts", - "sha256": "32451f93b1fe710f09244a57b8d433a74354674067443b547a34c3697d621dd7", - "bytes": 2591 + "sha256": "7fef295ea5c4655d12f311168be6993396c884cdd0c1c0765d1ccd4fddef0474", + "bytes": 2726 }, { "path": "apps/web/src/i18n/localRetrievalClaims.test.ts", - "sha256": "09c9dabeb917d5ac08a5c490e89328ff7d96b9ef9857ae4d08153a149bd940c5", - "bytes": 2328 + "sha256": "a27df3f9305fec63260a559cebe2262ea422f15ac592ed984cd10e35c820a282", + "bytes": 2361 }, { "path": "apps/web/src/i18n/localRetrievalMessages.ts", - "sha256": "1184ebd3db25f48a100121406c6efecfcf3ea8762624be02abd218bcfb69efc3", - "bytes": 22520 + "sha256": "14db34556c0581dce30b352659443c3aff5ad691d84e95b5cef6c459854e51bd", + "bytes": 23214 }, { "path": "apps/web/src/i18n/locale.ts", - "sha256": "2e7e36d514f0c419929e453dae77d96f7808eca827cd979f020b044b187cd403", - "bytes": 4240 + "sha256": "afea634e3a266e2388114cbe33943cf2df619486aa822ca3a0122d986b8c9056", + "bytes": 4653 }, { "path": "apps/web/src/i18n/messages.ts", - "sha256": "23448c6546f9d4bdd75b2c051cec261e23ce4152e58c30ac8a75fa7ab68e7540", - "bytes": 9805 + "sha256": "c460bbb0ab27d7079acd1fe4a8cf54b4e55d8d3a873fde1213baca7c274afcc7", + "bytes": 10135 }, { "path": "apps/web/src/i18n/metricFormatMessages.ts", - "sha256": "002f0819473db887a7060441e488c8c490e024959b49bfb93920ad5cdd8e18e2", - "bytes": 18552 + "sha256": "41fe45333c4e788f51b011ccf08e5e3f9d8461205e09d47d113d5ff5baf0f9af", + "bytes": 21601 }, { "path": "apps/web/src/i18n/modelHelpMessages.ts", - "sha256": "fde35f0cc7f76a9dd59f5539b695deda068a823f5c42e064da9e345bf6231cb7", - "bytes": 2530 + "sha256": "33ce6a0ed516e67f59396723bdacf91061ee378582c2c7c6cb4a7eeb0c364513", + "bytes": 2505 }, { "path": "apps/web/src/i18n/oauthMessages.ts", - "sha256": "29f3d0f0446be98359c48e6554690f379e64e95b49d31ae686f5364cd8a9d732", - "bytes": 10638 + "sha256": "5b5762d602cea7b611f75316eefc2e47903d7e678ba96dc25e3bb4b85db9357c", + "bytes": 10456 }, { "path": "apps/web/src/i18n/parserErrorMessages.ts", @@ -2318,178 +2323,183 @@ }, { "path": "apps/web/src/i18n/pickLocale.ts", - "sha256": "e3166c80b1373f6a562a0f6f3099e1b729579dc666a08c9faa1424cebfd0c03d", - "bytes": 355 + "sha256": "8f9b77b4ebce249fd54ce4021677971d0bc998efed76fccf35f4f0f1e2cc7cba", + "bytes": 362 }, { "path": "apps/web/src/i18n/plainWordingResources.ts", - "sha256": "7a59ac5f1a1e118df2eb960ad7d9ed51a18d9c7835eac8e57a79b6b1f506b607", - "bytes": 16966 + "sha256": "37a769f62d7e940a14c097403dd671d28699275a38eecb1dad18563f1a04200a", + "bytes": 17488 }, { "path": "apps/web/src/i18n/progressEn.ts", - "sha256": "f6183e7f60a10335d711edf8bbfcd9a5db72c6ce6cb296725ac404d9ce31e5ee", - "bytes": 12151 + "sha256": "3e922294d50e2e74f1610030ff0982bcfe297764e40f488b4495b1e08dba6fe3", + "bytes": 13340 }, { "path": "apps/web/src/i18n/progressJa.ts", - "sha256": "e57d1e3e6e33c1229409b178a67c073ce5891185ee582be48f64e52f9086ed8e", - "bytes": 13793 + "sha256": "71f8dca109126f0bef316219ad3f31a1c12307f744e632e819e779f3fec34cbb", + "bytes": 14970 }, { "path": "apps/web/src/i18n/progressKo.ts", - "sha256": "8dcbd48948a0813822baca9b11c2e1e0f206fd5caa86c2370a8eaab373f489db", - "bytes": 18403 + "sha256": "b92a302d6331c4f425a48b4c70d48e05d83ab25696fa0d1914fa0e6fedc2c2e1", + "bytes": 18752 }, { "path": "apps/web/src/i18n/progressZh.ts", - "sha256": "4eef14688f7e6507f6469c64ba3ae8d097931875cf6885ada66987487f3a1a53", - "bytes": 12052 + "sha256": "f62549ee18519a9040f77163abf08bae8cc4206be01ee549e8db40073034547e", + "bytes": 13169 }, { "path": "apps/web/src/i18n/progressZhHant.ts", - "sha256": "edbd514c5953b4e01d47c161cbc2362b980fe151fef881aae0b9555d8e581a4a", - "bytes": 12070 + "sha256": "17fc6f6fb42b041da1605616b687f5294562a27d92756941d962de0fae044a98", + "bytes": 13187 }, { "path": "apps/web/src/i18n/recordDialogsMessages.ts", - "sha256": "b55166ec72ee4699f049389a8e7777d9c0654a323f2f170a27144637e778ae22", - "bytes": 13009 + "sha256": "d064e490cf93a339c19dcd1cb3610b3d6a628f5f1adb80fa4ae8e2b3826bc165", + "bytes": 12744 }, { "path": "apps/web/src/i18n/recordPanelsMessages.ts", - "sha256": "77f3954c2469b1513ddf7232e312bc70df6db48de2d2c388b52f06c5cf5faed7", - "bytes": 15439 + "sha256": "51bda788d9287d92400a09d907872cdfd8e028c48527d21f624f5d0843f13829", + "bytes": 15122 }, { "path": "apps/web/src/i18n/remainingMessages.ts", - "sha256": "f08f1dc44f9aee659d13ae50a898d77f7d706361b496c7cdbb4fc482bcf45803", - "bytes": 15741 + "sha256": "b8d06e7d6db2e7108a156b24025e0877519eaeae838cbd8168d45ffecc794a43", + "bytes": 15409 }, { "path": "apps/web/src/i18n/researchMessages.ts", - "sha256": "41c5cc77b2c7d309961e6a80dccdfd7030ef1439cec8c27166248b4ffa5a3c87", - "bytes": 12268 + "sha256": "3428986528bdbdb1f6a085be761bd846cec5628e41dfd1e4460d0fc2a0c1a6a1", + "bytes": 12003 }, { "path": "apps/web/src/i18n/researchPanelsMessages.ts", - "sha256": "158674efef73dcf203b7a7b0578211a3e16532674c7b35de468eca77aaafbde8", - "bytes": 13781 + "sha256": "775fb35e3685bb5555bf10f070395c8fba27f42bf927a0e6879e566982167b10", + "bytes": 13493 }, { "path": "apps/web/src/i18n/reviewPanelsMessages.ts", - "sha256": "a3188d0cd49641a6bf2824f2573d64aa3d365b76d0000180823814a93a1b0838", - "bytes": 12458 + "sha256": "54bdc7c5629f10008fa700470431cca57b4b621f7373eb0dec3955ec195e46a7", + "bytes": 12292 }, { "path": "apps/web/src/i18n/sentences.ts", - "sha256": "4d142059bc0ea31b3cc9557e47e02310c21d5474b20085894b1da075665c3b77", - "bytes": 706 + "sha256": "8feee445c4cf685d582d13c10212b129fd1cd84659b826b6e420ce0fedf8c66f", + "bytes": 708 }, { "path": "apps/web/src/i18n/setupMessages.ts", - "sha256": "8c5848880465020a4df189061d3580c8a98a1586c9e87e4977056d8e61253f7b", - "bytes": 9197 + "sha256": "1901bd71422710bc1a43ae6e67e5675b78b058fc17ba1428061bb2b77046b125", + "bytes": 9005 }, { "path": "apps/web/src/i18n/sourceMessages.ts", - "sha256": "251bc0fa24c14252d2c96f0aaadd839700045cca77d630ffacfbbefb32bebfee", - "bytes": 12378 + "sha256": "a4d19954db58df9f5ffcab4660b60f7b4c5b8fc8c989a692111a3a8c0dfdc9c1", + "bytes": 12138 }, { "path": "apps/web/src/i18n/stateFormatMessages.ts", - "sha256": "2169c1c5f1190acd0207ebb39c13bd237d5765fb3b12f22025c098c3fe360dbf", - "bytes": 16508 + "sha256": "0ac3e3e7da143ef78df2a5765d6b6375774aac663778910a6b50e488171b4752", + "bytes": 18790 }, { "path": "apps/web/src/i18n/statusChinese.ts", - "sha256": "beb99204454e2a8341fb4843949c1b6e8ff77f4a0760da2c34ffc8f7d0fe1d3d", - "bytes": 5043 + "sha256": "3d12b232a37bb3e7bf65357a436e08d881dceb9c0f8ceedf28493b7933d38a42", + "bytes": 5422 }, { "path": "apps/web/src/i18n/statusEnglish.ts", - "sha256": "caf375733d39ad9e9cd9da57fb770b0320c12fec2cb373c65f780099f7edb041", - "bytes": 5282 + "sha256": "0901b5fe568361c6c31eef72f3c21a4dc19fd0d1efb975eb02e1f76f6c0084fe", + "bytes": 5661 }, { "path": "apps/web/src/i18n/statusJapanese.ts", - "sha256": "e52df8d27d736da76c0e51d8209ba3a11d3f088c586fd9eb0972d4c02e146c49", - "bytes": 5812 + "sha256": "4c2eec076e7ace6eb7a32760ed4249f2d75a5d38892b96a0f454f7d4c7f82b4d", + "bytes": 6202 }, { "path": "apps/web/src/i18n/statusResources.ts", - "sha256": "6ccc34dff1bfd6c10dbbbcfdec2997705e058c3f6eef9e9d182134a0130cff56", - "bytes": 6592 + "sha256": "84ad39445341204e4b5a496891f6dd97e6219e4b5572ed8006f400ccab0d7885", + "bytes": 7104 }, { "path": "apps/web/src/i18n/statusTraditional.ts", - "sha256": "7233017698241aa41524877c9386adb84e2531107264b3c85ad8b24d84d3513c", - "bytes": 5047 + "sha256": "ca74017363d3c8f79d3118585b40cc7e6cf3a75c3c8dc19f531ec2e529fcbdf6", + "bytes": 5426 }, { "path": "apps/web/src/i18n/testOrderResources.ts", - "sha256": "7ec9ab5f64e52f7abddc8068482bac048da3ca1ad4072cdf55c1163a56c394b3", - "bytes": 8006 + "sha256": "598f8a48631c9d7f4bc5fb060d8a15d3ac802c91096b8bdb7751f484063eec78", + "bytes": 8380 }, { "path": "apps/web/src/i18n/traceChinese.ts", - "sha256": "5a73bda6f6688ad70e2af222a56c035a9ca8b4e110c2f03991b80815d8bdbaf7", - "bytes": 2953 + "sha256": "9748a5090a21d0ba10314be34c235729c35149071dfe7bc3230f03e17d421c1b", + "bytes": 3018 }, { "path": "apps/web/src/i18n/traceEnglish.ts", - "sha256": "f06bf4f80b017768ce5f2ca7a28e21313ac2399e1e912dbf9c6114e5833f6b64", - "bytes": 3088 + "sha256": "055dbe58d1c648d7795d274e829139b09d4d592da07e06152813ac0fe65fdf46", + "bytes": 3165 }, { "path": "apps/web/src/i18n/traceFormatMessages.ts", - "sha256": "1a4e3b00135adac072ab3566deaf2ae3656f1ce12230aa7f9648b526a48bfa16", - "bytes": 10218 + "sha256": "8a300825d9ea514a9b0cb1f5a4fd99bb52b78be3c24774fb7b5b1f96f49e5a4e", + "bytes": 11062 }, { "path": "apps/web/src/i18n/traceJapanese.ts", - "sha256": "51daafaa2d8d953fc6919d3d13bb5eced356458d571b6c2d50144f3a76115e72", - "bytes": 3347 + "sha256": "0cb1c6d10a16e4b5701fd3f74df6dd3d5fc5eb24cb219547ae4cd130a52635a3", + "bytes": 3424 }, { "path": "apps/web/src/i18n/traceMessages.ts", - "sha256": "f75a22e42cc334fa2e5109e9f5a3f888f36f4bb105b5ff39e5a05622af2c590a", - "bytes": 16967 + "sha256": "a49e856d9e4543079bd2f55a7dd5ac06992a3aa2d20e0fa77ea920e317567ab8", + "bytes": 16641 }, { "path": "apps/web/src/i18n/traceReasonResources.ts", - "sha256": "863030c1fb65d6e963e9a3419597dd3e556395c2cc3034a10e6625a7af445918", - "bytes": 7324 + "sha256": "f76465a6553d632dba9fb62207eca34af4f10d159d7c80bbfe7ed11737ca16f4", + "bytes": 7475 }, { "path": "apps/web/src/i18n/traceTraditional.ts", - "sha256": "bb6c65d337079f17627b5baa4fdc562227c99545679ece88e016c32463b562e2", - "bytes": 2977 + "sha256": "6236829c6b78f7c994e6fe0fead75064df2baff65007e91b27fd382525a40e68", + "bytes": 3042 }, { "path": "apps/web/src/i18n/uiFormatMessages.ts", - "sha256": "fb025d04f9dcd2f7db9a9acd3fa823dfb7e813e2a9fbafa6fd300e39df9f2329", - "bytes": 18661 + "sha256": "8f734ca405763309efd18551c53e57d86d033f2981bb56b810a217d03ef677d4", + "bytes": 20034 + }, + { + "path": "apps/web/src/i18n/uiText.test.ts", + "sha256": "001c32378906e0b3b2b2e8d4d5d7a2002e1a46d7dad157627a148057b63de1d4", + "bytes": 1389 }, { "path": "apps/web/src/i18n/withColumns.ts", - "sha256": "8a6cadd474fde415cf9a602393edbd2d5b628c3a4ba4d25ff1fc098e32e7216b", - "bytes": 1203 + "sha256": "7fb73a3dbf4044b5989dc6a3d350b325c051f0cb26f0a50859c098da7134929b", + "bytes": 1220 }, { "path": "apps/web/src/i18n/wordingRules.test.ts", - "sha256": "870be8773cfe36cb09ec48b50271e6d870fb9f642083406dd8f308dccc3b4126", - "bytes": 13932 + "sha256": "5de59ac52a965a6243c9ee4ab795d97681ad1a364cf8d6d3fce2a17da6ff4789", + "bytes": 14323 }, { "path": "apps/web/src/i18n/workspaceDetailMessages.ts", - "sha256": "5b4e5950f9c30b32f331ea09673efaf4dfcea273b9b9b17e5d28d57e5c09c5e4", - "bytes": 4031 + "sha256": "9b8607325281acf106aeca06e2f606f867ee17c5a06fb182754be5081032da99", + "bytes": 3972 }, { "path": "apps/web/src/i18n/workspaceMessages.ts", - "sha256": "74cf1cf623915a5e6ce1bc39d6ed8c89540795f084683099198d1ba8a713f45d", - "bytes": 15708 + "sha256": "81e247a851d56c2f1054bdbb60ad479c5e2bfe202ef855b01c494bc9afb23a6c", + "bytes": 15357 }, { "path": "apps/web/src/i18n/zh/activityMessagesZh.ts", @@ -2498,28 +2508,28 @@ }, { "path": "apps/web/src/i18n/zh/advancedMessagesZh.ts", - "sha256": "486ec1e1068496a86fd5471d804c13830b785d98e24872f65d4f97428f5f877a", - "bytes": 6527 + "sha256": "674d83fa503670903a38216b7cd86b2161272c0ad446d2f089bebdbae548189e", + "bytes": 6543 }, { "path": "apps/web/src/i18n/zh/apiNoticesMessagesZh.ts", - "sha256": "0708bf415de42d38033f94c796b1b3e250911e67843e9b37aabfb5e7d98f2020", - "bytes": 2663 + "sha256": "29be7fa9297078a17a6bb3de7613074900f2e629b576faca22874f37f02e679d", + "bytes": 2667 }, { "path": "apps/web/src/i18n/zh/commonStateMessagesZh.ts", - "sha256": "56c2872633d5fa0c14050b04e2bed22cd82f98374976a6db1b3ee8a19e2f6bbf", - "bytes": 2884 + "sha256": "a5fb912f6ac3cb102c343a6c5efa4717d62d554b2e7d0175a26ae8f8c59ca86a", + "bytes": 2888 }, { "path": "apps/web/src/i18n/zh/coreMessagesZh.ts", - "sha256": "4c166f38e2a4d66328bb0b7d6b4673ded14c7c3e0f9c80b16f6127c0434efb00", - "bytes": 3964 + "sha256": "9ee1916320c50483d0a806016334b01d8c8e1571a21f721116753d2f1b254b57", + "bytes": 3980 }, { "path": "apps/web/src/i18n/zh/credentialMessagesZh.ts", - "sha256": "84c8184b2722a725b8c5ff63804dbf05f11467ec919fd7555237ed2d9dd23cd7", - "bytes": 5608 + "sha256": "d45b7c68c15e6b88e31c38f18ab31efd3b1cf8532bd6a3238f16b435d70b527a", + "bytes": 5640 }, { "path": "apps/web/src/i18n/zh/finishingMessagesZh.ts", @@ -2528,8 +2538,8 @@ }, { "path": "apps/web/src/i18n/zh/followupMessagesZh.ts", - "sha256": "b47227cba0d52fd1d5c25381a972d1ade230f5b84c480356fd1989dbd87975cf", - "bytes": 5214 + "sha256": "eb7663f38fe93d69eb993458d25b68977f65e717c442f4398f5204e85c3d3678", + "bytes": 5226 }, { "path": "apps/web/src/i18n/zh/groundingMessagesZh.ts", @@ -2538,28 +2548,28 @@ }, { "path": "apps/web/src/i18n/zh/historyViewMessagesZh.ts", - "sha256": "0cd50b597f6ae360d8048afc0b2ad15c59b99045aa7b0770fca6a8bbeea411ec", + "sha256": "d0952eb701b09c5ef6a27f1cc5d7a1e8a00874f624732823250747e90233e2cc", "bytes": 4465 }, { "path": "apps/web/src/i18n/zh/hypothesisActionsMessagesZh.ts", - "sha256": "91bc81d9f5272cefeafc05768ee51f89595505cf74af41b40a85a3503444d52b", - "bytes": 7013 + "sha256": "514f11a25256a390174293866f22da72dd544341d51bb3641de140a58ce7744e", + "bytes": 7025 }, { "path": "apps/web/src/i18n/zh/judgmentMessagesZh.ts", - "sha256": "088440b1043c38152fe0c08c1374b53f43614157d8941689c277d0b4fd19a5e0", - "bytes": 3151 + "sha256": "1d8bfbe1c4408e7146ade49b76cc1e042f3b397d3a55a8fe5c804b078be504d9", + "bytes": 3171 }, { "path": "apps/web/src/i18n/zh/localRetrievalMessagesZh.ts", - "sha256": "aeb91b2fa88cb58b3860899c7d9ae7b4448778653b83a78e2253699ecc50031b", - "bytes": 9552 + "sha256": "84383b00e2089892951e041eb8816032445f550bcd7c2f753bc355ea20ee63eb", + "bytes": 9509 }, { "path": "apps/web/src/i18n/zh/modelHelpMessagesZh.ts", - "sha256": "78b0f7d229c93d0e78844884776df45609e0e62fd062f26d0808cba9044fc94b", - "bytes": 1231 + "sha256": "b8fa218521e4a0e3fd5cd74ff3a7137a719bfea9821824e4cbc6515956e67caf", + "bytes": 1239 }, { "path": "apps/web/src/i18n/zh/oauthMessagesZh.ts", @@ -2568,23 +2578,23 @@ }, { "path": "apps/web/src/i18n/zh/parserErrorMessagesZh.ts", - "sha256": "4aebb261cdf446951e376bba341c051252367fff199a7c368a0cc8b4873a8e7a", - "bytes": 2547 + "sha256": "d4f6a260a5082ac1efd48969a7c52705bfcd0bd16b3bd6e9ea7123c46a83d1aa", + "bytes": 2571 }, { "path": "apps/web/src/i18n/zh/recordDialogsMessagesZh.ts", - "sha256": "43c8f1966a711f6355dfe00fbf5fc5959e1ba024ed5322e7b43ed7cfcff2d1fb", - "bytes": 5888 + "sha256": "c3041948e76b0dee1e1bfa7f83f8b85a8c44da05a71749b7dd4effc300174fc5", + "bytes": 5914 }, { "path": "apps/web/src/i18n/zh/recordPanelsMessagesZh.ts", - "sha256": "041cba3da1b96cdf01237fb7d8be371cabb50f5b576a1de4d04cd1d91c4a9855", - "bytes": 6905 + "sha256": "58dd7295309b32b8a7a5a185658d7a0e0c3dab129e78ae609ddda1f79b3c2a3e", + "bytes": 6903 }, { "path": "apps/web/src/i18n/zh/remainingMessagesZh.ts", - "sha256": "9b4df1ba25acb1ae79b182c46109dcb75f76e040d6c97a14f69bb931a70abc9b", - "bytes": 7195 + "sha256": "36f1251a10f644fa9b4cebfcfc364f3fb75541a382781813e40f0ff93c9b4022", + "bytes": 7211 }, { "path": "apps/web/src/i18n/zh/researchMessagesZh.ts", @@ -2593,38 +2603,38 @@ }, { "path": "apps/web/src/i18n/zh/researchPanelsMessagesZh.ts", - "sha256": "032cbc9b01f1efae89c6964c5a4bdb0cc34825eef6b3d1a7ed6745bcdc6180a8", - "bytes": 6334 + "sha256": "c5dacea30fab366ec7e2a71971dddac1859aa9ea55c3e0b470a7dbc0408813c9", + "bytes": 6342 }, { "path": "apps/web/src/i18n/zh/reviewPanelsMessagesZh.ts", - "sha256": "374f2ca1e724bd9eef8b47cbf9f5eb582c9ce1a83e901a5efd4635258d47436e", - "bytes": 6048 + "sha256": "45ab51576458104dec47811ad57438615abe0dbafb984eb5d800730271f38882", + "bytes": 6060 }, { "path": "apps/web/src/i18n/zh/setupMessagesZh.ts", - "sha256": "376e9e2643534ed5e87db678952ded44680e8a00cd4b097aa50fb830254afcd7", - "bytes": 4270 + "sha256": "f02a9d4bf9dcd3fa1849d844c8a47786a3f705f9807163ed3f36f675eae7bc2e", + "bytes": 4282 }, { "path": "apps/web/src/i18n/zh/sourceMessagesZh.ts", - "sha256": "763ce2d5a40e5efadc88985e2602ae20af83067303a78f3e262709bd7f6cdc0f", - "bytes": 5542 + "sha256": "c0d6d0447f84e1289432c15ec39074bab0aed8eae48f1f134cda374b251ef89d", + "bytes": 5562 }, { "path": "apps/web/src/i18n/zh/traceMessagesZh.ts", - "sha256": "b50170874a233801e326e623a0a7f666f242c6cd4ad9c6dada00a641bcea0b75", - "bytes": 7920 + "sha256": "6e9bd9fa3ab92e33399643f5a72bee2c10c7257c5681699ac49e78f191955b89", + "bytes": 7930 }, { "path": "apps/web/src/i18n/zh/workspaceDetailMessagesZh.ts", - "sha256": "75c523a6d22f8c4a9c59cd8b51103df430a85d2d151136ac82fbbf164884ee99", - "bytes": 2007 + "sha256": "e566dddee226e8c169ae1d54b21d5f8fbb3febfb5f7e14ab86f6dd2dd0eb30bc", + "bytes": 2015 }, { "path": "apps/web/src/i18n/zh/workspaceMessagesZh.ts", - "sha256": "6562f00fb08f60eab15cd5595dc324887af7578c9988c34d61f3ab75ac621c20", - "bytes": 7480 + "sha256": "b9539b8f3aba930f05d390f7f89b39c19dd45d3e01f023dc9ee32207b76fdcd6", + "bytes": 7500 }, { "path": "apps/web/src/i18n/zhHant/activityMessagesZhHant.ts", @@ -2633,28 +2643,28 @@ }, { "path": "apps/web/src/i18n/zhHant/advancedMessagesZhHant.ts", - "sha256": "b82f78717da755b06a224436c4a8a9b7e0a67a912e797c44d44d4840b8e1c670", - "bytes": 6537 + "sha256": "36f99847676a5012d249234bfaa410fef8ee13765c4606d9de09a734a34b2d46", + "bytes": 6553 }, { "path": "apps/web/src/i18n/zhHant/apiNoticesMessagesZhHant.ts", - "sha256": "56aa8eacb6c8c88fe09895e71bee4eb5679fe6da1e686ada31395670717c53e3", - "bytes": 2667 + "sha256": "81633ab27a831d15fa129971653438ffbc59919c741f421bc3a214eb71b9b2d5", + "bytes": 2671 }, { "path": "apps/web/src/i18n/zhHant/commonStateMessagesZhHant.ts", - "sha256": "3efddae9e9111df427c760b2eb8676069de4f51035390e034b39736b6f422f83", - "bytes": 2888 + "sha256": "decb6e5ba3b1c76cc3e08a51ad94be4ecba035fb7b043c4b49058b5b7a24fef5", + "bytes": 2892 }, { "path": "apps/web/src/i18n/zhHant/coreMessagesZhHant.ts", - "sha256": "46abe494a7fca2cf2a5f7b0b565168f2a5c4e08605b349b51fa3d9a6c49ab4bb", - "bytes": 3962 + "sha256": "d79b8331fb2a107c2665b81662f2dbf8275b7aa2de6e9efe3fdcc621f8ccfe15", + "bytes": 3978 }, { "path": "apps/web/src/i18n/zhHant/credentialMessagesZhHant.ts", - "sha256": "b868623e03158de0f927348504258a97d045ed10264e244baeb37d84dd4a55ac", - "bytes": 5627 + "sha256": "ebde5e650785e0331e1b0c508e3f54d4b47f004176edd6bcca3efdef7a78ce75", + "bytes": 5659 }, { "path": "apps/web/src/i18n/zhHant/finishingMessagesZhHant.ts", @@ -2663,8 +2673,8 @@ }, { "path": "apps/web/src/i18n/zhHant/followupMessagesZhHant.ts", - "sha256": "f7f91998c70265d41a5ecf7558cd8f99b4acd3ec31c4585ecbb980dc59802d60", - "bytes": 5230 + "sha256": "5d4a8bfca8695ce38abbd302c77083426a6d8ffc8f215a57907fb6b7c8941ea3", + "bytes": 5242 }, { "path": "apps/web/src/i18n/zhHant/groundingMessagesZhHant.ts", @@ -2673,28 +2683,28 @@ }, { "path": "apps/web/src/i18n/zhHant/historyViewMessagesZhHant.ts", - "sha256": "9ea9a1ab5ce5aaf53b34a0115fe10d7e9aa3655779cd38489b5cdf2dae2060c7", + "sha256": "61ab6a22d9d99e9c48edd8f764f14647330e59370f5374741663fcc7106ef183", "bytes": 4469 }, { "path": "apps/web/src/i18n/zhHant/hypothesisActionsMessagesZhHant.ts", - "sha256": "4e548d4ec435f59475ad3decbffced9d117433cb2eb80099581f5061476b6288", - "bytes": 7038 + "sha256": "a40b10e1878fd433e820e43b9613642e18d0c65e92a13026675381af281ad466", + "bytes": 7050 }, { "path": "apps/web/src/i18n/zhHant/judgmentMessagesZhHant.ts", - "sha256": "87b72d5b205e041184a1ffd0349d4c92936f0e7a46fe7b43040ed70e20cfceb5", - "bytes": 3155 + "sha256": "dbd1020fb414d84ed6430218a095f6491b3098c1c72eca79c490b28411f58718", + "bytes": 3175 }, { "path": "apps/web/src/i18n/zhHant/localRetrievalMessagesZhHant.ts", - "sha256": "c5d5b17eb574df1c94190877dd09cee484ddebe95d26db342628278a946a2ccf", - "bytes": 9572 + "sha256": "a4a6d85ae0b54f091812574ac2624308161f620871b93dc8b8277ac11ee31112", + "bytes": 9529 }, { "path": "apps/web/src/i18n/zhHant/modelHelpMessagesZhHant.ts", - "sha256": "825166a790b89bc9771566606f6d205224b1beef3fecd95273d812e0f56768e1", - "bytes": 1247 + "sha256": "a61e2f0a93735b5b98de0784c79616b7fecaa0b880df80e47ae9c7c2416096fb", + "bytes": 1259 }, { "path": "apps/web/src/i18n/zhHant/oauthMessagesZhHant.ts", @@ -2703,23 +2713,23 @@ }, { "path": "apps/web/src/i18n/zhHant/parserErrorMessagesZhHant.ts", - "sha256": "918f540d7335215c22bd4bc6d11b3718c4deafa2f643bd13caee73478c798da9", - "bytes": 2551 + "sha256": "2416a32e648d15d47133ad8b8741649808d2d48c71052f2d24947278f16e1812", + "bytes": 2575 }, { "path": "apps/web/src/i18n/zhHant/recordDialogsMessagesZhHant.ts", - "sha256": "dd6382826b2df1d6a9b18e1e977d272f6c212e905f7ab313130e218de91ce78d", - "bytes": 5898 + "sha256": "9e5086880e4d498c9ea14e12ce8c71dd90b0e84a75c61fc59d414a6c873483ae", + "bytes": 5924 }, { "path": "apps/web/src/i18n/zhHant/recordPanelsMessagesZhHant.ts", - "sha256": "b302a80611a0d9b4f939c722658b46372ff630b7d5f05bc33020556f2c711be6", - "bytes": 6912 + "sha256": "6bbdb0d2c2fa56eb7977baba7878a48d8f68a5b746c9d3f6f0d8fee1c923e8c4", + "bytes": 6910 }, { "path": "apps/web/src/i18n/zhHant/remainingMessagesZhHant.ts", - "sha256": "4ba763a3a3d0b27c3d3dc1be2ecc1e6f62ae0b5116114edff38f8b69447b2c47", - "bytes": 7229 + "sha256": "94c3a3626601c63143b351c18a2988779a0be393fbd7b7d3095878beb6caee52", + "bytes": 7245 }, { "path": "apps/web/src/i18n/zhHant/researchMessagesZhHant.ts", @@ -2728,48 +2738,48 @@ }, { "path": "apps/web/src/i18n/zhHant/researchPanelsMessagesZhHant.ts", - "sha256": "48d5af9666d902d121d784c93e90d62ea27e1b5d709f54dc4a6d7d3560142235", - "bytes": 6347 + "sha256": "085cfceadff1ea31b2826924a4d6492db582f614f672dc094459a3a1ed8b103e", + "bytes": 6355 }, { "path": "apps/web/src/i18n/zhHant/reviewPanelsMessagesZhHant.ts", - "sha256": "a7850c0795232e5a2023409682bf5476bf000a91443fa41172704299e553bec8", - "bytes": 6049 + "sha256": "3be47d0105724b46cedc28ce8834041517c8ec91038f5ad67faf8600c1f68804", + "bytes": 6061 }, { "path": "apps/web/src/i18n/zhHant/setupMessagesZhHant.ts", - "sha256": "625b7064eb2c5ddc08461980bf0c653136e60a705223317a2f381619373962fd", - "bytes": 4286 + "sha256": "2852f6b9fb94173142b8dc4b366b9acab9a082a7baf2f407c44a67e6bed404bc", + "bytes": 4298 }, { "path": "apps/web/src/i18n/zhHant/sourceMessagesZhHant.ts", - "sha256": "da02551fc3378c285d15859908bb770fce45d234e876c02ff336a76991df6637", - "bytes": 5555 + "sha256": "dabbbf7875d7a73a70bffae092b1df11da6f8606674b55d640a1f77ed6d366af", + "bytes": 5575 }, { "path": "apps/web/src/i18n/zhHant/traceMessagesZhHant.ts", - "sha256": "4fe99cd1bde8e5cf2428036433b48d444cadfd6b7354333c4809ce673483a521", - "bytes": 7933 + "sha256": "9260fde4f9230757bd1060c2fad4797f4d8fa90901bb1489158413d4e12d674b", + "bytes": 7943 }, { "path": "apps/web/src/i18n/zhHant/workspaceDetailMessagesZhHant.ts", - "sha256": "29dd70f4e7ecbf071604177f8d2a5c861dc88c6aebdad314769b4bc35833d92f", - "bytes": 2005 + "sha256": "889614705d9371b026fe1ddf3229274069cc9444de76ac7a06b4b022b7596343", + "bytes": 2013 }, { "path": "apps/web/src/i18n/zhHant/workspaceMessagesZhHant.ts", - "sha256": "e08758b72bb82891b0f88805e02db8053a123401ba235a99ef2939fd97de8793", - "bytes": 7514 + "sha256": "5c4a8bfa70c85e1db947a22c81b2aafcb86afa156bb4ec94e6bfdba0af1e8f7f", + "bytes": 7534 }, { "path": "apps/web/src/i18n/zhHantScreen.test.ts", - "sha256": "3b6e009c3cd57ceae83c89eb0f149fd4a192cce7035abd2806b4637e73926c5d", - "bytes": 11238 + "sha256": "b08e71ef5abc28e7eecba47e3ddc4c3f31e5f12ec8718b5504fc297d7de5d019", + "bytes": 11585 }, { "path": "apps/web/src/i18n/zhScreen.test.ts", - "sha256": "84da741c0497b7e6438074737ace8da301d13b046b4f072407fa60e9a574e9b7", - "bytes": 8803 + "sha256": "d10c07c0d3b327c93ebe3030b52fd326f7527ed995805bf69bdf1715ebf1ae0d", + "bytes": 9158 }, { "path": "apps/web/src/main.tsx", @@ -2778,18 +2788,13 @@ }, { "path": "apps/web/src/narrowLayoutRules.test.ts", - "sha256": "e1ce29dc550b80197befd2067339f6d36d08d341218a9337e3ab722d38bc1761", - "bytes": 6689 - }, - { - "path": "apps/web/src/preview-timeline.tsx", - "sha256": "035099afc0c013b87bd2f61e408b064e7c4d9c8fb50545e6b3d27339b52a8d1e", - "bytes": 28 + "sha256": "58ab37efde9939feb7acd176267a1d88fc54ee08ecd2afd27fdcdd782b0edc22", + "bytes": 7054 }, { "path": "apps/web/src/sourceDisplay.test.ts", - "sha256": "9e95e927122c48ea24490a539643c1bd1f2a3bbec85f8c5df2cddbde82cfd855", - "bytes": 1057 + "sha256": "7301490697ee075b551b60b8ae2d68c63ae2214579ad1e6da5bb730c81c20e2c", + "bytes": 1100 }, { "path": "apps/web/src/sourceDisplay.ts", @@ -2798,18 +2803,18 @@ }, { "path": "apps/web/src/styles.css", - "sha256": "9e1985afd5cbad3d22f21636159b9fae8c52c9c5114f36512f55cbe4f373c082", - "bytes": 22558 + "sha256": "4a045dea56b45b901e978b2b50a187250fe1cca585875649031a550932e7a651", + "bytes": 23862 }, { "path": "apps/web/src/test/jsdomTopLayer.ts", - "sha256": "544f7ce85aa84cdcf9ce479a722a73a70a29ba2c0d034acce67d2f595bf1e373", - "bytes": 835 + "sha256": "5ab3b073b13af439df19a62abb9282c19be6b1f327ed180bd744c4dd110da667", + "bytes": 841 }, { "path": "apps/web/src/testSetup.ts", - "sha256": "439299de2868d30543e93fdf1385a4c6cf5731bc2f8d4517d0de1ae97956e366", - "bytes": 405 + "sha256": "d37ae671a4cb33b93f97ba756e184b67ec4b41c056f1bae7193700a47e4f0a38", + "bytes": 410 }, { "path": "apps/web/src/timelinePreview.tsx", @@ -2818,8 +2823,8 @@ }, { "path": "apps/web/src/typeScaleRules.test.ts", - "sha256": "144e34be972db8f63f42bea91cafd73cb81d41b9abb9e45f6ac476ad8365212a", - "bytes": 1920 + "sha256": "5cfc794de17713350752502782872e6a3c4cf5d36162e1ebb0a0d0fefa45ea33", + "bytes": 1990 }, { "path": "apps/web/src/types.ts", @@ -2828,13 +2833,13 @@ }, { "path": "apps/web/src/vite-env.d.ts", - "sha256": "424faf9241dd699dda995b367ed36665732da1e6ec1f33b2fd40394488ecac92", - "bytes": 39 + "sha256": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5", + "bytes": 38 }, { "path": "apps/web/src/workstation.css", - "sha256": "ed5fe3509cb88b96ee93264fb467a67a0192c0002ed8802b129a1dae74fc498d", - "bytes": 74820 + "sha256": "1465476be92d2466c12edb2641635dd6e957d026007c6e0da2d026ac711e74a2", + "bytes": 84651 }, { "path": "apps/web/timeline-preview.html", @@ -2843,13 +2848,13 @@ }, { "path": "apps/web/tsconfig.app.json", - "sha256": "ade56e5ebdec988dd5269313a2487b3fb3eefae5f70aba5b3b9f6eefd0c44a16", - "bytes": 568 + "sha256": "ec45293782914673548ce66cf2727e7192a6a0f80d8650d9fe40aa895caf0b55", + "bytes": 567 }, { "path": "apps/web/tsconfig.json", - "sha256": "c3c1a5af0c9ed5727b295bd39076550c6a48a7a7d8a79eb29adc4e6c370830f0", - "bytes": 120 + "sha256": "e8d6f7b50828db3953e21c7e92584f9206b3cd2551b4012507372afbb7fe9ce3", + "bytes": 107 }, { "path": "apps/web/tsconfig.node.json", @@ -2858,8 +2863,8 @@ }, { "path": "apps/web/vite.config.ts", - "sha256": "4378a2ea0a6e79cc63ca96d6705a33edd186046e43039c4184446b47969763e9", - "bytes": 522 + "sha256": "c77e0fa23c6eddce8d0922b899a847ec9bfbc1a58088a2422ed7e75b02632148", + "bytes": 501 }, { "path": "config/architecture-conformance.json", @@ -2928,8 +2933,8 @@ }, { "path": "config/module-responsibility-budget.json", - "sha256": "6e9f7bb4e27dc00c8edbe7b23ca6732df6a01c422ce0d88cff02759affba4ecd", - "bytes": 8580 + "sha256": "ddc4b55de8ddf3b43add5fa20d33c5e0a6a1de3df2cc5b2acb8655db701e4a20", + "bytes": 9947 }, { "path": "config/post-d4-baseline.json", @@ -2973,7 +2978,7 @@ }, { "path": "docs/BETA.md", - "sha256": "bd0b80167226de0a4737254bc8610f1409d7d38764d48fe156b06ba6e255d81c", + "sha256": "b10b74e6969ebfbe121309a0afb2973589263d1322824223f657b9ec810d6b88", "bytes": 15322 }, { @@ -3153,8 +3158,8 @@ }, { "path": "docs/architecture/verification-profiles.md", - "sha256": "9319dfb3cf6eadcbcae238ef061542bd83892a8175aed451d80c42a0b90f92e1", - "bytes": 5729 + "sha256": "4bf1da232faa8b86c154ad95c612c85af9e9f725a6dcacacc7d0a0f9fbc28142", + "bytes": 5810 }, { "path": "docs/brand/README.md", @@ -3206,6 +3211,11 @@ "sha256": "6bbec26ab2474fa97bf481422a5a453de77f10de7eab03f9ce9a22876bb78df2", "bytes": 2299 }, + { + "path": "docs/releases/v0.2.0-beta.4.md", + "sha256": "7950b01eb040aa12ed66535ca802780a253f789065c8a12bf4ac6665664bd714", + "bytes": 4520 + }, { "path": "docs/screenshots/01-trace-verdict.png", "sha256": "800800b0e2bcfdd290d2f911618aab8a8e60e8e803d2400df9829cd61a351989", @@ -3713,8 +3723,8 @@ }, { "path": "migrations/versions/3b06e4f87d92_policy_digest_history.py", - "sha256": "be94048bf0570366a7a1c4f30bfe9ef26321d96809ca3319eabc9e68a2fd929d", - "bytes": 3821 + "sha256": "3f8490aedb8d2cf71c445cdeea63dfece3526dbbd04349fcbd6d0b107222a536", + "bytes": 3825 }, { "path": "migrations/versions/3dca0215d6a9_f4_outcome.py", @@ -3748,8 +3758,8 @@ }, { "path": "migrations/versions/7b06e5a9d3f1_a06_full_project_memory.py", - "sha256": "307db917e9a4e6a674c6f4bf35c4dd0750d70c267fecf5c1e70881e251655b9b", - "bytes": 3123 + "sha256": "e6793392ca913ad4528d7faf7213d9d509d5f603b18baf94f76645faa9efe765", + "bytes": 3051 }, { "path": "migrations/versions/8c13f6b2a4d9_a13_local_auth_sessions.py", @@ -3888,13 +3898,13 @@ }, { "path": "package.json", - "sha256": "7ebec460cfd37636034bb196caf162451358e5be0962fa19e36a5bb89e1f88be", + "sha256": "508dac102ac112a581fc16a93723d89c3bb8b4789b2d688d5d87d7101614afbe", "bytes": 106 }, { "path": "pnpm-lock.yaml", - "sha256": "7c92c27161a1705eb01b485f6b3d494400f5d8e18144217e1913e55221becaf5", - "bytes": 94958 + "sha256": "6f59c72f4b62a617a4043e09c4fe17d2212f867a42f9c5da207cfdcd2745464a", + "bytes": 129123 }, { "path": "pnpm-workspace.yaml", @@ -3903,8 +3913,8 @@ }, { "path": "pyproject.toml", - "sha256": "46eb245f7bf107b5d6489ccb7ca3a5927151f78d60d4d2ed160b4d63247b78a7", - "bytes": 1989 + "sha256": "361a88d4f8946a7ac84ad4dc91e9638d0871e4b267c6329a35d38d1e2f94bd72", + "bytes": 2454 }, { "path": "qa/scenarios/hero_6g_current_core.py", @@ -4308,7 +4318,7 @@ }, { "path": "src/thoth/__init__.py", - "sha256": "c36f5a2c2fd12d07d37f18e6ca5f0715f4a8a5c069984a38c336d8631663b899", + "sha256": "4e3584a994cb8ae6641a6a583403fa35517272033826436590f6afb86daf230f", "bytes": 56 }, { @@ -4393,8 +4403,8 @@ }, { "path": "src/thoth/adapters/connectors/http_read.py", - "sha256": "44ba72cebf0dde5d4fad206091b79449b78b6ac742c20599f197e4b4fc5520b5", - "bytes": 8337 + "sha256": "7cd0d7a893e05e16515308592bea49a084550755e990a1c195d02ccdfc6ab53b", + "bytes": 8518 }, { "path": "src/thoth/adapters/connectors/local.py", @@ -4413,8 +4423,8 @@ }, { "path": "src/thoth/adapters/connectors/postgres.py", - "sha256": "534125322625826456d2501e0f505f5997acbcf9bb9eda7cf347efd889e5cbd5", - "bytes": 6615 + "sha256": "cce583924b95360f1b0c5f8e89273d592bf7770577458731e080b7abee4557a1", + "bytes": 6790 }, { "path": "src/thoth/adapters/connectors/project_public_web.py", @@ -4438,8 +4448,8 @@ }, { "path": "src/thoth/adapters/connectors/s3.py", - "sha256": "309c15edb8104aded60c2bf711ff23de4bf27e46ace2cbae978660593f527f8e", - "bytes": 6694 + "sha256": "902f386e19ffffed8453024df8885f5fa69b32e0b440328751ce15b821831ecc", + "bytes": 6648 }, { "path": "src/thoth/adapters/connectors/site_discovery.py", @@ -4508,8 +4518,8 @@ }, { "path": "src/thoth/adapters/evaluators/__init__.py", - "sha256": "5793b548522565bbb89e310f037209279a9494bf37a2d5c97f2eef9d8100e2d5", - "bytes": 295 + "sha256": "5824a2cdcf54eb72952498f0b36be3c3adc5194e7a1543b4e5b32e3ff1260c09", + "bytes": 294 }, { "path": "src/thoth/adapters/evaluators/catalog.py", @@ -4638,8 +4648,8 @@ }, { "path": "src/thoth/adapters/memory/relation_projection.py", - "sha256": "db5d9f6337a15d1b8f94ae9e158a53f8de1cc7af6e0a533f042a6280bae299d5", - "bytes": 588 + "sha256": "10308322dd0c68eb521add9bab068ba9228a022346f58d2e484e4331a24fadd6", + "bytes": 574 }, { "path": "src/thoth/adapters/models/__init__.py", @@ -4723,13 +4733,13 @@ }, { "path": "src/thoth/adapters/models/codex_account_usage.py", - "sha256": "04e8e210b34b0975de68f646d325ca0652ef70764444a752ff381bdd08596a51", - "bytes": 4036 + "sha256": "ac931d9370fdc7b40371837b759e74d35b328c746e53aec56f22e587d4c59a15", + "bytes": 3988 }, { "path": "src/thoth/adapters/models/codex_app_server.py", - "sha256": "d1da91f0a11ad94939e1044df2b5ee419a776f3965878766d284b18d96dac769", - "bytes": 8930 + "sha256": "b55a7e58e239941660991a793d58d829f44dee9501715d1bf0a5919bb3d547d8", + "bytes": 8995 }, { "path": "src/thoth/adapters/models/codex_broker.py", @@ -4773,8 +4783,8 @@ }, { "path": "src/thoth/adapters/models/http_rejection.py", - "sha256": "c084c5c204216cfaca12b321e996ab4164421dba8accf0bdfe18df3e8067762b", - "bytes": 4751 + "sha256": "ca60717370ac66e05b9c7b7dbc45008e89f53b42da1336f903ad215567675f4d", + "bytes": 4697 }, { "path": "src/thoth/adapters/models/hypothesis_contract_text.py", @@ -4818,8 +4828,8 @@ }, { "path": "src/thoth/adapters/models/registry.py", - "sha256": "15e547bb4945c416bf8eb5e9e8c53186b298840700bed700915ec0eb891d84a7", - "bytes": 1703 + "sha256": "5128fc6cc5fed5a1a146bb4176fef9bbc2351d39ca194945227e32ee3aa2ad76", + "bytes": 1689 }, { "path": "src/thoth/adapters/models/response_events.py", @@ -4933,8 +4943,8 @@ }, { "path": "src/thoth/adapters/parsers/docling_pdf.py", - "sha256": "d9023ceb8962552cb1bea84d57548b50a7bdbc59159ffbf01a6979fd71618d44", - "bytes": 5016 + "sha256": "87f3bdbb42b77d5cf9e6c3a3d22e79181969d5d3e33c39196ef383525e4535a6", + "bytes": 5039 }, { "path": "src/thoth/adapters/parsers/docling_structure.py", @@ -4948,8 +4958,8 @@ }, { "path": "src/thoth/adapters/parsers/document_time.py", - "sha256": "a1e2f3cde5e1cd8e5a43a66d8e1d1da8363c350789f8156d6d37e79426bcbd43", - "bytes": 13350 + "sha256": "c26f8581d3a5039b263bd0be912339e3ba845d322b8bae6e20cd0f46ce8c2fad", + "bytes": 13378 }, { "path": "src/thoth/adapters/parsers/docx_parser.py", @@ -5118,8 +5128,8 @@ }, { "path": "src/thoth/adapters/storage/alembic_migrations.py", - "sha256": "4f841c1cc6396f6c3d82f6e580fc3051b1d001da1d0353c1145546e81282b796", - "bytes": 7295 + "sha256": "7574aebb2b2ed4406ed90f5bc94baaff6175c0ac5247e970a20b31090fffbb81", + "bytes": 7273 }, { "path": "src/thoth/adapters/storage/artifact_catalog_rows.py", @@ -5143,8 +5153,8 @@ }, { "path": "src/thoth/adapters/storage/auth.py", - "sha256": "8cbdd03ba2e759cd5220b06e3e06a3e115687e9ca9cb736d6c398dbf85154132", - "bytes": 1844 + "sha256": "6b597564918bd77af7e7a0208e43d4f886959cbb577dc778b2e2cfaf82011b14", + "bytes": 1790 }, { "path": "src/thoth/adapters/storage/baseline.py", @@ -5273,8 +5283,8 @@ }, { "path": "src/thoth/adapters/storage/field_measurement.py", - "sha256": "180d71bbad2b62340821b70ad3bf9ce1cddef7d6c1f4d11b8d06c5c1269ad7b2", - "bytes": 6099 + "sha256": "ec03d7cbff003ffddfd0f6816d2c1099e0cb31f6f7cdf448913f2aff5f66706e", + "bytes": 6047 }, { "path": "src/thoth/adapters/storage/governance.py", @@ -5328,8 +5338,8 @@ }, { "path": "src/thoth/adapters/storage/object_prune.py", - "sha256": "968d49f49b383a60631def53d97e91632098ea42889c7774cd3ccf69e5096949", - "bytes": 13643 + "sha256": "7c761ebd509c5d8d15323e507db2c51528c29272e7c07a4daafe08994358d37d", + "bytes": 13748 }, { "path": "src/thoth/adapters/storage/objects.py", @@ -5368,8 +5378,8 @@ }, { "path": "src/thoth/adapters/storage/project_purge.py", - "sha256": "439edd1d4a0beff1c10f905adce37e6909f5590818a42140b3898e1cf828ab51", - "bytes": 10116 + "sha256": "281b2193887576ffb6bac2cf116a312f51d27e3f52fc94126a463c7ef7125f69", + "bytes": 10227 }, { "path": "src/thoth/adapters/storage/projects.py", @@ -5398,8 +5408,8 @@ }, { "path": "src/thoth/adapters/storage/research_queue.py", - "sha256": "5b6cd78598ab6c6e65ff4125f22b6add10157438f7c0d5514ecaeb095ea6c913", - "bytes": 3258 + "sha256": "243a2fca9fe31a3fab373ddbfe3a2821e9071391ac61c286ac8bb6f75fc5825e", + "bytes": 3236 }, { "path": "src/thoth/adapters/storage/research_schema.py", @@ -5673,8 +5683,8 @@ }, { "path": "src/thoth/application/commands/investigation.py", - "sha256": "56c8b0887490c47393f597587de97805a9c9eec7179fe73a6721caaa0a77b7fe", - "bytes": 10963 + "sha256": "fc25ff53b522dbab9de1a1231ec6585bb58c86cefda125ac589a1021dacdb9f8", + "bytes": 10925 }, { "path": "src/thoth/application/commands/judgment_review.py", @@ -5803,8 +5813,8 @@ }, { "path": "src/thoth/application/commands/projectpacks.py", - "sha256": "525fc23114d1a72fd7ae7df2a9eaf60bfc832b7273e1c0d0da4c199daf14d60c", - "bytes": 2868 + "sha256": "f5117bc6681e635caa8cfdab5246fb11c84bb658a05744ae934920e5a531c427", + "bytes": 2846 }, { "path": "src/thoth/application/commands/projects.py", @@ -5908,8 +5918,8 @@ }, { "path": "src/thoth/application/commands/sources.py", - "sha256": "c516364e7e8cab54db718e6006e3af32084f90eeecb5c17cf9dea57f1251c4d6", - "bytes": 19296 + "sha256": "c204c5db071d6496ce3eeb3a6a6082d7d835e6f0836e0f1570b03f528c6f70b6", + "bytes": 19314 }, { "path": "src/thoth/application/commands/thread_analysis.py", @@ -5963,8 +5973,8 @@ }, { "path": "src/thoth/application/reducers/hypothesis_reducer.py", - "sha256": "d58b280721967bb814f1879e0a62d81953e255d121c9cdb0ab665085968d1ee0", - "bytes": 1491 + "sha256": "b58694474c9b6340a0fcb9988cd6748cb4844a7485fb301a942d597c122e6431", + "bytes": 1535 }, { "path": "src/thoth/application/reducers/outcome_reducer.py", @@ -6168,8 +6178,8 @@ }, { "path": "src/thoth/application/services/conversation_router.py", - "sha256": "43d22e37e8a5eac8a0431a2cc08052f1ed984d693c8a91d244664d0e3691a8e4", - "bytes": 14872 + "sha256": "efe20525539fe4b6fa80ded7cf7f7dbf71a59e9b74e13aaebf61bafde0a4ed1a", + "bytes": 14902 }, { "path": "src/thoth/application/services/counter_search_calculations.py", @@ -6203,8 +6213,8 @@ }, { "path": "src/thoth/application/services/criterion_compiler.py", - "sha256": "7fd5eaac2e3fc9d091353e7378c4da81343ba833a05bea9d945bb73ecef1c4c6", - "bytes": 4761 + "sha256": "ed0c81833d2edb98ef0b0a776a8137722d66d6d4c9f75d98edfded072737f9fa", + "bytes": 4739 }, { "path": "src/thoth/application/services/criterion_contract_service.py", @@ -6298,8 +6308,8 @@ }, { "path": "src/thoth/application/services/evidence_graph_service.py", - "sha256": "111b1263696d4b4c8d64de74bb00ea0e175be9b0e3a10c767a33581b1bdb9b4b", - "bytes": 21579 + "sha256": "60ddb664541c3353167a8fe90d6ec636f05ca4d17c1f5edd8a21155992de0254", + "bytes": 21577 }, { "path": "src/thoth/application/services/evidence_publication_basis.py", @@ -6573,8 +6583,8 @@ }, { "path": "src/thoth/application/services/investigation_service.py", - "sha256": "8132fe0043b71bf7085b57d12fadb99db14ecfb71b1568bf398e825038938d51", - "bytes": 12140 + "sha256": "81a0bdd96c730e6d5babcc1bb112e33f94d1d857dc1493bc4f2aaafcfcdf9806", + "bytes": 12102 }, { "path": "src/thoth/application/services/judgment_review_service.py", @@ -6698,8 +6708,8 @@ }, { "path": "src/thoth/application/services/model_settings.py", - "sha256": "8bb40157f052e84c8efe46c15229ac691c32d99c58580af9722c9b9087c40c25", - "bytes": 5664 + "sha256": "08a202a206d052343d1af8689302f3da78467d0bb486386475960d216dce7d1d", + "bytes": 5616 }, { "path": "src/thoth/application/services/model_settings_conversation.py", @@ -6753,8 +6763,8 @@ }, { "path": "src/thoth/application/services/policy_gate.py", - "sha256": "9eec9b88553b40806aaf36b0e490efe0f193deac0d705f2769a025e3696bca51", - "bytes": 4951 + "sha256": "ff295d76a6e6744b892fa16273abc7d221bb282ea182f7ef3e1926337fed1661", + "bytes": 4921 }, { "path": "src/thoth/application/services/post_execution_memory.py", @@ -6783,8 +6793,8 @@ }, { "path": "src/thoth/application/services/public_web_policy.py", - "sha256": "ff4acfca3e6c5589cba1af4b8335e8f33738bf7f25e5441932605ea0444fd881", - "bytes": 6477 + "sha256": "b2097cd23c066642f8551166038a6490ab6e0a470f013abeac2daa617d1f6331", + "bytes": 6417 }, { "path": "src/thoth/application/services/r2_closed_loop.py", @@ -6928,8 +6938,8 @@ }, { "path": "src/thoth/application/services/research_attempt_admission.py", - "sha256": "28345215be8133302724e8814de7e4ad88cdc57982faa06f5162f4cebcc463b3", - "bytes": 2649 + "sha256": "f63b659c0d2287e85e10102005ffcfbdfd3c2d55f2146fb47ce5cd0070565b75", + "bytes": 2627 }, { "path": "src/thoth/application/services/research_attempt_runner.py", @@ -7268,8 +7278,8 @@ }, { "path": "src/thoth/application/services/revision_diff.py", - "sha256": "ab8b6890ab59a0de48d52ee364d7388c3397ae2421fae8e28e62fe2305d182fe", - "bytes": 2017 + "sha256": "1bb379b9d5558c2cf8c9fd88303352ce245972c3b9881fff07b8b200bc3abb93", + "bytes": 1973 }, { "path": "src/thoth/application/services/revision_listing.py", @@ -7288,13 +7298,13 @@ }, { "path": "src/thoth/application/services/sandbox_router.py", - "sha256": "4004329cb53fac337d3623623a755f5bfc8fab19ef6be4915e7b97d4171c83cf", - "bytes": 1643 + "sha256": "e905602f6b250b94a5cc2a3026a21d0c304bd7fa618fb2b911ded8d526e2e232", + "bytes": 1629 }, { "path": "src/thoth/application/services/sandbox_service.py", - "sha256": "0b053c9acbcdbdd2344f6c29035930ad63d0919f861008aad56b40eca95ce25c", - "bytes": 26030 + "sha256": "a3cc9b0157020c5fcc7268e2e74b61306bdd07271daa58e17eb9dd8fdf16d29f", + "bytes": 26031 }, { "path": "src/thoth/application/services/scoped_artifacts.py", @@ -7343,8 +7353,8 @@ }, { "path": "src/thoth/application/services/source_time_service.py", - "sha256": "548cb9d55e6c4455cb43b2f3d0272ddc9b20e05da3c331adb7bb7428f3762d32", - "bytes": 8689 + "sha256": "0d52299a059fcfd1ebdfbb49412a9dc933a62e65d939b0483f88647341697a96", + "bytes": 8768 }, { "path": "src/thoth/application/services/test_validity_service.py", @@ -7383,8 +7393,8 @@ }, { "path": "src/thoth/application/services/tui_session_service.py", - "sha256": "44d77a832ff1d5cf75dc8b6a4d50b1bcc3d9fd0deab92496ec1aa87bb1d5c709", - "bytes": 11813 + "sha256": "5062087520fd6e1f42428bd8ec14e6dfabe57b2aaa15491d9194ca16e23b95a8", + "bytes": 11798 }, { "path": "src/thoth/application/services/user_activity_projection.py", @@ -7503,8 +7513,8 @@ }, { "path": "src/thoth/apps/decision_chain_routes.py", - "sha256": "0650adc5ec34cc3d84312d23c643bf366caea18b39489b581508c2e64b090485", - "bytes": 6644 + "sha256": "f7148c888cd0406146f4eae3502acc03c0e7f97efb37266b8ea89b171da4b011", + "bytes": 6628 }, { "path": "src/thoth/apps/decision_command_composition.py", @@ -7548,8 +7558,8 @@ }, { "path": "src/thoth/apps/hosted_review_snapshot.py", - "sha256": "23fe495dd927dfb26177469ab0737d9aec0c9b96ad5515b8b5dc04214c5160d4", - "bytes": 15701 + "sha256": "8d3eafdbefa67b9da97ee6a7acdd9a86a6207bd99cc09e69ef1d31f569a62677", + "bytes": 15994 }, { "path": "src/thoth/apps/hypothesis_link_composition.py", @@ -7698,8 +7708,8 @@ }, { "path": "src/thoth/apps/source_composition.py", - "sha256": "95ea71cc07c438875705876fa6a05e0ccd9422b94bc9e872acd78f1f5032ed95", - "bytes": 2630 + "sha256": "039db6fb4d0f761fa6497d7aa68d7bbbdb401a61923cc7ace06c1f0d5085af1b", + "bytes": 2638 }, { "path": "src/thoth/apps/source_evidence_routes.py", @@ -7813,8 +7823,8 @@ }, { "path": "src/thoth/domain/baseline.py", - "sha256": "abd3f33c97d8200882eec84c2c5274d9e3972b7820409e7c2aa65e17aff94231", - "bytes": 2341 + "sha256": "164054cbb3b4a21db4eb861f56f63b9321c3bfe7fda985acdd7af08402e6ef2a", + "bytes": 2343 }, { "path": "src/thoth/domain/behavior_artifact.py", @@ -7953,8 +7963,8 @@ }, { "path": "src/thoth/domain/evidence.py", - "sha256": "2aa311ddf035d71ebf308726702391d6106f7fc19ee2a62efb22b37796165daf", - "bytes": 2641 + "sha256": "fe829e6ad59cdd789cd16691cbfff7204165dd4ec79a9fbb3028254a531b00f1", + "bytes": 2633 }, { "path": "src/thoth/domain/evidence_basis.py", @@ -7983,8 +7993,8 @@ }, { "path": "src/thoth/domain/execution.py", - "sha256": "e90dd4672c96074a5622719f48fc0592575d9998db95b005a914351bde8cd88f", - "bytes": 1448 + "sha256": "0b2dfc5b0a042d0b6f90147c736e764e7d378be5d7aa8e4c59e778c74a2429fb", + "bytes": 1418 }, { "path": "src/thoth/domain/execution_full.py", @@ -8573,8 +8583,8 @@ }, { "path": "src/thoth/ports/artifact_ledger.py", - "sha256": "036fad0e145aa10d5da9ecde26aa371d1683aa103ef2623df7e44538f1be51ae", - "bytes": 2215 + "sha256": "f992604741ed48a870c358e6284ffd8c456eb56a57d884a238ca375e82282b57", + "bytes": 2224 }, { "path": "src/thoth/ports/auth.py", @@ -8663,8 +8673,8 @@ }, { "path": "src/thoth/ports/evaluator.py", - "sha256": "e4bfac4e69a36bf9ccccee31bf9cc778a85237de44efbaf50d7c494b20c444e2", - "bytes": 316 + "sha256": "1d1d8cf9e21b9eb8effe31a5456c5baff443c3f36e32cb224a87c6b95736032a", + "bytes": 315 }, { "path": "src/thoth/ports/event_store.py", @@ -8853,8 +8863,8 @@ }, { "path": "src/thoth/ports/sandbox_capability.py", - "sha256": "03163a74def651a30bfabe95156123336b8c46658608711d396629d7975b84a7", - "bytes": 387 + "sha256": "04e9f1d3c75a9f48c6c7168ed43e91cf210938b8f4e64609840756a5a498b9c7", + "bytes": 373 }, { "path": "src/thoth/ports/source_version_catalog.py", @@ -8883,8 +8893,8 @@ }, { "path": "src/thoth/ports/thread_runtime.py", - "sha256": "286b0da0204b99bfc3694e1fc493db35ee68db3ce4510c26eb801226af47ff6e", - "bytes": 1003 + "sha256": "4c4a344b8387cf1bb43a45509d2288fd2f20d777f898d0e8f24e18a7a9d5f6d4", + "bytes": 989 }, { "path": "src/thoth/ports/web_reader.py", @@ -8998,7 +9008,7 @@ }, { "path": "tests/adversarial/test_anti_hardcoding.py", - "sha256": "ef9beea92a9e1dd96fc391d30a0756427661d8c196b1c09ed81e0538f8cf283d", + "sha256": "03d3f75f309c8863bec07a99ee931b35a291598a3b83f9df9f2e50b14a1505f9", "bytes": 2900 }, { @@ -9018,8 +9028,8 @@ }, { "path": "tests/architecture/test_atomicity_debt_contract.py", - "sha256": "c914c278bbbe6ad22b0c6fb26615face646dfebe99b2df63ab9cca11985af61b", - "bytes": 1507 + "sha256": "bbf6a81b83938e850b069f671fef975a9500386dc3e91138acff0b1a6850605e", + "bytes": 1493 }, { "path": "tests/architecture/test_complexity_budget.py", @@ -9043,8 +9053,8 @@ }, { "path": "tests/architecture/test_ocp_extension_guards.py", - "sha256": "38cbcae9549c5d89a0190836a847210f296d75729ae686bc5360268669893e75", - "bytes": 3305 + "sha256": "5bcd7a58532076f162dd17bcfb6c82e8cf3a26470819eb2bd2828f9fd5db8c7d", + "bytes": 3307 }, { "path": "tests/architecture/test_portable_build_paths.py", @@ -9053,8 +9063,8 @@ }, { "path": "tests/architecture/test_post_d4_baseline.py", - "sha256": "9aa2e2275f24a462e9af6ee077600da1c34d358cd3e5c7c3bcffec477da4ddda", - "bytes": 5906 + "sha256": "8d1c67244cc313342d248dc89f41980230e61132fde1ac0e1f95660839b1e5b0", + "bytes": 5938 }, { "path": "tests/architecture/test_public_method_catalog_builder.py", @@ -9233,8 +9243,8 @@ }, { "path": "tests/atomicity/test_permission_predicates.py", - "sha256": "588ef2a6eb75a458a2761add8bc1e9a14fff486cbb143fd8bc9d07f01faa9a36", - "bytes": 6225 + "sha256": "83bb956c4dc1c262fea258cf154df2377e582811f1c0f623514cbf14dfc67f59", + "bytes": 6078 }, { "path": "tests/atomicity/test_plan_revision.py", @@ -9243,8 +9253,8 @@ }, { "path": "tests/atomicity/test_policy_digest_history_migration.py", - "sha256": "73fbe3cb30a972064e5378e71acd78edbd4b51e0b0f6cb12e22005ea1900bf59", - "bytes": 6275 + "sha256": "9755a9d680212041d4cbaa4a7b378a73d3024fe288abde3d087a11447007ccf3", + "bytes": 6389 }, { "path": "tests/atomicity/test_profile_bootstrap.py", @@ -9253,8 +9263,8 @@ }, { "path": "tests/atomicity/test_project_governance.py", - "sha256": "d9d2e8e7aac2e9f90e8ed19d5cf0426360b5dc6f163a5966731862d66d449903", - "bytes": 4903 + "sha256": "78cbab28fed1a2e4f154e84fe30d2160d68eed492597501050461257c68b2588", + "bytes": 4964 }, { "path": "tests/atomicity/test_project_lifecycle_faults.py", @@ -9311,6 +9321,11 @@ "sha256": "4863d6c2f6572bd3aa70dfbc16d50c6a17cc1a223a1343b5af8f20ae7d956473", "bytes": 23221 }, + { + "path": "tests/ci/test_ci_python_format.py", + "sha256": "d1eca3ab99f7cf9fc98baa65cca5d9931b274841ee9673dc9efb6f0f01186470", + "bytes": 1184 + }, { "path": "tests/ci/test_ci_test_shards.py", "sha256": "74b7ded8273b51ec89468ba0b74d3f6f52145f270299eae58a815543aed8430b", @@ -9818,8 +9833,8 @@ }, { "path": "tests/integration/public_web_test_support.py", - "sha256": "b3bfad024134917b463d3cadf951b0965b3634eb348fc91d0b7ab19e54be6845", - "bytes": 7878 + "sha256": "f4ac2b6013360c1769c339cfbcd8230252518ba1b64c446ac0332bbf66e038be", + "bytes": 7879 }, { "path": "tests/integration/readset_helpers.py", @@ -9838,8 +9853,8 @@ }, { "path": "tests/integration/research_measurement_helpers.py", - "sha256": "bc1444f58ab13d5ebcf76c073386d3e28190b8d6a0c22612d3d30bad3ebd4b86", - "bytes": 6280 + "sha256": "9ca2f36f84fb7b0be07abb6b237fe854c64f0e9f615183efa61023ad68c587e0", + "bytes": 6238 }, { "path": "tests/integration/resource_scope_helpers.py", @@ -9943,8 +9958,8 @@ }, { "path": "tests/integration/test_a04_stale_evidence.py", - "sha256": "b697da570501f765d2c0c103e36573a57f5366fb1ae7e7bd6b4405ebe54ca579", - "bytes": 23841 + "sha256": "3755d71c0e1284944f8c450eb114a5f99a8cf22e44de5688a1d2dd05bf74795c", + "bytes": 23777 }, { "path": "tests/integration/test_a04_storage_authority.py", @@ -10003,8 +10018,8 @@ }, { "path": "tests/integration/test_a08_recursive_improvement.py", - "sha256": "af115cef235784174f562c4bba5d82d471b88be1630ea50463ffc5f9832e9666", - "bytes": 9865 + "sha256": "6abb8488e55cc52c2c43393fc02f6e614c30ece13a400983886799e51dbc6bec", + "bytes": 9870 }, { "path": "tests/integration/test_a08_retrieval_policy_candidate.py", @@ -10058,8 +10073,8 @@ }, { "path": "tests/integration/test_a11_policy_integrity.py", - "sha256": "3ad080c53fb1a1e1b2bb745010cded9d862076d53b1675451fbbaa03212602c6", - "bytes": 27724 + "sha256": "a6dd9428a22d7302c34da1169c9d204aee3b3dffd42d3c6ebd0e73ceae570798", + "bytes": 27807 }, { "path": "tests/integration/test_a12_blind_score_and_adjudication.py", @@ -10258,8 +10273,8 @@ }, { "path": "tests/integration/test_conversation_ui_read.py", - "sha256": "aff6db4ec5db35a83ec05a86bd9d64b7bbb9147f9325dc079ceafad096e88dce", - "bytes": 9121 + "sha256": "287aff098c7e01a60f285759fbc88ee05490a14a7c4d103ef1ce9b664eddd496", + "bytes": 8965 }, { "path": "tests/integration/test_counter_cleanup_cancel_preserves_prior.py", @@ -10468,8 +10483,8 @@ }, { "path": "tests/integration/test_historical_access_verification.py", - "sha256": "2094dc306db94bb4aac566a0fa54239ddd2297128c7171a78592768ee083d07e", - "bytes": 12250 + "sha256": "c27f9fada1195b537172e127d82b9641867e31f282fb2d509ea180303ed30b2c", + "bytes": 12288 }, { "path": "tests/integration/test_history_change_summary_read.py", @@ -10478,18 +10493,18 @@ }, { "path": "tests/integration/test_hosted_review_dispatch_gate.py", - "sha256": "0a61dd5d8069e9820ba1531b0e6061ee25563fa613f42c9527433cd9026f1510", - "bytes": 10028 + "sha256": "a0dae62db1ae5efc371caf5efb749ab57f64a7e11acfa1836f77618b69aa0b67", + "bytes": 10068 }, { "path": "tests/integration/test_hosted_review_internet_vs_model.py", - "sha256": "ff9b470b354c59fb1a760d55f814e484ed481417d1d1b0b8a474bd99bc747281", - "bytes": 3471 + "sha256": "a749a09078cde2834c53c3ed170252d9ab0619c94ec87399d2803f474949f0d3", + "bytes": 3425 }, { "path": "tests/integration/test_hosted_review_session_isolation.py", - "sha256": "af22ece41b0e66e98f20db9b3755bb3dcd5a41e53d0d8948917c8b5f6813f6fc", - "bytes": 12066 + "sha256": "64b6a3e7d7264f9c78f32fa2521c57d15ea6e36f976b540fbd3a6131158cc40b", + "bytes": 12064 }, { "path": "tests/integration/test_hosted_review_stale_attempt.py", @@ -10513,8 +10528,8 @@ }, { "path": "tests/integration/test_hypothesis_full_rpc.py", - "sha256": "6105c0bc0990ec392a6f3101759e9a2ed29d9705b284dd881925a3555812d833", - "bytes": 24901 + "sha256": "b1e784c8419e59b534c448ff1b2c577e4aeba3f6769b43b65215e9b27b79f81c", + "bytes": 24879 }, { "path": "tests/integration/test_hypothesis_grounding_storage.py", @@ -10528,8 +10543,8 @@ }, { "path": "tests/integration/test_hypothesis_prediction_outcome_cycle.py", - "sha256": "ea9bb533ca4e2db498dfbabee3c01d856f4a2ee4a259434e6e82b77a3d40595a", - "bytes": 24480 + "sha256": "02963d8b764b5294755723aee1caf4f1ccad3e3b86bfb03e5bfb601098ce7e59", + "bytes": 24450 }, { "path": "tests/integration/test_hypothesis_same.py", @@ -10868,8 +10883,8 @@ }, { "path": "tests/integration/test_model_settings.py", - "sha256": "6c507257d81c1e387eb63795d2fd00cfe722d221eae3e920eb6fd9473c9c20da", - "bytes": 9089 + "sha256": "e985eef5ecbfa35faf91d79c94877a110afc590e31808f028ee7da7149fdff11", + "bytes": 9090 }, { "path": "tests/integration/test_model_settings_cache_only.py", @@ -11093,23 +11108,23 @@ }, { "path": "tests/integration/test_public_oauth_memory_excerpt_regression.py", - "sha256": "00a9a7adcfeeb996a4ad4ac444e68a51ce488eb1b0b35482997b9691a9c4fc01", - "bytes": 2222 + "sha256": "2552003afc5bdb5cdc620b55da4c068c8894cb47dc8251b8e4b1e2dfb20df1d6", + "bytes": 2200 }, { "path": "tests/integration/test_public_web_evidence_gates.py", - "sha256": "d9fb95ec6c41bcc247ae2867317702a68b6f103e5e96d7a8bfa5530734cbebbd", - "bytes": 6720 + "sha256": "dd173d2bb4061362484ab2ab8ad99ac21ac007a006f3697ae79e8b084cf1b481", + "bytes": 6708 }, { "path": "tests/integration/test_public_web_policy_update_rpc.py", - "sha256": "dd7e2f312cc56c8f5f6505ee5cde635ba989c4e12d91094edc7dded79ab9890d", - "bytes": 10808 + "sha256": "33c055a4757a8f3820b2b1f36dc47b6637e41c2fde12cce1869dcebb34652a3e", + "bytes": 10762 }, { "path": "tests/integration/test_public_web_runtime_catalog.py", - "sha256": "034208201b1f602d1622d6f9cd72d8d9b43b8aac5eb30bd3fbb6786a9f9af3d9", - "bytes": 3583 + "sha256": "efe04826d1b7a305dc24b8ed2b14edefeae4779d8168dea9507c6532392c8a97", + "bytes": 3569 }, { "path": "tests/integration/test_r2_closed_loop_golden.py", @@ -11258,8 +11273,8 @@ }, { "path": "tests/integration/test_research_history_read.py", - "sha256": "d46c1d49a066fabe485eea4be59c161d409b7b72bbff19394c6b8ed0d6cdd8a5", - "bytes": 9102 + "sha256": "11ce10f0592ff48f8d1cb8b624c1cec78d030bda46bae6017ba068ca7e5c9d59", + "bytes": 9156 }, { "path": "tests/integration/test_research_identity_migration.py", @@ -11278,13 +11293,13 @@ }, { "path": "tests/integration/test_research_lineage_publication.py", - "sha256": "af4a88cdcc795c4c842a84af865eff5060726fbd8c1ea294132faa4127b41270", - "bytes": 8830 + "sha256": "6c8b0d3a8d7ffbae7ec208ee605a197498da0467d52d0ea676c5a6ad443073fc", + "bytes": 8694 }, { "path": "tests/integration/test_research_memory_basis.py", - "sha256": "ef4a7004d534105928059f587d261c707b06a69197a458bbacd9cd97c89dfe53", - "bytes": 8903 + "sha256": "cfd8b84f77d745c99cbd8b15d40a068ebb28be1c778b50820adffd3199cae3a4", + "bytes": 8951 }, { "path": "tests/integration/test_research_memory_correction_currentness.py", @@ -11433,8 +11448,8 @@ }, { "path": "tests/integration/test_resource_scope_revocation.py", - "sha256": "d498451afcf77c1953d7a7df805190c9e3a5ac27aa030dbfae3b137fdac31306", - "bytes": 5217 + "sha256": "89099fa5f222c356b661c5e89944934ffa0a2e58b351b91b9eef220a6fcce391", + "bytes": 5266 }, { "path": "tests/integration/test_resource_scope_roundtrip.py", @@ -11458,8 +11473,8 @@ }, { "path": "tests/integration/test_restore_apply_atomicity.py", - "sha256": "57f36979d3311451de83acca44920fa258752ee611b2c677657f05febcaf872b", - "bytes": 11301 + "sha256": "a10971b145ed58634ea9f36d19d1fcb22c89d9d32cc6a8a20ce6dac1f5d855eb", + "bytes": 11363 }, { "path": "tests/integration/test_restore_concurrency.py", @@ -11468,13 +11483,13 @@ }, { "path": "tests/integration/test_restore_currentness_consumers.py", - "sha256": "aa35e7393e4415cd666e2b9d52b3c92210864cfa9b7ca87a5eb0b1ad77d30d60", - "bytes": 7290 + "sha256": "537325ce34c331ed97b36605bef42e94b50d719482a7541037ab982500b6b9c6", + "bytes": 7358 }, { "path": "tests/integration/test_restore_drift.py", - "sha256": "4f08e84c0ce23f22453c461db4483a2973df7a3b4fb8e43aa3f1cefd4faa490d", - "bytes": 3063 + "sha256": "cd4f32bd704a095cb657c38a553e477b84a44e520db28d229fb9cf0f4a4523b4", + "bytes": 3033 }, { "path": "tests/integration/test_restore_legacy_compatibility.py", @@ -11653,8 +11668,8 @@ }, { "path": "tests/integration/test_selected_evidence_query.py", - "sha256": "0df7bbba34239747baefdfa6a72bdcbfc676b4960487869e228cd7170d07f2f2", - "bytes": 6396 + "sha256": "88c5df167b95283c8f64bdd5d78279e72e71a53506a20ed7c4c8a7ceb4e650c7", + "bytes": 6270 }, { "path": "tests/integration/test_source_cleanup_cancel_boundary.py", @@ -11673,8 +11688,8 @@ }, { "path": "tests/integration/test_source_time_connection.py", - "sha256": "6b892032e0c1a94bc4a54839eef93b424541e2b2cc8e37b1340fccab20546616", - "bytes": 9930 + "sha256": "f98261dfc86e048313fdaa8d6faeedfe22afa2a8aec304347e1edda39d55d4de", + "bytes": 9828 }, { "path": "tests/integration/test_stage_checkpoint_contract.py", @@ -11718,8 +11733,8 @@ }, { "path": "tests/integration/test_structured_context_pipeline.py", - "sha256": "1ce42ed2292a5ca5e2a141fd6d90487c0f6a5824b93a133d01f3141dbf96b907", - "bytes": 10944 + "sha256": "b70ef514ed147f37abdf750e12ed6f9833c9538b6e9689b11b615339aa11b9fb", + "bytes": 10932 }, { "path": "tests/integration/test_text_context_pipeline.py", @@ -11813,8 +11828,8 @@ }, { "path": "tests/integration/test_user_activity_thread_read.py", - "sha256": "fce16d64670557b795c4b6876487f9c996f8e89563e62c36fd520b9eb0645f6c", - "bytes": 2376 + "sha256": "b7e23db482eb9130f89a67c28ca0a24431b3ac668000d74addad239a198ac368", + "bytes": 2332 }, { "path": "tests/integration/test_v2_cycle_lineage_values.py", @@ -11828,8 +11843,8 @@ }, { "path": "tests/integration/test_workspace_restart_storage.py", - "sha256": "e6a5ef1002509cdaebae4236fa0df2a48470e6cc1996e0123c8331e583ca5c94", - "bytes": 15163 + "sha256": "84ea574ac7cba07b278aaca8b941684d6e7435c35b71e0e925f3a0ad03286b7b", + "bytes": 15239 }, { "path": "tests/integration/test_workspace_runtime_exclusivity_baseline.py", @@ -11858,8 +11873,8 @@ }, { "path": "tests/parser/test_document_time.py", - "sha256": "dd82efc69ef676a6eb45da0f6e28f38aca0111de3092422816f7a8d0fca2cd87", - "bytes": 2199 + "sha256": "6c1e01eda668e241c6dc4301f0e064f881efe4d2cfe0f90af86ba6ffd17faf3b", + "bytes": 2218 }, { "path": "tests/parser/test_document_time_protocol_fallback.py", @@ -11903,8 +11918,8 @@ }, { "path": "tests/parser/test_test_log.py", - "sha256": "f68454c540489ff2ed07cbbfc306cf3fe27721ccb7324aea5ef26bec289b3246", - "bytes": 1085 + "sha256": "2b3e5b08b72bc5b2c84852b2190d20b3fd262b190d8e49bb3f552ec885cffba7", + "bytes": 1071 }, { "path": "tests/parser/test_text.py", @@ -11933,7 +11948,7 @@ }, { "path": "tests/test_foundation.py", - "sha256": "868a8f79901f0aee47bedcfae8cf2ce234d33bc32811d577833567981628f5db", + "sha256": "1bcf79b8e422c199061c35628ac805e6e5b77dc488fb88e92dbd93a5986abf47", "bytes": 1422 }, { @@ -12113,8 +12128,8 @@ }, { "path": "tests/unit/application/test_research_final_producer_selection.py", - "sha256": "88801b7a5de2c9646fc2b4dcbd8c42d404461dced2ce96d3c6d973047dbf059e", - "bytes": 6364 + "sha256": "1a12a904a3c5f3d77e45997f70888334386bb8483d1af7e5c35309225d876cc8", + "bytes": 6350 }, { "path": "tests/unit/application/test_research_freshness_memory.py", @@ -12153,8 +12168,8 @@ }, { "path": "tests/unit/application/test_user_activity_projection.py", - "sha256": "474030af56553332919130452b9b30a6bea106edffeb40cde0f8f74c0e66441c", - "bytes": 9427 + "sha256": "fbebe14a3bda8eb76392357b4d5e4611b2751b54663ce67803c4d9883e983bcd", + "bytes": 9392 }, { "path": "tests/unit/application/test_workspace_ready_offload.py", @@ -12178,7 +12193,7 @@ }, { "path": "tests/unit/apps/test_source_policy_defaults.py", - "sha256": "07fd08b892f5df466311f297700102bcba79fac4cd0dcaf433342d13ac7fadef", + "sha256": "48cf7aca893225aa026dc1f28a507df91b4149a1364a03a7c9f24c8aaa9d2778", "bytes": 3103 }, { @@ -12188,8 +12203,8 @@ }, { "path": "tests/unit/connectors/test_connector_capability_routing.py", - "sha256": "f153ebc1f66463ab3f19e305ad90c6ad5a27a8b4179b486c7630ce2f5db9639e", - "bytes": 3434 + "sha256": "31d827236ea0843921eff55b756cabb5161911d5baed1873488471039384dfe1", + "bytes": 3420 }, { "path": "tests/unit/connectors/test_connector_config.py", @@ -12358,8 +12373,8 @@ }, { "path": "tests/unit/domain/test_models.py", - "sha256": "fc8f7cc8d4faedf4d0b8131973cace06ea0a649632e55f6f5a488e6b8eedc132", - "bytes": 7314 + "sha256": "db782a243f02d2dd9a483c8045ef433d2b2637fe8e15c3111dead9ffe635f5e9", + "bytes": 7403 }, { "path": "tests/unit/domain/test_notification_order.py", @@ -12568,8 +12583,8 @@ }, { "path": "tests/unit/models/test_codex_app_server_auth_only.py", - "sha256": "f56e316353f2f83d92029360486e9f1a5bf38629a052aa2b1582ed285cc6f3dd", - "bytes": 7146 + "sha256": "62be9bb4963a8cded58bd00500c8125200ca6a67628de53d279f1ecab87a3330", + "bytes": 7143 }, { "path": "tests/unit/models/test_codex_catalog_snapshot.py", @@ -12588,8 +12603,8 @@ }, { "path": "tests/unit/models/test_codex_oauth_model.py", - "sha256": "9fda00209ff517658974211f2fb229ca6396c655477ede823853f732215176f8", - "bytes": 8018 + "sha256": "a83ad0d66b19aa3419f42a8ba45d489c131b92f932e5a2ca69610777f85bfe10", + "bytes": 8006 }, { "path": "tests/unit/models/test_credential_auth_projection_contract.py", @@ -12603,13 +12618,13 @@ }, { "path": "tests/unit/models/test_hosted_review_provider_budget.py", - "sha256": "e958f041dc8bc893041722fbbf74e34f9bec13fabb7b981f230a6cebc84d052c", - "bytes": 8720 + "sha256": "fda6a4338ce23db0ffa9245030d8b200433e1ee49add2e6fcc59ec1cd7000f2e", + "bytes": 8698 }, { "path": "tests/unit/models/test_http_rejection_metadata.py", - "sha256": "78ed4bab51ec765276d773b403801a4df3898928c777a85d995df151aa17fb25", - "bytes": 2236 + "sha256": "8f6ef18fbef36bc7b2fd3460867612d092a2b84892cd2edc3758c167113dad80", + "bytes": 2208 }, { "path": "tests/unit/models/test_hypothesis_contract_wire_text.py", @@ -12978,13 +12993,13 @@ }, { "path": "tests/unit/services/test_criterion_compiler.py", - "sha256": "ab7bdbc68f3c75b6c9262657059539ba4ec8b1c820d90fb5ccb2575705c2dd4f", - "bytes": 4178 + "sha256": "fb989a8edf3057fc2dd25a556dc268136ea304c55dda9b1297add4c73eeba621", + "bytes": 4164 }, { "path": "tests/unit/services/test_evidence_context.py", - "sha256": "9daafa0190290e8c4017d4c092a05736a85062ab386c493e0b62382116ede6ee", - "bytes": 6704 + "sha256": "96f9214fced51eb640e519f55e12c7038f872f551b710071e6f44f47171541f9", + "bytes": 6674 }, { "path": "tests/unit/services/test_execution_reconciler.py", @@ -13048,8 +13063,8 @@ }, { "path": "tests/unit/services/test_model_route.py", - "sha256": "ee0f9a219d7daa46fd095b69b66a1bb48d3046be38130a238d1a30b0c1d9d743", - "bytes": 2599 + "sha256": "f1092fa8bf3e6d694ba4bf777719ec98af200a82107e6e87704a5d5d40b87a9a", + "bytes": 2555 }, { "path": "tests/unit/services/test_object_record_drafts.py", @@ -13088,8 +13103,8 @@ }, { "path": "tests/unit/services/test_retry_model_mismatch.py", - "sha256": "0fcdbba868e3884608f6c618a869241a2b988891b089fe1d90526a161f1417e7", - "bytes": 6353 + "sha256": "705d2461369a59a8b74a37123cb032bb3ab9255edbdc3a85647f166c674fa4b3", + "bytes": 6339 }, { "path": "tests/unit/services/test_thread_cycle_lineage_contract.py", @@ -13373,8 +13388,8 @@ }, { "path": "tests/unit/test_hosted_review_snapshot.py", - "sha256": "ebade1c6326c80e6621a5533c03d2f9cfcb997fbb1feb8bfcb36dc5d98fa936e", - "bytes": 12975 + "sha256": "ccce7df7330fb8ea767a0972bbadabb7462c46ece75ba9157a2019ec57b4a9f2", + "bytes": 12963 }, { "path": "tests/unit/test_hypothesis_eval_cases.py", @@ -13553,18 +13568,18 @@ }, { "path": "tests/unit/test_public_web_policy_reconciliation.py", - "sha256": "253c3edb373575a0df148fbea5442ce27f75111fb4f447a18a8e4964a53dcc25", - "bytes": 6665 + "sha256": "d939260f83348bd801c0bfd752be3a519bf1af4bf806c439365eec3474d819bc", + "bytes": 6643 }, { "path": "tests/unit/test_public_web_query_disclosure.py", - "sha256": "02bd5aea95d4fc723d456c1901a8751349932dc6b792b007d2a8275f08e346e1", - "bytes": 5515 + "sha256": "68c8ab433933810f5acfee44a59805e85ea70824509b3169db0318196b60f9c3", + "bytes": 5473 }, { "path": "tests/unit/test_public_web_uri_boundary.py", - "sha256": "7cea87d4c0dd802e46797355efd78a4a70c9955a8f1345d5f5038284cd8c3010", - "bytes": 4742 + "sha256": "a17f06e769be4f80499fc00a45f5bbadea21d7d1f7f7bf9236c927fecc4ec650", + "bytes": 4736 }, { "path": "tests/unit/test_r2_context_preflight_contract.py", @@ -13743,8 +13758,8 @@ }, { "path": "tests/unit/test_source_time_resolution.py", - "sha256": "8ad4c3a6c58fcdf68adc13e4f009f40b66dfddbd2a457e05b1c9a17107e47de2", - "bytes": 3686 + "sha256": "f5a1210d7c20de574b4baa8e8b6b6c4fd84e7d2a6da94369835b35d7798076f3", + "bytes": 3599 }, { "path": "tests/unit/test_test_validity.py", @@ -13808,7 +13823,7 @@ }, { "path": "uv.lock", - "sha256": "62ad4f0cb97c5af0566a2532bff9ec92ae218714205e1456d5b3d9bca801c021", + "sha256": "699adc13604c6242c2f585948fe1e0c0b8656efbed93027f38d2bf05b27c1d70", "bytes": 429530 } ], diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore new file mode 100644 index 00000000..c32a6cd8 --- /dev/null +++ b/apps/web/.prettierignore @@ -0,0 +1,10 @@ +# Build output and caches. +dist +*.tsbuildinfo + +# Recorded server responses and other fixtures keep the bytes they were recorded with. +src/api/fixtures +*.fixture.json + +# Static files that are served as they are. +public diff --git a/apps/web/.prettierrc.json b/apps/web/.prettierrc.json new file mode 100644 index 00000000..de753c53 --- /dev/null +++ b/apps/web/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 5dd1efc9..c12a257c 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -1,5 +1,6 @@ import js from "@eslint/js"; import globals from "globals"; +import jsxA11y from "eslint-plugin-jsx-a11y"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; @@ -7,7 +8,11 @@ import tseslint from "typescript-eslint"; export default tseslint.config( { ignores: ["dist"] }, { - extends: [js.configs.recommended, ...tseslint.configs.recommended], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + jsxA11y.flatConfigs.recommended, + ], files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2022, @@ -28,4 +33,3 @@ export default tseslint.config( rules: { "no-console": "off" }, }, ); - diff --git a/apps/web/package.json b/apps/web/package.json index 3ecccda9..9c109047 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,39 +1,43 @@ -{ - "name": "@thoth/web", - "version": "0.2.0-beta.3", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --host 127.0.0.1", - "build": "tsc -b && vite build", - "lint": "eslint .", - "typecheck": "tsc -b --pretty false", - "test": "vitest", - "preview": "vite preview --host 127.0.0.1" - }, - "dependencies": { - "@blueprintjs/core": "6.18.0", - "@blueprintjs/icons": "6.13.0", - "@tanstack/react-query": "^5.66.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "zod": "^3.24.0" - }, - "devDependencies": { - "@eslint/js": "^9.19.0", - "@types/node": "22.20.4", - "@types/react": "^19.0.8", - "@types/react-dom": "^19.0.3", - "@vitejs/plugin-react": "^4.3.4", - "eslint": "^9.19.0", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.18", - "globals": "^15.14.0", - "jsdom": "26.1.0", - "typescript": "~5.7.3", - "typescript-eslint": "^8.22.0", - "vite": "^6.1.0", - "vitest": "^3.0.5" - }, - "license": "MIT" -} +{ + "name": "@thoth/web", + "version": "0.2.0-beta.4", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "lint": "eslint . && prettier --check .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc -b --pretty false", + "test": "vitest", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@blueprintjs/core": "6.18.0", + "@blueprintjs/icons": "6.13.0", + "@tanstack/react-query": "^5.66.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@types/node": "22.20.4", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.19.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^15.14.0", + "jsdom": "26.1.0", + "prettier": "3.9.10", + "typescript": "~5.7.3", + "typescript-eslint": "^8.22.0", + "vite": "^6.1.0", + "vitest": "^3.0.5" + }, + "license": "MIT" +} diff --git a/apps/web/preview-timeline.html b/apps/web/preview-timeline.html deleted file mode 100644 index 7d5908d1..00000000 --- a/apps/web/preview-timeline.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - THOTH timeline preview - - -
- - - diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 900c9f95..58eec28e 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -19,8 +19,10 @@ describe("THOTH UI contracts", () => { expect(html).toContain("전체 42개 중 선택"); }); - it("renders a null scientific classification as unrecorded, never a fabricated status",()=>{ - const html=renderToStaticMarkup(); - expect(html).toContain("미기록");expect(html).not.toContain("PASS");expect(html).not.toContain("UNKNOWN"); + it("renders a null scientific classification as unrecorded, never a fabricated status", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("미기록"); + expect(html).not.toContain("PASS"); + expect(html).not.toContain("UNKNOWN"); }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 51a4efc8..ee0a426e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,18 +3,34 @@ import { lazy, Suspense, useEffect } from "react"; import { LiveProjectWorkspace } from "./components/LiveProjectWorkspace"; -const HistoryExample = import.meta.env.DEV ? lazy(() => import("./components/history/HistoryExample")) : null; +const HistoryExample = import.meta.env.DEV + ? lazy(() => import("./components/history/HistoryExample")) + : null; const ResultExample = import.meta.env.DEV ? lazy(() => import("./components/ResultExample")) : null; export function App() { const locale = useLocale(); useEffect(() => applyDocumentLocale(locale), [locale]); - const example = typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("example"); + const example = + typeof window === "undefined" + ? null + : new URLSearchParams(window.location.search).get("example"); if (HistoryExample && example === "history") { - return {t("examples.historyLoading")}

}>
; + return ( + {t("examples.historyLoading")}

}> + +
+ ); } - if (ResultExample && (example === "result-before" || example === "result-after" || example === "result-iris")) { - return {t("examples.resultLoading")}

}>
; + if ( + ResultExample && + (example === "result-before" || example === "result-after" || example === "result-iris") + ) { + return ( + {t("examples.resultLoading")}

}> + +
+ ); } return ; } diff --git a/apps/web/src/api/actionDraft.ts b/apps/web/src/api/actionDraft.ts index 910b3e1c..3167daf7 100644 --- a/apps/web/src/api/actionDraft.ts +++ b/apps/web/src/api/actionDraft.ts @@ -1,6 +1,22 @@ import { rpc } from "./rpcClient"; /** A draft action request made from one discriminating test of a hypothesis. What the person declares is sent as written; the server decides the risk from it and nothing about risk is sent. */ -export async function draftActionFromTest(projectId: string, hypothesisId: string, testId: string, declaration: Record) { - return (await rpc<{ action: Record }>("action/draft/fromTest", { project_id: projectId, hypothesis_id: hypothesisId, test_id: testId, effect_declaration: declaration }, crypto.randomUUID())).value; +export async function draftActionFromTest( + projectId: string, + hypothesisId: string, + testId: string, + declaration: Record, +) { + return ( + await rpc<{ action: Record }>( + "action/draft/fromTest", + { + project_id: projectId, + hypothesis_id: hypothesisId, + test_id: testId, + effect_declaration: declaration, + }, + crypto.randomUUID(), + ) + ).value; } diff --git a/apps/web/src/api/capabilities.ts b/apps/web/src/api/capabilities.ts index 218c8c0e..14b59c4f 100644 --- a/apps/web/src/api/capabilities.ts +++ b/apps/web/src/api/capabilities.ts @@ -6,22 +6,87 @@ import { readQueries } from "./rpcClient"; export type Capability = (typeof catalog.methods)[number]; export const capabilityCatalog = catalog; export const capabilities = catalog.methods; -export const namespaces = [...new Set(capabilities.map(method => method.namespace))].sort(); +export const namespaces = [...new Set(capabilities.map((method) => method.namespace))].sort(); export const namespaceLabels: Record = { - get project() { return t("advanced.projectGroup"); }, get thread() { return t("advanced.threadGroup"); }, get investigation() { return t("advanced.discoveryGroup"); }, get evidence() { return t("advanced.evidenceGroup"); }, - get object() { return t("advanced.object"); }, get criteria() { return t("record.criterion"); }, get hypothesis() { return t("advanced.hypothesisGroup"); }, get action() { return t("advanced.actionGroup"); }, - get execution() { return t("advanced.execution"); }, get revision() { return t("advanced.historyGroup"); }, get outcome() { return t("advanced.outcomes"); }, get memory() { return t("result.memory"); }, - get improvement() { return t("advanced.improvementGroup"); }, get receipt() { return t("advanced.receiptGroup"); }, get closure() { return t("advanced.closureGroup"); }, get export() { return t("advanced.export"); }, - get operation() { return t("advanced.async"); }, get model() { return t("model.settings"); }, get workspace() { return t("advanced.workspace"); }, - get projectpack() { return t("advanced.examples"); }, get field() { return t("advanced.field"); }, get trace() { return t("workspace.trace"); }, + get project() { + return t("advanced.projectGroup"); + }, + get thread() { + return t("advanced.threadGroup"); + }, + get investigation() { + return t("advanced.discoveryGroup"); + }, + get evidence() { + return t("advanced.evidenceGroup"); + }, + get object() { + return t("advanced.object"); + }, + get criteria() { + return t("record.criterion"); + }, + get hypothesis() { + return t("advanced.hypothesisGroup"); + }, + get action() { + return t("advanced.actionGroup"); + }, + get execution() { + return t("advanced.execution"); + }, + get revision() { + return t("advanced.historyGroup"); + }, + get outcome() { + return t("advanced.outcomes"); + }, + get memory() { + return t("result.memory"); + }, + get improvement() { + return t("advanced.improvementGroup"); + }, + get receipt() { + return t("advanced.receiptGroup"); + }, + get closure() { + return t("advanced.closureGroup"); + }, + get export() { + return t("advanced.export"); + }, + get operation() { + return t("advanced.async"); + }, + get model() { + return t("model.settings"); + }, + get workspace() { + return t("advanced.workspace"); + }, + get projectpack() { + return t("advanced.examples"); + }, + get field() { + return t("advanced.field"); + }, + get trace() { + return t("workspace.trace"); + }, }; export function filterCapabilities(search: string, namespace = "all", surface = "all") { const tokens = search.trim().toLowerCase().split(/\s+/).filter(Boolean); - return capabilities.filter(method => - (namespace === "all" || method.namespace === namespace) && - (surface === "all" || method.surface === surface) && - tokens.every(token => `${method.name} ${method.canonical_owner} ${method.policy} ${namespaceLabels[method.namespace] ?? ""}`.toLowerCase().includes(token)), + return capabilities.filter( + (method) => + (namespace === "all" || method.namespace === namespace) && + (surface === "all" || method.surface === surface) && + tokens.every((token) => + `${method.name} ${method.canonical_owner} ${method.policy} ${namespaceLabels[method.namespace] ?? ""}` + .toLowerCase() + .includes(token), + ), ); } @@ -31,7 +96,8 @@ export function transportFor(method: string) { export function parseInvocation(text: string, projectId: string): Record { const value: unknown = JSON.parse(text); - if (!value || typeof value !== "object" || Array.isArray(value)) throw new UiError("api.jsonObject"); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new UiError("api.jsonObject"); const input = value as Record; if (!projectId || input.project_id !== projectId) throw new UiError("api.projectMismatch"); return input; diff --git a/apps/web/src/api/contracts.test.ts b/apps/web/src/api/contracts.test.ts index 84d99689..6da851e3 100644 --- a/apps/web/src/api/contracts.test.ts +++ b/apps/web/src/api/contracts.test.ts @@ -3,10 +3,23 @@ import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi, afterEach } from "vitest"; -import { capabilities, capabilityCatalog, filterCapabilities, namespaces, parseInvocation, transportFor } from "./capabilities"; +import { + capabilities, + capabilityCatalog, + filterCapabilities, + namespaces, + parseInvocation, + transportFor, +} from "./capabilities"; import { readQueries, rpc, RpcError } from "./rpcClient"; import { recordViews } from "./recordViews"; -import { authorityOptions, authorityStates, cutoffOptions, cutoffStates, sourceConnectSchema } from "./sourcePolicy"; +import { + authorityOptions, + authorityStates, + cutoffOptions, + cutoffStates, + sourceConnectSchema, +} from "./sourcePolicy"; import { isThreadAnalysis } from "./research"; import { captureProjectCreation } from "./projectCreation"; @@ -15,85 +28,162 @@ import { captureProjectCreation } from "./projectCreation"; // The time limit only ends a hang. The child does nothing but import the server's command and bus // modules and print their schemas; on a busy GitHub runner that import outlasted the earlier 20 s // (spawnSync ETIMEDOUT) although nothing was wrong. -const pythonHangLimitMs=120_000; -const repository=fileURLToPath(new URL("../../../../",import.meta.url)); -const python=[join(repository,".venv","Scripts","python.exe"),join(repository,".venv","bin","python")].find(existsSync); -if(!python)throw new Error("Web/backend contract integration requires the repository .venv Python environment."); -const backendContract=JSON.parse(execFileSync(python,["-c",[ - "import json", - "from thoth.application.commands.sources import SourceConnectInput", - "from thoth.application.commands.projects import ProjectCreateInput", - "from thoth.protocol.bus import READ_QUERY_METHODS", - "print(json.dumps({'source':SourceConnectInput.model_json_schema(),'project':ProjectCreateInput.model_json_schema(),'queries':sorted(READ_QUERY_METHODS)}))", -].join("\n")],{shell:false,encoding:"utf8",timeout:pythonHangLimitMs,cwd:repository, - env:{...process.env,PYTHONPATH:join(repository,"src"),PYTHONDONTWRITEBYTECODE:"1"}, -})) as {source:{$defs:Record};project:{properties:{policy_binding_ref:{default:string}}};queries:string[]}; +const pythonHangLimitMs = 120_000; +const repository = fileURLToPath(new URL("../../../../", import.meta.url)); +const python = [ + join(repository, ".venv", "Scripts", "python.exe"), + join(repository, ".venv", "bin", "python"), +].find(existsSync); +if (!python) + throw new Error( + "Web/backend contract integration requires the repository .venv Python environment.", + ); +const backendContract = JSON.parse( + execFileSync( + python, + [ + "-c", + [ + "import json", + "from thoth.application.commands.sources import SourceConnectInput", + "from thoth.application.commands.projects import ProjectCreateInput", + "from thoth.protocol.bus import READ_QUERY_METHODS", + "print(json.dumps({'source':SourceConnectInput.model_json_schema(),'project':ProjectCreateInput.model_json_schema(),'queries':sorted(READ_QUERY_METHODS)}))", + ].join("\n"), + ], + { + shell: false, + encoding: "utf8", + timeout: pythonHangLimitMs, + cwd: repository, + env: { ...process.env, PYTHONPATH: join(repository, "src"), PYTHONDONTWRITEBYTECODE: "1" }, + }, + ), +) as { + source: { $defs: Record }; + project: { properties: { policy_binding_ref: { default: string } } }; + queries: string[]; +}; -describe("canonical surface parity",()=>{ - it("covers every catalog method and namespace without inventing a completion metric",()=>{ - expect(new Set(capabilities.map(item=>item.name)).size).toBe(capabilityCatalog.runtime_method_count); - expect(capabilities.filter(item=>item.canonical)).toHaveLength(capabilityCatalog.canonical_method_count); - expect(capabilities.filter(item=>!item.canonical)).toHaveLength(capabilityCatalog.compatibility_alias_count); +describe("canonical surface parity", () => { + it("covers every catalog method and namespace without inventing a completion metric", () => { + expect(new Set(capabilities.map((item) => item.name)).size).toBe( + capabilityCatalog.runtime_method_count, + ); + expect(capabilities.filter((item) => item.canonical)).toHaveLength( + capabilityCatalog.canonical_method_count, + ); + expect(capabilities.filter((item) => !item.canonical)).toHaveLength( + capabilityCatalog.compatibility_alias_count, + ); expect(filterCapabilities("")).toHaveLength(capabilities.length); - expect(recordViews.map(item=>item.namespace).sort()).toEqual(namespaces); - for(const view of recordViews){ - if(view.method) expect(capabilities.some(item=>item.name===view.method)).toBe(true); - else expect(capabilities.filter(item=>item.namespace===view.namespace).every(item=>item.surface==="COMMAND")).toBe(true); + expect(recordViews.map((item) => item.namespace).sort()).toEqual(namespaces); + for (const view of recordViews) { + if (view.method) expect(capabilities.some((item) => item.name === view.method)).toBe(true); + else + expect( + capabilities + .filter((item) => item.namespace === view.namespace) + .every((item) => item.surface === "COMMAND"), + ).toBe(true); } }); - it("uses the exact server query whitelist, not a method-name heuristic",()=>{ + it("uses the exact server query whitelist, not a method-name heuristic", () => { expect([...readQueries].sort()).toEqual(backendContract.queries); expect(transportFor("thread/read")).toBe("/rpc/query"); expect(transportFor("receipt/read")).toBe("/rpc"); expect(transportFor("receipt/verify")).toBe("/rpc"); }); - it("searches namespace labels, exact IDs and owners",()=>{ - expect(filterCapabilities("model/settings").map(item=>item.name)).toEqual(["model/settings/read","model/settings/update"]); - expect(filterCapabilities("","field","QUERY")).toHaveLength(0); - expect(filterCapabilities("가설").every(item=>item.namespace==="hypothesis")).toBe(true); + it("searches namespace labels, exact IDs and owners", () => { + expect(filterCapabilities("model/settings").map((item) => item.name)).toEqual([ + "model/settings/read", + "model/settings/update", + ]); + expect(filterCapabilities("", "field", "QUERY")).toHaveLength(0); + expect(filterCapabilities("가설").every((item) => item.namespace === "hypothesis")).toBe(true); }); - it("does not send arbitrary arrays or a different project scope",()=>{ - expect(()=>parseInvocation("[]","p")).toThrow("JSON 객체"); - expect(()=>parseInvocation('{"project_id":"q"}',"p")).toThrow("일치"); - expect(parseInvocation('{"project_id":"p","thread_id":"t"}',"p")).toEqual({project_id:"p",thread_id:"t"}); + it("does not send arbitrary arrays or a different project scope", () => { + expect(() => parseInvocation("[]", "p")).toThrow("JSON 객체"); + expect(() => parseInvocation('{"project_id":"q"}', "p")).toThrow("일치"); + expect(parseInvocation('{"project_id":"p","thread_id":"t"}', "p")).toEqual({ + project_id: "p", + thread_id: "t", + }); }); }); -describe("source intake payload contract",()=>{ - it("two projects use distinct IDs and inherit the server's scoped default policy",()=>{ - const first=captureProjectCreation("first","2026-09-14T10:00","general-rnd"); - const second=captureProjectCreation("second","2026-09-14T10:00","general-rnd"); +describe("source intake payload contract", () => { + it("two projects use distinct IDs and inherit the server's scoped default policy", () => { + const first = captureProjectCreation("first", "2026-09-14T10:00", "general-rnd"); + const second = captureProjectCreation("second", "2026-09-14T10:00", "general-rnd"); expect(first.input.project_id).not.toBe(second.input.project_id); expect(first.key).not.toBe(second.key); expect(first.input).not.toHaveProperty("policy_binding_ref"); expect(second.input).not.toHaveProperty("policy_binding_ref"); expect(backendContract.project.properties.policy_binding_ref.default).toBe("policy:default"); }); - it("matches Python authority/cutoff enums and emits no UI-only enum aliases",()=>{ + it("matches Python authority/cutoff enums and emits no UI-only enum aliases", () => { expect([...authorityStates]).toEqual(backendContract.source.$defs.AuthorityState.enum); expect([...cutoffStates]).toEqual(backendContract.source.$defs.CutoffState.enum); - expect(authorityOptions.map(item=>item.value).sort()).toEqual([...authorityStates].sort()); - expect(cutoffOptions.map(item=>item.value).sort()).toEqual([...cutoffStates].sort()); - const base={project_id:"p",relative_path:"source.md",media_type:"text/markdown",authority:"INFORMAL",cutoff_state:"AFTER_CUTOFF",security_class:"INTERNAL",resource_scope:{owner_kind:"PROJECT",visibility:"PROJECT_SHARED"},version_label:"web-sha"}; + expect(authorityOptions.map((item) => item.value).sort()).toEqual([...authorityStates].sort()); + expect(cutoffOptions.map((item) => item.value).sort()).toEqual([...cutoffStates].sort()); + const base = { + project_id: "p", + relative_path: "source.md", + media_type: "text/markdown", + authority: "INFORMAL", + cutoff_state: "AFTER_CUTOFF", + security_class: "INTERNAL", + resource_scope: { owner_kind: "PROJECT", visibility: "PROJECT_SHARED" }, + version_label: "web-sha", + }; expect(sourceConnectSchema.parse(base)).toEqual(base); - expect(()=>sourceConnectSchema.parse({...base,authority:"REFERENCE"})).toThrow(); - expect(()=>sourceConnectSchema.parse({...base,cutoff_state:"FUTURE_EXCLUDED"})).toThrow(); - expect(()=>sourceConnectSchema.parse({...base,resource_scope:{owner_kind:"PROJECT",visibility:"WORKSTREAM"}})).toThrow(); + expect(() => sourceConnectSchema.parse({ ...base, authority: "REFERENCE" })).toThrow(); + expect(() => sourceConnectSchema.parse({ ...base, cutoff_state: "FUTURE_EXCLUDED" })).toThrow(); + expect(() => + sourceConnectSchema.parse({ + ...base, + resource_scope: { owner_kind: "PROJECT", visibility: "WORKSTREAM" }, + }), + ).toThrow(); }); - it("keeps incomplete result contracts out of full-analysis consumers",()=>{ - expect(isThreadAnalysis({portfolio:{hypotheses:[]},action_plan:{alternatives:[]},selected_evidence_refs:[]})).toBe(false); - expect(isThreadAnalysis({answer:"partial answer",gaps:["not yet checked"]})).toBe(false); + it("keeps incomplete result contracts out of full-analysis consumers", () => { + expect( + isThreadAnalysis({ + portfolio: { hypotheses: [] }, + action_plan: { alternatives: [] }, + selected_evidence_refs: [], + }), + ).toBe(false); + expect(isThreadAnalysis({ answer: "partial answer", gaps: ["not yet checked"] })).toBe(false); }); }); -afterEach(()=>vi.unstubAllGlobals()); -it("routes authorized reads separately and retains typed error details",async()=>{ - const fetch=vi.fn().mockResolvedValue({ok:true,json:async()=>({jsonrpc:"2.0",id:"x",result:{operation_id:"",state:"SUCCEEDED",value:{}}})}); - vi.stubGlobal("fetch",fetch); - await rpc("thread/list",{project_id:"p"},"same-key"); +afterEach(() => vi.unstubAllGlobals()); +it("routes authorized reads separately and retains typed error details", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + jsonrpc: "2.0", + id: "x", + result: { operation_id: "", state: "SUCCEEDED", value: {} }, + }), + }); + vi.stubGlobal("fetch", fetch); + await rpc("thread/list", { project_id: "p" }, "same-key"); expect(fetch.mock.calls[0][0]).toBe("/rpc/query"); - await rpc("receipt/read",{project_id:"p",receipt_id:"r"},"read-key"); + await rpc("receipt/read", { project_id: "p", receipt_id: "r" }, "read-key"); expect(fetch.mock.calls[1][0]).toBe("/rpc"); - fetch.mockResolvedValue({ok:true,json:async()=>({jsonrpc:"2.0",id:"x",error:{code:-32003,message:"denied",data:{reason_code:"POLICY_BLOCKED"}}})}); - await expect(rpc("action/create",{project_id:"p"},"command-key")).rejects.toMatchObject({name:"RpcError",details:{reason_code:"POLICY_BLOCKED"}} satisfies Partial); + fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + jsonrpc: "2.0", + id: "x", + error: { code: -32003, message: "denied", data: { reason_code: "POLICY_BLOCKED" } }, + }), + }); + await expect(rpc("action/create", { project_id: "p" }, "command-key")).rejects.toMatchObject({ + name: "RpcError", + details: { reason_code: "POLICY_BLOCKED" }, + } satisfies Partial); }); diff --git a/apps/web/src/api/conversation.test.ts b/apps/web/src/api/conversation.test.ts index 3298d196..9957ff42 100644 --- a/apps/web/src/api/conversation.test.ts +++ b/apps/web/src/api/conversation.test.ts @@ -1,54 +1,152 @@ -import { afterEach, expect, it, vi } from "vitest"; -import { readConversation, withCurrentCheckpoint, type ConversationTurn } from "./conversation"; -import { rpc } from "./rpcClient"; - -vi.mock("./rpcClient",()=>({rpc:vi.fn()})); -afterEach(()=>vi.resetAllMocks()); -const input=(epoch:number)=>({request_epoch:epoch,request_revision_digest:`request-${epoch}`,operation_id:`op-${epoch}`,text:`question ${epoch}`,edit_kind:"APPEND",created_at:"2026-09-14T00:00:00Z",authored_text_ref:{revision_digest:`authored-${epoch}`}}); - -it("restores two distinct saved answers and never assigns a different request's result",async()=>{ - vi.mocked(rpc).mockImplementation(async(method,scope)=>({operation_id:"query",state:"SUCCEEDED",value:method==="thread/activity/list"?{conversation:{turns:[input(1),input(2)],next_before_epoch:null,history_limited:false}}:{operation_id:scope.operation_id,state:"SUCCEEDED",result:{thread_id:"t",request_epoch:scope.operation_id==="op-1"?1:2,answer:`answer ${scope.operation_id}`}}})); - const page=await readConversation("p","t",null); - expect(page.turns.map(t=>t.result?.answer)).toEqual(["answer op-1","answer op-2"]); - const updated=withCurrentCheckpoint(page.turns,{operation_state:"RUNNING",current_result:{request_ref:{revision_digest:"request-2"},operation_id:"op-2",result:{answer:"checkpoint"}}}); - expect(updated.map(t=>t.result?.answer)).toEqual(["answer op-1","checkpoint"]); -}); - -it("does not use current text for an inaccessible or mismatched historical answer",async()=>{ - vi.mocked(rpc).mockImplementation(async(method,scope)=>({operation_id:"query",state:"SUCCEEDED",value:method==="thread/activity/list"?{conversation:{turns:[input(1)],next_before_epoch:null,history_limited:false}}:{operation_id:scope.operation_id,state:"SUCCEEDED",result:{thread_id:"other",request_epoch:1,answer:"foreign"}}})); - const page=await readConversation("p","t",null); - expect(page.turns[0].result).toBeNull();expect(page.turns[0].unavailable).toBeTruthy(); -}); - -it("uses digest and operation together for checkpoint binding",()=>{ - const turns:ConversationTurn[]=[{input:input(1),state:"SUCCEEDED",result:{answer:"old"}}]; - expect(withCurrentCheckpoint(turns,{current_result:{request_ref:{revision_digest:"request-1"},operation_id:"other",result:{answer:"wrong"}}})[0].result?.answer).toBe("old"); -}); - -it('retains failure and does not turn an initial checkpoint into an answer',()=>{ - const error={message:'research execution failed',data:{reason_code:'INTERNAL'}}; - const turns:ConversationTurn[]=[{input:input(1),state:'FAILED',result:null,error}]; - const rendered=withCurrentCheckpoint(turns,{operation_state:'FAILED',current_result:{request_ref:{revision_digest:'request-1'},operation_id:'op-1',result:{message:'Examining sources'},completion:'CHECKPOINT'}})[0]; - expect(rendered.result).toBeNull();expect(rendered.error).toBe(error);expect(rendered.state).toBe('FAILED'); -}); - -it('never fills a denied historical result from a current checkpoint',()=>{ - const turns:ConversationTurn[]=[{input:input(1),state:'UNAVAILABLE',result:null,unavailable:'denied'}]; - expect(withCurrentCheckpoint(turns,{current_result:{request_ref:{revision_digest:'request-1'},operation_id:'op-1',result:{answer:'sensitive'}}})).toEqual(turns); -}); - -it('retains actual partial content with the matching failed operation',()=>{ - const turns:ConversationTurn[]=[{input:input(1),state:'FAILED',result:null,error:{message:'failed'}}]; - const rendered=withCurrentCheckpoint(turns,{operation_state:'FAILED',current_result:{request_ref:{revision_digest:'request-1'},operation_id:'op-1',result:{answer:'partial observation'}}})[0]; - expect(rendered.result?.answer).toBe('partial observation');expect(rendered.state).toBe('FAILED'); -}); - -it('does not attribute a later failed request to a readable previous answer',()=>{ - const turns:ConversationTurn[]=[{input:input(1),state:'SUCCEEDED',result:{answer:'previous answer'}}]; - const rendered=withCurrentCheckpoint(turns,{request:{operation_id:'op-2'},operation_state:'FAILED',operation_error:{message:'new request failed'}, - previous_result:{request_ref:{revision_digest:'request-1'},operation_id:'op-1',result:{answer:'previous answer'}}, - basis_currentness:{state:'REVIEW_REQUIRED',reasons:['REQUEST_CHANGED']}})[0]; - expect(rendered.result?.answer).toBe('previous answer'); - expect(rendered.state).toBe('STALE'); +import { afterEach, expect, it, vi } from "vitest"; +import { readConversation, withCurrentCheckpoint, type ConversationTurn } from "./conversation"; +import { rpc } from "./rpcClient"; + +vi.mock("./rpcClient", () => ({ rpc: vi.fn() })); +afterEach(() => vi.resetAllMocks()); +const input = (epoch: number) => ({ + request_epoch: epoch, + request_revision_digest: `request-${epoch}`, + operation_id: `op-${epoch}`, + text: `question ${epoch}`, + edit_kind: "APPEND", + created_at: "2026-09-14T00:00:00Z", + authored_text_ref: { revision_digest: `authored-${epoch}` }, +}); + +it("restores two distinct saved answers and never assigns a different request's result", async () => { + vi.mocked(rpc).mockImplementation(async (method, scope) => ({ + operation_id: "query", + state: "SUCCEEDED", + value: + method === "thread/activity/list" + ? { + conversation: { + turns: [input(1), input(2)], + next_before_epoch: null, + history_limited: false, + }, + } + : { + operation_id: scope.operation_id, + state: "SUCCEEDED", + result: { + thread_id: "t", + request_epoch: scope.operation_id === "op-1" ? 1 : 2, + answer: `answer ${scope.operation_id}`, + }, + }, + })); + const page = await readConversation("p", "t", null); + expect(page.turns.map((t) => t.result?.answer)).toEqual(["answer op-1", "answer op-2"]); + const updated = withCurrentCheckpoint(page.turns, { + operation_state: "RUNNING", + current_result: { + request_ref: { revision_digest: "request-2" }, + operation_id: "op-2", + result: { answer: "checkpoint" }, + }, + }); + expect(updated.map((t) => t.result?.answer)).toEqual(["answer op-1", "checkpoint"]); +}); + +it("does not use current text for an inaccessible or mismatched historical answer", async () => { + vi.mocked(rpc).mockImplementation(async (method, scope) => ({ + operation_id: "query", + state: "SUCCEEDED", + value: + method === "thread/activity/list" + ? { conversation: { turns: [input(1)], next_before_epoch: null, history_limited: false } } + : { + operation_id: scope.operation_id, + state: "SUCCEEDED", + result: { thread_id: "other", request_epoch: 1, answer: "foreign" }, + }, + })); + const page = await readConversation("p", "t", null); + expect(page.turns[0].result).toBeNull(); + expect(page.turns[0].unavailable).toBeTruthy(); +}); + +it("uses digest and operation together for checkpoint binding", () => { + const turns: ConversationTurn[] = [ + { input: input(1), state: "SUCCEEDED", result: { answer: "old" } }, + ]; + expect( + withCurrentCheckpoint(turns, { + current_result: { + request_ref: { revision_digest: "request-1" }, + operation_id: "other", + result: { answer: "wrong" }, + }, + })[0].result?.answer, + ).toBe("old"); +}); + +it("retains failure and does not turn an initial checkpoint into an answer", () => { + const error = { message: "research execution failed", data: { reason_code: "INTERNAL" } }; + const turns: ConversationTurn[] = [{ input: input(1), state: "FAILED", result: null, error }]; + const rendered = withCurrentCheckpoint(turns, { + operation_state: "FAILED", + current_result: { + request_ref: { revision_digest: "request-1" }, + operation_id: "op-1", + result: { message: "Examining sources" }, + completion: "CHECKPOINT", + }, + })[0]; + expect(rendered.result).toBeNull(); + expect(rendered.error).toBe(error); + expect(rendered.state).toBe("FAILED"); +}); + +it("never fills a denied historical result from a current checkpoint", () => { + const turns: ConversationTurn[] = [ + { input: input(1), state: "UNAVAILABLE", result: null, unavailable: "denied" }, + ]; + expect( + withCurrentCheckpoint(turns, { + current_result: { + request_ref: { revision_digest: "request-1" }, + operation_id: "op-1", + result: { answer: "sensitive" }, + }, + }), + ).toEqual(turns); +}); + +it("retains actual partial content with the matching failed operation", () => { + const turns: ConversationTurn[] = [ + { input: input(1), state: "FAILED", result: null, error: { message: "failed" } }, + ]; + const rendered = withCurrentCheckpoint(turns, { + operation_state: "FAILED", + current_result: { + request_ref: { revision_digest: "request-1" }, + operation_id: "op-1", + result: { answer: "partial observation" }, + }, + })[0]; + expect(rendered.result?.answer).toBe("partial observation"); + expect(rendered.state).toBe("FAILED"); +}); + +it("does not attribute a later failed request to a readable previous answer", () => { + const turns: ConversationTurn[] = [ + { input: input(1), state: "SUCCEEDED", result: { answer: "previous answer" } }, + ]; + const rendered = withCurrentCheckpoint(turns, { + request: { operation_id: "op-2" }, + operation_state: "FAILED", + operation_error: { message: "new request failed" }, + previous_result: { + request_ref: { revision_digest: "request-1" }, + operation_id: "op-1", + result: { answer: "previous answer" }, + }, + basis_currentness: { state: "REVIEW_REQUIRED", reasons: ["REQUEST_CHANGED"] }, + })[0]; + expect(rendered.result?.answer).toBe("previous answer"); + expect(rendered.state).toBe("STALE"); expect(rendered.error).toBeUndefined(); }); diff --git a/apps/web/src/api/conversation.ts b/apps/web/src/api/conversation.ts index 6753bee1..f8219fce 100644 --- a/apps/web/src/api/conversation.ts +++ b/apps/web/src/api/conversation.ts @@ -5,23 +5,57 @@ import type { ExecutionError, ResearchFailure, ResultManifest } from "./research import { hasResearchContent } from "./presentation"; import type { Currentness } from "./historyModels"; import { ConversationRefreshError, isTransientReadFailure } from "./conversationRefresh"; -import { readLocalDraft, writeLocalDraft, type BrowserScope, type DraftWriteResult } from "./localWorkspacePersistence"; +import { + readLocalDraft, + writeLocalDraft, + type BrowserScope, + type DraftWriteResult, +} from "./localWorkspacePersistence"; const inputSchema = z.object({ - request_epoch: z.number().int(), request_revision_digest: z.string(), - operation_id: z.string(), text: z.string(), edit_kind: z.string(), created_at: z.string(), + request_epoch: z.number().int(), + request_revision_digest: z.string(), + operation_id: z.string(), + text: z.string(), + edit_kind: z.string(), + created_at: z.string(), authored_text_ref: z.object({ revision_digest: z.string() }).passthrough(), }); -const pageSchema = z.object({ turns: z.array(inputSchema), next_before_epoch: z.number().nullable(), history_limited: z.boolean() }); +const pageSchema = z.object({ + turns: z.array(inputSchema), + next_before_epoch: z.number().nullable(), + history_limited: z.boolean(), +}); export type ConversationInput = z.infer; -export type ConversationTurn = { input: ConversationInput; result: Record | null; state: string; unavailable?: string; error?:ExecutionError | null; failure?:ResearchFailure | null; terminalReason?:string | null; currentness?: Currentness }; -export type ConversationPage = { turns: ConversationTurn[]; next: number | null; limited: boolean; supported: boolean; redacted?: boolean }; +export type ConversationTurn = { + input: ConversationInput; + result: Record | null; + state: string; + unavailable?: string; + error?: ExecutionError | null; + failure?: ResearchFailure | null; + terminalReason?: string | null; + currentness?: Currentness; +}; +export type ConversationPage = { + turns: ConversationTurn[]; + next: number | null; + limited: boolean; + supported: boolean; + redacted?: boolean; +}; function redactedPage(): ConversationPage { return { turns: [], next: null, limited: false, supported: true, redacted: true }; } -export async function readConversation(projectId: string, threadId: string, before: number | null, signal?: AbortSignal, onBlocked?: () => void): Promise { +export async function readConversation( + projectId: string, + threadId: string, + before: number | null, + signal?: AbortSignal, + onBlocked?: () => void, +): Promise { try { return await readConversationPage(projectId, threadId, before, signal, onBlocked); } catch (error) { @@ -34,66 +68,153 @@ export async function readConversation(projectId: string, threadId: string, befo } } -async function readConversationPage(projectId: string, threadId: string, before: number | null, signal?: AbortSignal, onBlocked?: () => void): Promise { - const response = await rpc<{ conversation?: unknown }>("thread/activity/list", { - project_id: projectId, thread_id: threadId, ...(before === null ? {} : { conversation_before_epoch: before }), - }, crypto.randomUUID(), signal); - if (response.value.conversation === undefined) return { turns: [], next: null, limited: false, supported: false }; +async function readConversationPage( + projectId: string, + threadId: string, + before: number | null, + signal?: AbortSignal, + onBlocked?: () => void, +): Promise { + const response = await rpc<{ conversation?: unknown }>( + "thread/activity/list", + { + project_id: projectId, + thread_id: threadId, + ...(before === null ? {} : { conversation_before_epoch: before }), + }, + crypto.randomUUID(), + signal, + ); + if (response.value.conversation === undefined) + return { turns: [], next: null, limited: false, supported: false }; const page = pageSchema.parse(response.value.conversation); - const observations = await Promise.all(page.turns.map(async input => { - const unavailable: ConversationTurn = { input, result: null, state: "UNAVAILABLE", get unavailable() { return t("conversation.unavailable"); } }; - let value; - try { - ({ value } = await rpc<{ operation_id: string; state: string; result: Record | null; error?:ExecutionError | null }>( - "operation/result/read", { project_id: projectId, operation_id: input.operation_id }, crypto.randomUUID(), signal)); - } catch (error) { - if (signal?.aborted) throw error; - const transient = isTransientReadFailure(error); - if (!transient) onBlocked?.(); - return { turn: unavailable, transient }; - } - const result = value.result; - if (value.operation_id !== input.operation_id || (result && (result.thread_id !== threadId || result.request_epoch !== input.request_epoch))) { - onBlocked?.(); - return { turn: unavailable, transient: false }; - } - return { turn: { input, result, state: value.state, error:value.error, failure:value.error?.data?.failure, - terminalReason:typeof result?.terminal_reason === 'string'?result.terminal_reason:null } satisfies ConversationTurn, transient: false }; - })); - if (observations.some(item => item.transient)) { + const observations = await Promise.all( + page.turns.map(async (input) => { + const unavailable: ConversationTurn = { + input, + result: null, + state: "UNAVAILABLE", + get unavailable() { + return t("conversation.unavailable"); + }, + }; + let value; + try { + ({ value } = await rpc<{ + operation_id: string; + state: string; + result: Record | null; + error?: ExecutionError | null; + }>( + "operation/result/read", + { project_id: projectId, operation_id: input.operation_id }, + crypto.randomUUID(), + signal, + )); + } catch (error) { + if (signal?.aborted) throw error; + const transient = isTransientReadFailure(error); + if (!transient) onBlocked?.(); + return { turn: unavailable, transient }; + } + const result = value.result; + if ( + value.operation_id !== input.operation_id || + (result && (result.thread_id !== threadId || result.request_epoch !== input.request_epoch)) + ) { + onBlocked?.(); + return { turn: unavailable, transient: false }; + } + return { + turn: { + input, + result, + state: value.state, + error: value.error, + failure: value.error?.data?.failure, + terminalReason: + typeof result?.terminal_reason === "string" ? result.terminal_reason : null, + } satisfies ConversationTurn, + transient: false, + }; + }), + ); + if (observations.some((item) => item.transient)) { // A concurrent denial/identity failure must take priority over retained cached content. - if (observations.some(item => !item.transient && item.turn.state === "UNAVAILABLE")) { + if (observations.some((item) => !item.transient && item.turn.state === "UNAVAILABLE")) { return redactedPage(); } throw new ConversationRefreshError(); } - const turns = observations.map(item => item.turn); + const turns = observations.map((item) => item.turn); return { turns, next: page.next_before_epoch, limited: page.history_limited, supported: true }; } /** A current checkpoint can update only the exact authored request it belongs to. */ -type Checkpoint = Pick & Partial>; -export function withCurrentCheckpoint(turns: ConversationTurn[], status?: {current_result?: Checkpoint | null; previous_result?: Checkpoint | null; operation_state?: string; operation_error?:ExecutionError | null; failure?:ResearchFailure | null; basis_currentness?: Currentness; request?: { operation_id: string }}): ConversationTurn[] { +type Checkpoint = Pick & + Partial>; +export function withCurrentCheckpoint( + turns: ConversationTurn[], + status?: { + current_result?: Checkpoint | null; + previous_result?: Checkpoint | null; + operation_state?: string; + operation_error?: ExecutionError | null; + failure?: ResearchFailure | null; + basis_currentness?: Currentness; + request?: { operation_id: string }; + }, +): ConversationTurn[] { if (!status) return turns; const manifest = status.current_result ?? status.previous_result; const ownsOperation = !status.request || status.request.operation_id === manifest?.operation_id; - return turns.map(turn => !turn.unavailable && turn.state !== 'UNAVAILABLE' && manifest?.request_ref?.revision_digest === turn.input.request_revision_digest && - manifest.operation_id === turn.input.operation_id ? { - ...turn, result:hasResearchContent(manifest.result) ? manifest.result : turn.result, - state:ownsOperation && ["FAILED", "CANCELLED"].includes(status.operation_state ?? "") ? status.operation_state! : status.current_result && ownsOperation ? status.operation_state ?? turn.state : ["FAILED", "CANCELLED"].includes(turn.state) ? turn.state : "STALE", - error:(ownsOperation ? status.operation_error : null) ?? turn.error, failure:(ownsOperation ? status.failure : null) ?? turn.failure, - currentness: status.basis_currentness ?? turn.currentness, - terminalReason:manifest.terminal_reason ?? turn.terminalReason, - } : turn); + return turns.map((turn) => + !turn.unavailable && + turn.state !== "UNAVAILABLE" && + manifest?.request_ref?.revision_digest === turn.input.request_revision_digest && + manifest.operation_id === turn.input.operation_id + ? { + ...turn, + result: hasResearchContent(manifest.result) ? manifest.result : turn.result, + state: + ownsOperation && ["FAILED", "CANCELLED"].includes(status.operation_state ?? "") + ? status.operation_state! + : status.current_result && ownsOperation + ? (status.operation_state ?? turn.state) + : ["FAILED", "CANCELLED"].includes(turn.state) + ? turn.state + : "STALE", + error: (ownsOperation ? status.operation_error : null) ?? turn.error, + failure: (ownsOperation ? status.failure : null) ?? turn.failure, + currentness: status.basis_currentness ?? turn.currentness, + terminalReason: manifest.terminal_reason ?? turn.terminalReason, + } + : turn, + ); } -const draftKey = (project: string, thread: string, scope?: BrowserScope) => `thoth:draft:v1:${JSON.stringify([project, thread, ...(scope?.mode === "HOSTED" && scope.browserScopeId ? [scope.browserScopeId] : [])])}`; +const draftKey = (project: string, thread: string, scope?: BrowserScope) => + `thoth:draft:v1:${JSON.stringify([project, thread, ...(scope?.mode === "HOSTED" && scope.browserScopeId ? [scope.browserScopeId] : [])])}`; export function readDraft(project: string, thread: string, scope?: BrowserScope): string { if (scope?.mode === "LOCAL") return readLocalDraft(scope.workspaceId, project, thread); - try { return sessionStorage.getItem(draftKey(project, thread, scope)) ?? ""; } catch { return ""; } + try { + return sessionStorage.getItem(draftKey(project, thread, scope)) ?? ""; + } catch { + return ""; + } } -export function writeDraft(project: string, thread: string, text: string, scope?: BrowserScope): DraftWriteResult { +export function writeDraft( + project: string, + thread: string, + text: string, + scope?: BrowserScope, +): DraftWriteResult { if (scope?.mode === "LOCAL") return writeLocalDraft(scope.workspaceId, project, thread, text); - try { if (text) sessionStorage.setItem(draftKey(project, thread, scope), text); else sessionStorage.removeItem(draftKey(project, thread, scope)); return "SAVED"; } - catch { return "UNAVAILABLE"; } + try { + if (text) sessionStorage.setItem(draftKey(project, thread, scope), text); + else sessionStorage.removeItem(draftKey(project, thread, scope)); + return "SAVED"; + } catch { + return "UNAVAILABLE"; + } } diff --git a/apps/web/src/api/conversationRefetchDiagnosis.test.ts b/apps/web/src/api/conversationRefetchDiagnosis.test.ts index 4aa2fa7b..8236f84d 100644 --- a/apps/web/src/api/conversationRefetchDiagnosis.test.ts +++ b/apps/web/src/api/conversationRefetchDiagnosis.test.ts @@ -4,31 +4,65 @@ import { readConversation, withCurrentCheckpoint, type ConversationPage } from " import { canRetainConversation } from "./conversationRefresh"; import { rpc, RpcError, RpcTransportError } from "./rpcClient"; -vi.mock("./rpcClient", async importOriginal => ({ - ...await importOriginal(), rpc: vi.fn(), +vi.mock("./rpcClient", async (importOriginal) => ({ + ...(await importOriginal()), + rpc: vi.fn(), })); const clients: QueryClient[] = []; -afterEach(() => { clients.splice(0).forEach(client => client.clear()); vi.resetAllMocks(); }); +afterEach(() => { + clients.splice(0).forEach((client) => client.clear()); + vi.resetAllMocks(); +}); async function readableAnswerThen(error: Error) { let interrupted = false; - vi.mocked(rpc).mockImplementation(async method => { - if (method === "thread/activity/list") return { operation_id: "query", state: "SUCCEEDED", value: { - conversation: { turns: [{ request_epoch: 1, request_revision_digest: "request-1", operation_id: "op-1", - text: "saved question", edit_kind: "APPEND", created_at: "2026-09-21T09:45:09Z", - authored_text_ref: { revision_digest: "authored-1" } }], next_before_epoch: null, history_limited: false }, - }}; + vi.mocked(rpc).mockImplementation(async (method) => { + if (method === "thread/activity/list") + return { + operation_id: "query", + state: "SUCCEEDED", + value: { + conversation: { + turns: [ + { + request_epoch: 1, + request_revision_digest: "request-1", + operation_id: "op-1", + text: "saved question", + edit_kind: "APPEND", + created_at: "2026-09-21T09:45:09Z", + authored_text_ref: { revision_digest: "authored-1" }, + }, + ], + next_before_epoch: null, + history_limited: false, + }, + }, + }; if (interrupted) throw error; - return { operation_id: "query", state: "SUCCEEDED", value: { - operation_id: "op-1", state: "SUCCEEDED", result: { thread_id: "t", request_epoch: 1, answer: "saved answer" }, - }}; + return { + operation_id: "query", + state: "SUCCEEDED", + value: { + operation_id: "op-1", + state: "SUCCEEDED", + result: { thread_id: "t", request_epoch: 1, answer: "saved answer" }, + }, + }; + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, }); - const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); clients.push(client); - const options = { queryKey: ["conversation", "p", "t"], queryFn: () => readConversation("p", "t", null) }; + const options = { + queryKey: ["conversation", "p", "t"], + queryFn: () => readConversation("p", "t", null), + }; await client.fetchQuery(options); - expect(client.getQueryData(options.queryKey)?.turns[0].result?.answer).toBe("saved answer"); + expect(client.getQueryData(options.queryKey)?.turns[0].result?.answer).toBe( + "saved answer", + ); interrupted = true; return { client, options }; } @@ -36,18 +70,27 @@ async function readableAnswerThen(error: Error) { it.each([ ["network", () => new RpcTransportError("RPC network request failed", "NETWORK")], ["timeout", () => new RpcTransportError("RPC timed out", "TIMEOUT")], - ["temporary upstream failure", () => new RpcTransportError("RPC transport failed (504)", "HTTP", 504)], -] as const)("keeps a saved answer when a return-to-window refetch has a %s error", async (_label, makeError) => { - const { client, options } = await readableAnswerThen(makeError()); - await expect(client.fetchQuery(options)).rejects.toBeInstanceOf(Error); - const cached = client.getQueryData(options.queryKey)!; - expect(cached.turns[0].result?.answer).toBe("saved answer"); - expect(client.getQueryState(options.queryKey)?.status).toBe("error"); -}); + [ + "temporary upstream failure", + () => new RpcTransportError("RPC transport failed (504)", "HTTP", 504), + ], +] as const)( + "keeps a saved answer when a return-to-window refetch has a %s error", + async (_label, makeError) => { + const { client, options } = await readableAnswerThen(makeError()); + await expect(client.fetchQuery(options)).rejects.toBeInstanceOf(Error); + const cached = client.getQueryData(options.queryKey)!; + expect(cached.turns[0].result?.answer).toBe("saved answer"); + expect(client.getQueryState(options.queryKey)?.status).toBe("error"); + }, +); it("replaces a saved answer with an unavailable state when access is actually denied", async () => { - const { client, options } = await readableAnswerThen(new RpcError("resource access rejected", -32040, - { reason_code: "RESOURCE_SCOPE_ACCESS_DENIED" })); + const { client, options } = await readableAnswerThen( + new RpcError("resource access rejected", -32040, { + reason_code: "RESOURCE_SCOPE_ACCESS_DENIED", + }), + ); const refreshed = await client.fetchQuery(options); expect(refreshed.turns[0].state).toBe("UNAVAILABLE"); expect(refreshed.turns[0].result).toBeNull(); @@ -55,10 +98,23 @@ it("replaces a saved answer with an unavailable state when access is actually de }); it("does not classify malformed result data as a temporary transport error", async () => { - const { client, options } = await readableAnswerThen(new RpcTransportError("RPC network request failed", "NETWORK")); + const { client, options } = await readableAnswerThen( + new RpcTransportError("RPC network request failed", "NETWORK"), + ); const existing = client.getQueryData(options.queryKey)!; - vi.mocked(rpc).mockImplementation(async method => ({ operation_id: "query", state: "SUCCEEDED", value: - method === "thread/activity/list" ? { conversation: { turns: existing.turns.map(turn => turn.input), next_before_epoch: null, history_limited: false } } : undefined, + vi.mocked(rpc).mockImplementation(async (method) => ({ + operation_id: "query", + state: "SUCCEEDED", + value: + method === "thread/activity/list" + ? { + conversation: { + turns: existing.turns.map((turn) => turn.input), + next_before_epoch: null, + history_limited: false, + }, + } + : undefined, })); const refreshed = await client.fetchQuery(options); expect(refreshed.redacted).toBe(true); @@ -67,12 +123,19 @@ it("does not classify malformed result data as a temporary transport error", asy }); it("keeps an earlier stored failure when a different request is current", async () => { - const { client, options } = await readableAnswerThen(new RpcTransportError("RPC network request failed", "NETWORK")); + const { client, options } = await readableAnswerThen( + new RpcTransportError("RPC network request failed", "NETWORK"), + ); const turn = client.getQueryData(options.queryKey)!.turns[0]; const error = { message: "earlier stored failure" }; const merged = withCurrentCheckpoint([{ ...turn, state: "FAILED", error }], { - request: { operation_id: "op-2" }, operation_state: "SUCCEEDED", - previous_result: { request_ref: { revision_digest: "request-1" }, operation_id: "op-1", result: turn.result! }, + request: { operation_id: "op-2" }, + operation_state: "SUCCEEDED", + previous_result: { + request_ref: { revision_digest: "request-1" }, + operation_id: "op-1", + result: turn.result!, + }, }); expect(merged[0].state).toBe("FAILED"); expect(merged[0].error).toBe(error); diff --git a/apps/web/src/api/expectedResultContract.ts b/apps/web/src/api/expectedResultContract.ts index 54d46aed..d3446494 100644 --- a/apps/web/src/api/expectedResultContract.ts +++ b/apps/web/src/api/expectedResultContract.ts @@ -5,5 +5,7 @@ export function expectedResultContract(ready: unknown): "v4" | "v5" | undefined if (ready.expected_result_contracts.includes("v5")) return "v5"; return ready.expected_result_contracts.includes("v4") ? "v4" : undefined; } - return "response_language_scope" in ready && ready.response_language_scope === "REQUEST" ? "v4" : undefined; + return "response_language_scope" in ready && ready.response_language_scope === "REQUEST" + ? "v4" + : undefined; } diff --git a/apps/web/src/api/expectedResultState.test.ts b/apps/web/src/api/expectedResultState.test.ts index ced40860..e1ea752d 100644 --- a/apps/web/src/api/expectedResultState.test.ts +++ b/apps/web/src/api/expectedResultState.test.ts @@ -4,7 +4,13 @@ import { expectedUnknown } from "./expectedResultState"; it("uses the shared state fixture without changing legacy Unicode or display semantics", () => { for (const item of fixture) { - expect(expectedUnknown(item.row.expected ?? "", item.row.knowledge_state, "ranking"), item.name).toBe(item.web); - expect(expectedUnknown(item.row.expected ?? "", item.row.knowledge_state, "annotation"), item.name).toBe(item.annotation); + expect( + expectedUnknown(item.row.expected ?? "", item.row.knowledge_state, "ranking"), + item.name, + ).toBe(item.web); + expect( + expectedUnknown(item.row.expected ?? "", item.row.knowledge_state, "annotation"), + item.name, + ).toBe(item.annotation); } }); diff --git a/apps/web/src/api/expectedResultState.ts b/apps/web/src/api/expectedResultState.ts index 8262fec8..2ad216f9 100644 --- a/apps/web/src/api/expectedResultState.ts +++ b/apps/web/src/api/expectedResultState.ts @@ -1,6 +1,11 @@ /** Legacy consumers intentionally retain their different historical whitespace rules. */ -export function expectedUnknown(label: string, state: unknown, consumer: "ranking" | "annotation"): boolean { +export function expectedUnknown( + label: string, + state: unknown, + consumer: "ranking" | "annotation", +): boolean { if (state !== undefined) return state === "UNKNOWN"; - const normalized = consumer === "ranking" ? label.trim().replace(/\s+/g, " ").toLowerCase() : label.trim(); + const normalized = + consumer === "ranking" ? label.trim().replace(/\s+/g, " ").toLowerCase() : label.trim(); return normalized === "모름"; } diff --git a/apps/web/src/api/files.test.ts b/apps/web/src/api/files.test.ts index 274bfbdf..38f19cb6 100644 --- a/apps/web/src/api/files.test.ts +++ b/apps/web/src/api/files.test.ts @@ -7,12 +7,22 @@ import { UploadRefused } from "./uploadTypes"; type Progress = { phase: "uploading" | "processing"; loaded: number; total: number | null }; type Options = { signal?: AbortSignal; onProgress?: (progress: Progress) => void }; const upload: (file: File, project: string, options?: Options) => Promise = stageFile; -const staged: StagedFile = { relative_path: "uploads/p/a.md", filename: "a.md", media_type: "text/markdown", byte_sha256: "a".repeat(64), bytes: 4 }; -const stalled = "No data was sent for 2 minutes, so the upload stopped. Check your connection and try again. No source was connected."; +const staged: StagedFile = { + relative_path: "uploads/p/a.md", + filename: "a.md", + media_type: "text/markdown", + byte_sha256: "a".repeat(64), + bytes: 4, +}; +const stalled = + "No data was sent for 2 minutes, so the upload stopped. Check your connection and try again. No source was connected."; class FakeXHR { static instances: FakeXHR[] = []; - upload = { onprogress: null as null | ((event: ProgressEvent) => void), onload: null as null | (() => void) }; + upload = { + onprogress: null as null | ((event: ProgressEvent) => void), + onload: null as null | (() => void), + }; onload: null | (() => void) = null; onerror: null | (() => void) = null; onabort: null | (() => void) = null; @@ -25,33 +35,58 @@ class FakeXHR { body: FormData | null = null; open = vi.fn(); setRequestHeader = vi.fn(); - send = vi.fn((body: FormData) => { this.body = body; }); + send = vi.fn((body: FormData) => { + this.body = body; + }); abort = vi.fn(() => this.onabort?.()); - constructor() { FakeXHR.instances.push(this); } + constructor() { + FakeXHR.instances.push(this); + } progress(loaded: number, total = 100, lengthComputable = true) { - this.upload.onprogress?.(Object.assign(new Event("progress"), { loaded, total, lengthComputable })); + this.upload.onprogress?.( + Object.assign(new Event("progress"), { loaded, total, lengthComputable }), + ); } complete(status = 200, text = JSON.stringify(staged)) { - this.status = status; this.responseText = text; this.onload?.(); + this.status = status; + this.responseText = text; + this.onload?.(); } } beforeEach(() => { - vi.useFakeTimers(); setLocale("en"); FakeXHR.instances = []; + vi.useFakeTimers(); + setLocale("en"); + FakeXHR.instances = []; vi.stubGlobal("XMLHttpRequest", FakeXHR); - vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + setLocale("ko"); }); -afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setLocale("ko"); }); function start(options?: Options) { const result: { value?: StagedFile; error?: Error } = {}; const file = new File(["data"], "a.md", { type: "text/markdown" }); const done = upload(file, "project:p", options).then( - value => { result.value = value; }, error => { result.error = error; }, + (value) => { + result.value = value; + }, + (error) => { + result.error = error; + }, ); return { result, done, file }; } -function xhr() { expect(FakeXHR.instances).toHaveLength(1); return FakeXHR.instances[0]; } +function xhr() { + expect(FakeXHR.instances).toHaveLength(1); + return FakeXHR.instances[0]; +} async function expectNoUploadTimer() { // Drain DOM bookkeeping at the same instant; a leaked 120-second timer would remain. await vi.advanceTimersByTimeAsync(0); @@ -67,21 +102,26 @@ it("fails at 120 seconds without progress, not at 119.999 seconds", async () => expect(xhr().abort).toHaveBeenCalledTimes(1); await expectNoUploadTimer(); setLocale("ko"); - expect(result.error?.message).toBe("파일을 보내는 중 2분 동안 진행이 없어 멈췄습니다. 연결을 확인하고 다시 시도하세요. 연결된 자료는 없습니다."); + expect(result.error?.message).toBe( + "파일을 보내는 중 2분 동안 진행이 없어 멈췄습니다. 연결을 확인하고 다시 시도하세요. 연결된 자료는 없습니다.", + ); }); it("allows a slow upload beyond two minutes while bytes keep increasing", async () => { const updates: Progress[] = []; - const { result, done, file } = start({ onProgress: value => updates.push(value) }); + const { result, done, file } = start({ onProgress: (value) => updates.push(value) }); const request = xhr(); expect(request.open).toHaveBeenCalledWith("POST", "/files/stage"); expect(request.setRequestHeader).toHaveBeenCalledWith("x-thoth-project-id", "project:p"); expect(request.body?.get("file")).toBe(file); for (const loaded of [10, 20, 30]) { - await vi.advanceTimersByTimeAsync(119000); request.progress(loaded); + await vi.advanceTimersByTimeAsync(119000); + request.progress(loaded); expect(result.error).toBeUndefined(); } - request.upload.onload?.(); request.complete(); await done; + request.upload.onload?.(); + request.complete(); + await done; expect(result.value).toEqual(staged); expect(updates).toContainEqual({ phase: "uploading", loaded: 30, total: 100 }); expect(updates.at(-1)?.phase).toBe("processing"); @@ -90,58 +130,79 @@ it("allows a slow upload beyond two minutes while bytes keep increasing", async }); it("does not renew the deadline for repeated or decreasing byte counts", async () => { - const { result } = start(); const request = xhr(); - await vi.advanceTimersByTimeAsync(110000); request.progress(10); - await vi.advanceTimersByTimeAsync(119000); request.progress(10); request.progress(9); + const { result } = start(); + const request = xhr(); + await vi.advanceTimersByTimeAsync(110000); + request.progress(10); + await vi.advanceTimersByTimeAsync(119000); + request.progress(10); + request.progress(9); expect(result.error).toBeUndefined(); await vi.advanceTimersByTimeAsync(1000); expect(result.error?.message).toBe(stalled); }); it("tracks increasing bytes even if the total length is unknown", async () => { - const progress = vi.fn(); const { result, done } = start({ onProgress: progress }); + const progress = vi.fn(); + const { result, done } = start({ onProgress: progress }); const request = xhr(); - await vi.advanceTimersByTimeAsync(119000); request.progress(10, 0, false); + await vi.advanceTimersByTimeAsync(119000); + request.progress(10, 0, false); await vi.advanceTimersByTimeAsync(119000); expect(result.error).toBeUndefined(); expect(progress).toHaveBeenLastCalledWith({ phase: "uploading", loaded: 10, total: null }); - request.complete(); await done; + request.complete(); + await done; }); it("has no processing deadline after upload completion and ignores late progress", async () => { - const { result, done } = start(); const request = xhr(); - await vi.advanceTimersByTimeAsync(119000); request.upload.onload?.(); - await vi.advanceTimersByTimeAsync(180000); request.progress(100); + const { result, done } = start(); + const request = xhr(); + await vi.advanceTimersByTimeAsync(119000); + request.upload.onload?.(); + await vi.advanceTimersByTimeAsync(180000); + request.progress(100); expect(result.error).toBeUndefined(); await expectNoUploadTimer(); - request.complete(); await done; + request.complete(); + await done; expect(result.value).toEqual(staged); }); it("allows cancellation while the server is processing without a timer", async () => { const controller = new AbortController(); - const { result, done } = start({ signal: controller.signal }); const request = xhr(); - request.upload.onload?.(); await vi.advanceTimersByTimeAsync(180000); + const { result, done } = start({ signal: controller.signal }); + const request = xhr(); + request.upload.onload?.(); + await vi.advanceTimersByTimeAsync(180000); expect(result.error).toBeUndefined(); - controller.abort(); await done; + controller.abort(); + await done; expect(result.error?.message).toBe("Upload cancelled. No source was connected."); expect(request.abort).toHaveBeenCalledTimes(1); await expectNoUploadTimer(); }); it("cancels the request and ignores a late success callback", async () => { - const controller = new AbortController(); const { result, done } = start({ signal: controller.signal }); - const request = xhr(); const lateLoad = request.onload; - controller.abort(); await done; + const controller = new AbortController(); + const { result, done } = start({ signal: controller.signal }); + const request = xhr(); + const lateLoad = request.onload; + controller.abort(); + await done; expect(request.abort).toHaveBeenCalledTimes(1); expect(result.error?.message).toBe("Upload cancelled. No source was connected."); - request.status = 200; request.responseText = JSON.stringify(staged); lateLoad?.(); + request.status = 200; + request.responseText = JSON.stringify(staged); + lateLoad?.(); expect(result.value).toBeUndefined(); await expectNoUploadTimer(); }); it("does not send a request for an already cancelled signal", async () => { - const controller = new AbortController(); controller.abort(); const { result } = start({ signal: controller.signal }); + const controller = new AbortController(); + controller.abort(); + const { result } = start({ signal: controller.signal }); await vi.advanceTimersByTimeAsync(0); expect(result.error?.message).toBe("Upload cancelled. No source was connected."); expect(FakeXHR.instances).toHaveLength(0); @@ -151,20 +212,26 @@ it("does not send a request for an already cancelled signal", async () => { it("does not send if cancellation occurs during initial progress notification", async () => { const controller = new AbortController(); - const { result, done } = start({ signal: controller.signal, onProgress: () => controller.abort() }); + const { result, done } = start({ + signal: controller.signal, + onProgress: () => controller.abort(), + }); await done; expect(result.error?.message).toBe("Upload cancelled. No source was connected."); expect(xhr().send).not.toHaveBeenCalled(); await expectNoUploadTimer(); }); -it.each([[413, '{"detail":"server detail"}', "server detail"], [503, "not json", "503"]])( - "preserves HTTP error handling for %s", async (status, text, expected) => { - const { result, done } = start(); xhr().complete(Number(status), String(text)); await done; - expect(result.error?.message).toContain(String(expected)); - await expectNoUploadTimer(); - }, -); +it.each([ + [413, '{"detail":"server detail"}', "server detail"], + [503, "not json", "503"], +])("preserves HTTP error handling for %s", async (status, text, expected) => { + const { result, done } = start(); + xhr().complete(Number(status), String(text)); + await done; + expect(result.error?.message).toContain(String(expected)); + await expectNoUploadTimer(); +}); it("turns a refusal the server names by code into one that names the file, and keeps the old sentence otherwise", async () => { const refused = start(); @@ -173,7 +240,9 @@ it("turns a refusal the server names by code into one that names the file, and k await refused.done; expect(refused.result.error).toBeInstanceOf(UploadRefused); expect(refused.result.error).toMatchObject({ code: "UPLOAD_UNSUPPORTED_TYPE", filename: "a.md" }); - expect(refused.result.error?.message).toContain('"a.md" is not a file type that can be uploaded.'); + expect(refused.result.error?.message).toContain( + '"a.md" is not a file type that can be uploaded.', + ); setLocale("ko"); expect(refused.result.error?.message).toContain('"a.md" 파일은 올릴 수 없는 형식입니다.'); setLocale("en"); @@ -189,13 +258,17 @@ it("turns a refusal the server names by code into one that names the file, and k }); it("cleans up after malformed successful JSON", async () => { - const { result, done } = start(); xhr().complete(200, "invalid json"); await done; + const { result, done } = start(); + xhr().complete(200, "invalid json"); + await done; expect(result.error).toBeInstanceOf(SyntaxError); await expectNoUploadTimer(); }); it("cleans up after network errors", async () => { - const { result, done } = start(); xhr().onerror?.(); await done; + const { result, done } = start(); + xhr().onerror?.(); + await done; expect(result.error).toBeInstanceOf(Error); await expectNoUploadTimer(); }); diff --git a/apps/web/src/api/files.ts b/apps/web/src/api/files.ts index 70e76451..733872a2 100644 --- a/apps/web/src/api/files.ts +++ b/apps/web/src/api/files.ts @@ -21,7 +21,11 @@ export type StageFileOptions = { const STALL_MS = 120_000; -export function stageFile(file: File, projectId: string, options: StageFileOptions = {}): Promise { +export function stageFile( + file: File, + projectId: string, + options: StageFileOptions = {}, +): Promise { if (options.signal?.aborted) return Promise.reject(new UiError("sources.fileUploadCancelled")); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -40,13 +44,18 @@ export function stageFile(file: File, projectId: string, options: StageFileOptio if (error !== undefined) reject(error); else resolve(result!); }; - const stop = (error: Error) => { if (!settled) { finish(error); xhr.abort(); } }; + const stop = (error: Error) => { + if (!settled) { + finish(error); + xhr.abort(); + } + }; const cancel = () => stop(new UiError("sources.fileUploadCancelled")); const renew = () => { clearTimeout(timer); timer = setTimeout(() => stop(new UiError("sources.fileUploadStalled")), STALL_MS); }; - xhr.upload.onprogress = event => { + xhr.upload.onprogress = (event) => { if (settled || processing || event.loaded <= loaded) return; loaded = event.loaded; total = event.lengthComputable && event.total > 0 ? event.total : null; @@ -70,12 +79,19 @@ export function stageFile(file: File, projectId: string, options: StageFileOptio return; } let detail: { detail?: string } | null = null; - try { detail = JSON.parse(xhr.responseText); } catch { /* Preserve the HTTP fallback. */ } + try { + detail = JSON.parse(xhr.responseText); + } catch { + /* Preserve the HTTP fallback. */ + } finish(new Error(detail?.detail ?? formatMessage("filePrepareFailed", xhr.status))); return; } - try { finish(undefined, JSON.parse(xhr.responseText) as StagedFile); } - catch (error) { finish(error); } + try { + finish(undefined, JSON.parse(xhr.responseText) as StagedFile); + } catch (error) { + finish(error); + } }; xhr.onerror = () => finish(new Error(formatMessage("filePrepareFailed", xhr.status))); xhr.onabort = () => finish(new UiError("sources.fileUploadCancelled")); @@ -89,6 +105,8 @@ export function stageFile(file: File, projectId: string, options: StageFileOptio renew(); options.onProgress?.({ phase: "uploading", loaded, total }); if (!settled) xhr.send(body); - } catch (error) { finish(error); } + } catch (error) { + finish(error); + } }); } diff --git a/apps/web/src/api/forgetProject.test.ts b/apps/web/src/api/forgetProject.test.ts index 881e9790..0d5d7deb 100644 --- a/apps/web/src/api/forgetProject.test.ts +++ b/apps/web/src/api/forgetProject.test.ts @@ -1,11 +1,19 @@ // @vitest-environment jsdom import { afterEach, expect, it } from "vitest"; import { forgetProject } from "./forgetProject"; -import { inspectLocalContext, readLocalDraft, writeLocalContext, writeLocalDraft } from "./localWorkspacePersistence"; +import { + inspectLocalContext, + readLocalDraft, + writeLocalContext, + writeLocalDraft, +} from "./localWorkspacePersistence"; const HERE = `workspace:${"c".repeat(32)}`; const OTHER = `workspace:${"d".repeat(32)}`; -afterEach(() => { localStorage.clear(); sessionStorage.clear(); }); +afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); +}); it("removes drafts and the last place of the deleted project in this workspace and nothing else", () => { writeLocalDraft(HERE, "gone", "t1", "unsent one"); @@ -29,4 +37,3 @@ it("leaves the last place alone when it points at another project", () => { forgetProject({ mode: "LOCAL", workspaceId: HERE }, "gone"); expect(inspectLocalContext(HERE).context).toEqual({ projectId: "kept", threadId: "t1" }); }); - diff --git a/apps/web/src/api/forgetProject.ts b/apps/web/src/api/forgetProject.ts index 192e8ed3..92068c7c 100644 --- a/apps/web/src/api/forgetProject.ts +++ b/apps/web/src/api/forgetProject.ts @@ -1,4 +1,8 @@ -import { inspectLocalContext, writeLocalContext, type BrowserScope } from "./localWorkspacePersistence"; +import { + inspectLocalContext, + writeLocalContext, + type BrowserScope, +} from "./localWorkspacePersistence"; const localPrefix = "thoth:local-workspace:v1:"; const sessionPrefixes = ["thoth:draft:v1:", "thoth:restore-pending:v1:"]; @@ -16,7 +20,9 @@ function parsedList(raw: string): unknown[] | null { try { const value: unknown = JSON.parse(raw); return Array.isArray(value) ? value : null; - } catch { return null; } + } catch { + return null; + } } /** @@ -27,17 +33,21 @@ export function forgetProject(scope: BrowserScope, projectId: string): void { try { if (scope.mode === "LOCAL" && scope.workspaceId) { const context = inspectLocalContext(scope.workspaceId); - if (context.kind === "VALID" && context.context.projectId === projectId) writeLocalContext(scope.workspaceId, { projectId: "", threadId: "" }); + if (context.kind === "VALID" && context.context.projectId === projectId) + writeLocalContext(scope.workspaceId, { projectId: "", threadId: "" }); for (const key of keysOf(localStorage)) { - const kind = ["draft:", "pending:"].find(name => key.startsWith(localPrefix + name)); + const kind = ["draft:", "pending:"].find((name) => key.startsWith(localPrefix + name)); const identity = kind ? parsedList(key.slice(localPrefix.length + kind.length)) : null; - if (identity && identity[0] === scope.workspaceId && identity[1] === projectId) localStorage.removeItem(key); + if (identity && identity[0] === scope.workspaceId && identity[1] === projectId) + localStorage.removeItem(key); } } for (const key of keysOf(sessionStorage)) { - const prefix = sessionPrefixes.find(name => key.startsWith(name)); + const prefix = sessionPrefixes.find((name) => key.startsWith(name)); const identity = prefix ? parsedList(key.slice(prefix.length)) : null; if (identity && identity[0] === projectId) sessionStorage.removeItem(key); } - } catch { /* Storage can be unavailable; nothing the server holds depends on it. */ } + } catch { + /* Storage can be unavailable; nothing the server holds depends on it. */ + } } diff --git a/apps/web/src/api/groundingNegotiation.test.ts b/apps/web/src/api/groundingNegotiation.test.ts index ebdbee6f..3ccdd19a 100644 --- a/apps/web/src/api/groundingNegotiation.test.ts +++ b/apps/web/src/api/groundingNegotiation.test.ts @@ -15,42 +15,59 @@ it.each([ }); it("negotiates all three reads across old, v5 and legacy servers without changing commands", async () => { - const sent: {method: string; params: {input: Record}}[] = []; + const sent: { method: string; params: { input: Record } }[] = []; const reads = ["thread/read", "operation/result/read", "thread/result/read"]; let ready: Record = {}; - vi.stubGlobal("fetch", vi.fn(async (_url, options) => { - const request = JSON.parse(String(options.body)); sent.push(request); - return Response.json({ jsonrpc: "2.0", id: request.id, result: { operation_id: "", state: "SUCCEEDED", value: request.method === "workspace/ready" ? ready : {} } }); - })); + vi.stubGlobal( + "fetch", + vi.fn(async (_url, options) => { + const request = JSON.parse(String(options.body)); + sent.push(request); + return Response.json({ + jsonrpc: "2.0", + id: request.id, + result: { + operation_id: "", + state: "SUCCEEDED", + value: request.method === "workspace/ready" ? ready : {}, + }, + }); + }), + ); await rpc("workspace/ready", {}, "ready-unsupported"); for (const method of reads) { - await rpc(method, {project_id: "p", operation_id: "op"}, `unsupported-${method}`); + await rpc(method, { project_id: "p", operation_id: "op" }, `unsupported-${method}`); expect(sent.at(-1)!.params.input).not.toHaveProperty("expected_result_contract"); } ready = { expected_result_contracts: ["v4", "v5"] }; await rpc("workspace/ready", {}, "ready-v5"); for (const method of reads) { - await rpc(method, {project_id: "p"}, method); + await rpc(method, { project_id: "p" }, method); expect(sent.at(-1)!.params.input.expected_result_contract).toBe("v5"); } - await rpc("thread/read", {expected_result_contract: "v4"}, "explicit-v4"); + await rpc("thread/read", { expected_result_contract: "v4" }, "explicit-v4"); expect(sent.at(-1)!.params.input.expected_result_contract).toBe("v4"); - const command = {project_id: "p", thread_id: "t", contract_version: 2, - instruction: "Synthetic question", response_language: "en"}; + const command = { + project_id: "p", + thread_id: "t", + contract_version: 2, + instruction: "Synthetic question", + response_language: "en", + }; const originalCommand = structuredClone(command); await rpc("thread/input", command, "command"); expect(sent.at(-1)!.params.input).toEqual(originalCommand); expect(command).toEqual(originalCommand); - ready = {response_language_scope: "REQUEST"}; + ready = { response_language_scope: "REQUEST" }; await rpc("workspace/ready", {}, "ready-legacy"); for (const method of reads) { - await rpc(method, {project_id: "p", operation_id: "op"}, `legacy-${method}`); + await rpc(method, { project_id: "p", operation_id: "op" }, `legacy-${method}`); expect(sent.at(-1)!.params.input.expected_result_contract).toBe("v4"); } ready = {}; await rpc("workspace/ready", {}, "ready-old"); for (const method of reads) { - await rpc(method, {project_id: "p", operation_id: "op"}, `old-${method}`); + await rpc(method, { project_id: "p", operation_id: "op" }, `old-${method}`); expect(sent.at(-1)!.params.input).not.toHaveProperty("expected_result_contract"); } }); diff --git a/apps/web/src/api/history.test.ts b/apps/web/src/api/history.test.ts index 4fe3894a..0b306931 100644 --- a/apps/web/src/api/history.test.ts +++ b/apps/web/src/api/history.test.ts @@ -4,13 +4,37 @@ import { readHistoryEntry, readHistoryPage } from "./history"; const digest = "a".repeat(64); const scope = { projectId: "project-1", threadId: "thread-1", requestDigest: digest }; -const coverage = { visibility: "AUTHORIZED_SUBSET", association: "EXACT", scan: "COMPLETE_PAGE", reasons: [] }; -const envelope = (value: unknown) => new Response(JSON.stringify({ jsonrpc: "2.0", id: "test", result: { operation_id: "", state: "SUCCEEDED", value } }), { status: 200 }); -afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); +const coverage = { + visibility: "AUTHORIZED_SUBSET", + association: "EXACT", + scan: "COMPLETE_PAGE", + reasons: [], +}; +const envelope = (value: unknown) => + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: "test", + result: { operation_id: "", state: "SUCCEEDED", value }, + }), + { status: 200 }, + ); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); it("preserves an empty limited page and its continuation cursor", async () => { - const fetcher = vi.fn().mockResolvedValue(envelope({ contract_version: 2, items: [], next_cursor: "next-scanned-page", actor_scope_digest: digest, - capability: { restore: "READ_ONLY", apply_ready: false, reason_codes: [] }, coverage: { ...coverage, scan: "LIMITED" } })); + const fetcher = vi.fn().mockResolvedValue( + envelope({ + contract_version: 2, + items: [], + next_cursor: "next-scanned-page", + actor_scope_digest: digest, + capability: { restore: "READ_ONLY", apply_ready: false, reason_codes: [] }, + coverage: { ...coverage, scan: "LIMITED" }, + }), + ); vi.stubGlobal("fetch", fetcher); const page = await readHistoryPage(scope, null); expect(page.items).toEqual([]); @@ -20,7 +44,15 @@ it("preserves an empty limited page and its continuation cursor", async () => { }); it("never falls back to a journaling RPC when a readonly method is unsupported", async () => { - const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ jsonrpc: "2.0", id: "test", error: { code: -32601, message: "Method not found", data: {} } }))); + const fetcher = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: "test", + error: { code: -32601, message: "Method not found", data: {} }, + }), + ), + ); vi.stubGlobal("fetch", fetcher); await expect(readHistoryPage(scope, null)).rejects.toThrow(); expect(fetcher).toHaveBeenCalledTimes(1); @@ -28,18 +60,54 @@ it("never falls back to a journaling RPC when a readonly method is unsupported", }); it("rejects an answer belonging to a different request instead of displaying it", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(envelope({ contract_version: 2, project_id: "project-1", thread_id: "thread-1", - request_revision_digest: "b".repeat(64), operation_id: "op-1", request: {}, manifest: null, - result: { answer: "This is another request's answer" }, error: null, availability: "AVAILABLE", coverage, - basis_currentness: { state: "CURRENT", reasons: [], execution_eligible: false } }))); - await expect(readHistoryEntry({ kind: "result", scope, operationId: "op-1" })).rejects.toThrow(/연결/); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + envelope({ + contract_version: 2, + project_id: "project-1", + thread_id: "thread-1", + request_revision_digest: "b".repeat(64), + operation_id: "op-1", + request: {}, + manifest: null, + result: { answer: "This is another request's answer" }, + error: null, + availability: "AVAILABLE", + coverage, + basis_currentness: { state: "CURRENT", reasons: [], execution_eligible: false }, + }), + ), + ); + await expect(readHistoryEntry({ kind: "result", scope, operationId: "op-1" })).rejects.toThrow( + /연결/, + ); }); it("returns an unavailable historical answer without substituting current content", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(envelope({ contract_version: 2, project_id: "project-1", thread_id: "thread-1", - request_revision_digest: digest, operation_id: "op-1", request: {}, manifest: null, result: null, - error: null, availability: "UNAVAILABLE", coverage: { ...coverage, association: "PARTIAL" }, - basis_currentness: { state: "UNAVAILABLE", reasons: ["ACCESS_REVOKED"], execution_eligible: false } }))); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + envelope({ + contract_version: 2, + project_id: "project-1", + thread_id: "thread-1", + request_revision_digest: digest, + operation_id: "op-1", + request: {}, + manifest: null, + result: null, + error: null, + availability: "UNAVAILABLE", + coverage: { ...coverage, association: "PARTIAL" }, + basis_currentness: { + state: "UNAVAILABLE", + reasons: ["ACCESS_REVOKED"], + execution_eligible: false, + }, + }), + ), + ); const entry = await readHistoryEntry({ kind: "result", scope, operationId: "op-1" }); expect(entry.result).toBeNull(); expect(entry.availability).toBe("UNAVAILABLE"); diff --git a/apps/web/src/api/history.ts b/apps/web/src/api/history.ts index 27fa3dd5..cbd7e229 100644 --- a/apps/web/src/api/history.ts +++ b/apps/web/src/api/history.ts @@ -1,47 +1,122 @@ import { UiError } from "../i18n/UiError"; import { t } from "../i18n/locale"; import { rpc } from "./rpcClient"; -import { historyDetailSchema, historyPageSchema, historicalResultSchema, type WireHistoryItem } from "./historySchemas"; -import type { HistoryEntry, HistoryKind, HistoryPage, HistoryRow, HistoryScope, HistorySelection, RecordReference } from "./historyModels"; +import { + historyDetailSchema, + historyPageSchema, + historicalResultSchema, + type WireHistoryItem, +} from "./historySchemas"; +import type { + HistoryEntry, + HistoryKind, + HistoryPage, + HistoryRow, + HistoryScope, + HistorySelection, + RecordReference, +} from "./historyModels"; export function wireScope(scope: HistoryScope) { - return { project_id: scope.projectId, ...(scope.threadId ? { thread_id: scope.threadId } : {}), - ...(scope.requestDigest ? { request_revision_digest: scope.requestDigest } : {}) }; + return { + project_id: scope.projectId, + ...(scope.threadId ? { thread_id: scope.threadId } : {}), + ...(scope.requestDigest ? { request_revision_digest: scope.requestDigest } : {}), + }; } export function wireRecord(record: RecordReference) { - return { owner_kind: record.owner, project_id: record.projectId, immutable_id: record.id, revision_digest: record.digest }; + return { + owner_kind: record.owner, + project_id: record.projectId, + immutable_id: record.id, + revision_digest: record.digest, + }; } -async function query(method: string, input: Record, signal?: AbortSignal): Promise { +async function query( + method: string, + input: Record, + signal?: AbortSignal, +): Promise { const response = await rpc(method, input, crypto.randomUUID(), signal); if (response.state !== "SUCCEEDED") throw new UiError("api.historyIncomplete"); return response.value; } function rowOf(item: WireHistoryItem, scope: HistoryScope): HistoryRow { if (item.record_ref.project_id !== scope.projectId) throw new UiError("api.historyProject"); - const record: RecordReference = { owner: item.record_ref.owner_kind, projectId: item.record_ref.project_id, - id: item.record_ref.immutable_id, digest: item.record_ref.revision_digest, - entityType: item.entity_type ?? undefined, entityId: item.entity_id ?? undefined }; + const record: RecordReference = { + owner: item.record_ref.owner_kind, + projectId: item.record_ref.project_id, + id: item.record_ref.immutable_id, + digest: item.record_ref.revision_digest, + entityType: item.entity_type ?? undefined, + entityId: item.entity_id ?? undefined, + }; const origin = item.origin_request_revision_digest; - const links = item.scope_links.filter(link => link.association === "PRODUCED_IN" && link.request_revision_digest === origin); - const threads = new Set(links.map(link => link.thread_id)); - const thread = scope.threadId ? (threads.has(scope.threadId) ? scope.threadId : undefined) - : (threads.size === 1 ? links[0].thread_id : undefined); - const selection: HistorySelection = item.kind === "RESULT" && origin && thread - ? { kind: "result", scope: { ...scope, threadId: thread, requestDigest: origin }, operationId: item.operation_id ?? undefined, resultDigest: item.record_ref.revision_digest, record, - occurredAt: item.occurred_at, title: item.title } - : { kind: "record", scope, record }; - return { id: item.item_id, kind: item.kind, title: item.title, occurredAt: item.occurred_at, - association: item.association, membership: item.head_membership, availability: item.availability, - currentness: item.currentness, selection, completion: item.completion, phase: item.phase, - changeSummary: item.change_summary }; + const links = item.scope_links.filter( + (link) => link.association === "PRODUCED_IN" && link.request_revision_digest === origin, + ); + const threads = new Set(links.map((link) => link.thread_id)); + const thread = scope.threadId + ? threads.has(scope.threadId) + ? scope.threadId + : undefined + : threads.size === 1 + ? links[0].thread_id + : undefined; + const selection: HistorySelection = + item.kind === "RESULT" && origin && thread + ? { + kind: "result", + scope: { ...scope, threadId: thread, requestDigest: origin }, + operationId: item.operation_id ?? undefined, + resultDigest: item.record_ref.revision_digest, + record, + occurredAt: item.occurred_at, + title: item.title, + } + : { kind: "record", scope, record }; + return { + id: item.item_id, + kind: item.kind, + title: item.title, + occurredAt: item.occurred_at, + association: item.association, + membership: item.head_membership, + availability: item.availability, + currentness: item.currentness, + selection, + completion: item.completion, + phase: item.phase, + changeSummary: item.change_summary, + }; } -export async function readHistoryPage(scope: HistoryScope, cursor: string | null, signal?: AbortSignal, kinds?: HistoryKind[]): Promise { - const value = historyPageSchema.parse(await query("revision/timeline/read", { - project_id: scope.projectId, scope: wireScope(scope), cursor, limit: 25, contract_version: 2, ...(kinds ? { kinds } : {}), - }, signal)); - return { items: value.items.map(item => rowOf(item, scope)), nextCursor: value.next_cursor, - coverage: value.coverage, actorScope: value.actor_scope_digest }; +export async function readHistoryPage( + scope: HistoryScope, + cursor: string | null, + signal?: AbortSignal, + kinds?: HistoryKind[], +): Promise { + const value = historyPageSchema.parse( + await query( + "revision/timeline/read", + { + project_id: scope.projectId, + scope: wireScope(scope), + cursor, + limit: 25, + contract_version: 2, + ...(kinds ? { kinds } : {}), + }, + signal, + ), + ); + return { + items: value.items.map((item) => rowOf(item, scope)), + nextCursor: value.next_cursor, + coverage: value.coverage, + actorScope: value.actor_scope_digest, + }; } function questionOf(request: Record): string | null { @@ -51,16 +126,32 @@ function questionOf(request: Record): string | null { return null; } -export async function readHistoryEntry(selection: HistorySelection, signal?: AbortSignal): Promise { +export async function readHistoryEntry( + selection: HistorySelection, + signal?: AbortSignal, +): Promise { if (selection.kind === "result") { const scope = selection.scope; - const value = historicalResultSchema.parse(await query("thread/result/read", { - project_id: scope.projectId, thread_id: scope.threadId, request_revision_digest: scope.requestDigest, - ...(selection.resultDigest ? { result_revision_digest: selection.resultDigest } : {}), contract_version: 2, - }, signal)); - if (value.project_id !== scope.projectId || value.thread_id !== scope.threadId || value.request_revision_digest !== scope.requestDigest - || (selection.operationId && value.operation_id !== selection.operationId) - || (selection.resultDigest && value.result_revision_digest !== selection.resultDigest)) { + const value = historicalResultSchema.parse( + await query( + "thread/result/read", + { + project_id: scope.projectId, + thread_id: scope.threadId, + request_revision_digest: scope.requestDigest, + ...(selection.resultDigest ? { result_revision_digest: selection.resultDigest } : {}), + contract_version: 2, + }, + signal, + ), + ); + if ( + value.project_id !== scope.projectId || + value.thread_id !== scope.threadId || + value.request_revision_digest !== scope.requestDigest || + (selection.operationId && value.operation_id !== selection.operationId) || + (selection.resultDigest && value.result_revision_digest !== selection.resultDigest) + ) { throw new UiError("api.historyResult"); } const readable = value.availability !== "UNAVAILABLE"; @@ -68,34 +159,99 @@ export async function readHistoryEntry(selection: HistorySelection, signal?: Abo // A missing current-head projection must not hide an otherwise readable past answer. let comparisonEntry: HistoryEntry | null = null; if (readable && selection.record && selection.record.digest === value.result_revision_digest) { - try { comparisonEntry = await readHistoryEntry({ kind: "record", scope, record: selection.record }, signal); } - catch (error) { if (signal?.aborted) throw error; } + try { + comparisonEntry = await readHistoryEntry( + { kind: "record", scope, record: selection.record }, + signal, + ); + } catch (error) { + if (signal?.aborted) throw error; + } } - return { selection, title: selection.title ?? t("api.historyPair"), occurredAt: selection.occurredAt ?? (typeof value.request.created_at === "string" ? value.request.created_at : null), - kind: "RESULT", question: readable ? questionOf(value.request) : null, result: readable ? value.result : null, content: null, - executionState: value.operation_state ?? null, resultPhase: typeof value.manifest?.completion === "string" ? value.manifest.completion : null, - currentness: value.basis_currentness, availability: value.availability, reasons: value.coverage.reasons, - currentHead: comparisonEntry?.currentHead ?? null, restoreSelection: comparisonEntry?.restoreSelection ?? null, - capability: { restore: "READ_ONLY", applyReady: false, previewSupported: false, reasons: [] }, technical: readable ? value - : { project_id: value.project_id, thread_id: value.thread_id, availability: value.availability, coverage: value.coverage, basis_currentness: value.basis_currentness } }; + return { + selection, + title: selection.title ?? t("api.historyPair"), + occurredAt: + selection.occurredAt ?? + (typeof value.request.created_at === "string" ? value.request.created_at : null), + kind: "RESULT", + question: readable ? questionOf(value.request) : null, + result: readable ? value.result : null, + content: null, + executionState: value.operation_state ?? null, + resultPhase: + typeof value.manifest?.completion === "string" ? value.manifest.completion : null, + currentness: value.basis_currentness, + availability: value.availability, + reasons: value.coverage.reasons, + currentHead: comparisonEntry?.currentHead ?? null, + restoreSelection: comparisonEntry?.restoreSelection ?? null, + capability: { restore: "READ_ONLY", applyReady: false, previewSupported: false, reasons: [] }, + technical: readable + ? value + : { + project_id: value.project_id, + thread_id: value.thread_id, + availability: value.availability, + coverage: value.coverage, + basis_currentness: value.basis_currentness, + }, + }; } - const value = historyDetailSchema.parse(await query("revision/timeline/item/read", { - project_id: selection.scope.projectId, scope: wireScope(selection.scope), record_ref: wireRecord(selection.record), contract_version: 2, - }, signal)); + const value = historyDetailSchema.parse( + await query( + "revision/timeline/item/read", + { + project_id: selection.scope.projectId, + scope: wireScope(selection.scope), + record_ref: wireRecord(selection.record), + contract_version: 2, + }, + signal, + ), + ); const ref = value.item.record_ref; - if (ref.project_id !== selection.scope.projectId || ref.project_id !== selection.record.projectId || ref.owner_kind !== selection.record.owner - || ref.immutable_id !== selection.record.id || ref.revision_digest !== selection.record.digest) { + if ( + ref.project_id !== selection.scope.projectId || + ref.project_id !== selection.record.projectId || + ref.owner_kind !== selection.record.owner || + ref.immutable_id !== selection.record.id || + ref.revision_digest !== selection.record.digest + ) { throw new UiError("api.historySelection"); } const { item } = value; const head = value.current_head_digest ?? null; - const restoreSelection = ref.owner_kind === "SEMANTIC_REVISION" && item.entity_type && item.entity_id && head - ? { projectId: ref.project_id, entityType: item.entity_type, entityId: item.entity_id, targetDigest: ref.revision_digest, expectedHead: head } : null; - return { selection, title: item.title, occurredAt: item.occurred_at, kind: item.kind, - question: item.kind === "REQUEST" && value.content ? questionOf(value.content) : null, result: null, - content: item.availability === "UNAVAILABLE" ? null : value.content, currentness: item.currentness, - availability: item.availability, reasons: value.coverage.reasons, currentHead: head, restoreSelection, + const restoreSelection = + ref.owner_kind === "SEMANTIC_REVISION" && item.entity_type && item.entity_id && head + ? { + projectId: ref.project_id, + entityType: item.entity_type, + entityId: item.entity_id, + targetDigest: ref.revision_digest, + expectedHead: head, + } + : null; + return { + selection, + title: item.title, + occurredAt: item.occurred_at, + kind: item.kind, + question: item.kind === "REQUEST" && value.content ? questionOf(value.content) : null, + result: null, + content: item.availability === "UNAVAILABLE" ? null : value.content, + currentness: item.currentness, + availability: item.availability, + reasons: value.coverage.reasons, + currentHead: head, + restoreSelection, changeSummary: item.change_summary, - capability: { restore: item.capability.restore, applyReady: item.capability.apply_ready, previewSupported: item.capability.preview_supported, reasons: item.capability.reason_codes }, - technical: item.availability === "UNAVAILABLE" ? { ...value, content: null } : value }; + capability: { + restore: item.capability.restore, + applyReady: item.capability.apply_ready, + previewSupported: item.capability.preview_supported, + reasons: item.capability.reason_codes, + }, + technical: item.availability === "UNAVAILABLE" ? { ...value, content: null } : value, + }; } diff --git a/apps/web/src/api/historyComparison.ts b/apps/web/src/api/historyComparison.ts index a29a3eab..999864de 100644 --- a/apps/web/src/api/historyComparison.ts +++ b/apps/web/src/api/historyComparison.ts @@ -2,28 +2,76 @@ import { UiError } from "../i18n/UiError"; import { t } from "../i18n/locale"; import { z } from "zod"; import { rpc } from "./rpcClient"; -import { changeGroupSchema, coverageSchema, diffEntrySchema, digestSchema, type WireChange, type WireChangeGroup } from "./historySchemas"; +import { + changeGroupSchema, + coverageSchema, + diffEntrySchema, + digestSchema, + type WireChange, + type WireChangeGroup, +} from "./historySchemas"; import type { RevisionComparison, RestoreSelection } from "./historyModels"; -const comparisonSchema = z.object({ contract_version: z.literal(2), from_revision_digest: digestSchema, - to_revision_digest: digestSchema, diff: z.array(diffEntrySchema), summary_groups: z.array(changeGroupSchema), coverage: coverageSchema }); +const comparisonSchema = z.object({ + contract_version: z.literal(2), + from_revision_digest: digestSchema, + to_revision_digest: digestSchema, + diff: z.array(diffEntrySchema), + summary_groups: z.array(changeGroupSchema), + coverage: coverageSchema, +}); -export function mapComparison(changes: WireChange[], groups: WireChangeGroup[], coverage = "COMPLETE", reasons: string[] = []): RevisionComparison { - return { coverage, reasons, changes: changes.map(change => { - const group = groups.find(item => item.trace_paths.includes(change.path)); - return { path: change.path, before: change.before, after: change.after, - beforeMissing: !change.before_present, afterMissing: !change.after_present, - group: group?.kind ?? "OTHER", label: group?.summary ?? t("revision.content") }; - }) }; +export function mapComparison( + changes: WireChange[], + groups: WireChangeGroup[], + coverage = "COMPLETE", + reasons: string[] = [], +): RevisionComparison { + return { + coverage, + reasons, + changes: changes.map((change) => { + const group = groups.find((item) => item.trace_paths.includes(change.path)); + return { + path: change.path, + before: change.before, + after: change.after, + beforeMissing: !change.before_present, + afterMissing: !change.after_present, + group: group?.kind ?? "OTHER", + label: group?.summary ?? t("revision.content"), + }; + }), + }; } -export async function compareHistory(selection: RestoreSelection, signal?: AbortSignal): Promise { - const response = await rpc("revision/diff/read", { project_id: selection.projectId, - from_revision_digest: selection.targetDigest, to_revision_digest: selection.expectedHead, contract_version: 2 }, crypto.randomUUID(), signal); +export async function compareHistory( + selection: RestoreSelection, + signal?: AbortSignal, +): Promise { + const response = await rpc( + "revision/diff/read", + { + project_id: selection.projectId, + from_revision_digest: selection.targetDigest, + to_revision_digest: selection.expectedHead, + contract_version: 2, + }, + crypto.randomUUID(), + signal, + ); if (response.state !== "SUCCEEDED") throw new UiError("comparison.incomplete"); const value = comparisonSchema.parse(response.value); - if (value.from_revision_digest !== selection.targetDigest || value.to_revision_digest !== selection.expectedHead) { + if ( + value.from_revision_digest !== selection.targetDigest || + value.to_revision_digest !== selection.expectedHead + ) { throw new UiError("comparison.binding"); } - return mapComparison(value.diff, value.summary_groups, value.coverage.association === "EXACT" ? "COMPLETE" : "PARTIAL", value.coverage.reasons); + return mapComparison( + value.diff, + value.summary_groups, + value.coverage.association === "EXACT" ? "COMPLETE" : "PARTIAL", + value.coverage.reasons, + ); } diff --git a/apps/web/src/api/historyHttp.test.ts b/apps/web/src/api/historyHttp.test.ts index c9efddaf..7da3b9e5 100644 --- a/apps/web/src/api/historyHttp.test.ts +++ b/apps/web/src/api/historyHttp.test.ts @@ -9,40 +9,73 @@ const origin = process.env.THOTH_HISTORY_HTTP_ORIGIN; const descriptorPath = process.env.THOTH_HISTORY_HTTP_DESCRIPTOR; afterEach(() => vi.unstubAllGlobals()); -it.runIf(Boolean(origin && descriptorPath))("reads exact records, diff and blocked apply preview from the isolated Python HTTP server", async () => { - const target = new URL(origin!); - if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname)) throw new Error("This contract check only accepts an explicitly selected loopback fixture."); - const descriptor = JSON.parse(readFileSync(descriptorPath!, "utf8")); - if (descriptor.model !== "CONTROLLED_TEST_DOUBLE" || descriptor.apply_ready !== false) throw new Error("Expected the read-only controlled fixture descriptor."); - const calls: string[] = []; - const nativeFetch = globalThis.fetch; - vi.stubGlobal("fetch", async (url: RequestInfo | URL, init?: RequestInit) => { - if (String(url) !== "/rpc/query") throw new Error("HTTP verification forbids journaling and mutation transports."); - calls.push(JSON.parse(String(init?.body)).method); - return nativeFetch(new URL(String(url), target), init); - }); - const scope = { projectId: descriptor.project_id as string, threadId: descriptor.thread_id as string }; - const before = await rpc>("thread/read", { project_id: scope.projectId, thread_id: scope.threadId }, crypto.randomUUID()); - let page = await readHistoryPage(scope, null); - let item = page.items.find(row => row.selection.kind === "record" && row.selection.record.digest === descriptor.selection.target_revision_digest); - for (let index = 0; !item && page.nextCursor && index < 3; index += 1) { - page = await readHistoryPage(scope, page.nextCursor); - item = page.items.find(row => row.selection.kind === "record" && row.selection.record.digest === descriptor.selection.target_revision_digest); - } - if (!item) throw new Error("The fixture's historical hypothesis is not present in the authorized timeline pages."); - const entry = await readHistoryEntry(item.selection); - expect(entry.restoreSelection?.expectedHead).toBe(descriptor.selection.expected_current_head); - expect(entry.capability.previewSupported).toBe(true); - expect(entry.content).not.toBeNull(); - const comparison = await compareHistory(entry.restoreSelection!); - expect(comparison.changes.some(change => change.path.includes("statement") && change.before !== change.after)).toBe(true); - const preview = await readRestorePreview(entry.restoreSelection!); - expect(preview.principalScope).toBe(page.actorScope); - expect(preview.applyReady).toBe(false); - expect(preview.selection.targetDigest).toBe(descriptor.selection.target_revision_digest); - const after = await rpc>("thread/read", { project_id: scope.projectId, thread_id: scope.threadId }, crypto.randomUUID()); - expect(Array.isArray(before.value.model_dispatches)).toBe(true); - expect(after.value.model_dispatches).toEqual(before.value.model_dispatches); - expect(calls).not.toContain("revision/restore/apply"); - expect(calls).not.toContain("thread/input"); -}, 30000); +it.runIf(Boolean(origin && descriptorPath))( + "reads exact records, diff and blocked apply preview from the isolated Python HTTP server", + async () => { + const target = new URL(origin!); + if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname)) + throw new Error("This contract check only accepts an explicitly selected loopback fixture."); + const descriptor = JSON.parse(readFileSync(descriptorPath!, "utf8")); + if (descriptor.model !== "CONTROLLED_TEST_DOUBLE" || descriptor.apply_ready !== false) + throw new Error("Expected the read-only controlled fixture descriptor."); + const calls: string[] = []; + const nativeFetch = globalThis.fetch; + vi.stubGlobal("fetch", async (url: RequestInfo | URL, init?: RequestInit) => { + if (String(url) !== "/rpc/query") + throw new Error("HTTP verification forbids journaling and mutation transports."); + calls.push(JSON.parse(String(init?.body)).method); + return nativeFetch(new URL(String(url), target), init); + }); + const scope = { + projectId: descriptor.project_id as string, + threadId: descriptor.thread_id as string, + }; + const before = await rpc>( + "thread/read", + { project_id: scope.projectId, thread_id: scope.threadId }, + crypto.randomUUID(), + ); + let page = await readHistoryPage(scope, null); + let item = page.items.find( + (row) => + row.selection.kind === "record" && + row.selection.record.digest === descriptor.selection.target_revision_digest, + ); + for (let index = 0; !item && page.nextCursor && index < 3; index += 1) { + page = await readHistoryPage(scope, page.nextCursor); + item = page.items.find( + (row) => + row.selection.kind === "record" && + row.selection.record.digest === descriptor.selection.target_revision_digest, + ); + } + if (!item) + throw new Error( + "The fixture's historical hypothesis is not present in the authorized timeline pages.", + ); + const entry = await readHistoryEntry(item.selection); + expect(entry.restoreSelection?.expectedHead).toBe(descriptor.selection.expected_current_head); + expect(entry.capability.previewSupported).toBe(true); + expect(entry.content).not.toBeNull(); + const comparison = await compareHistory(entry.restoreSelection!); + expect( + comparison.changes.some( + (change) => change.path.includes("statement") && change.before !== change.after, + ), + ).toBe(true); + const preview = await readRestorePreview(entry.restoreSelection!); + expect(preview.principalScope).toBe(page.actorScope); + expect(preview.applyReady).toBe(false); + expect(preview.selection.targetDigest).toBe(descriptor.selection.target_revision_digest); + const after = await rpc>( + "thread/read", + { project_id: scope.projectId, thread_id: scope.threadId }, + crypto.randomUUID(), + ); + expect(Array.isArray(before.value.model_dispatches)).toBe(true); + expect(after.value.model_dispatches).toEqual(before.value.model_dispatches); + expect(calls).not.toContain("revision/restore/apply"); + expect(calls).not.toContain("thread/input"); + }, + 30000, +); diff --git a/apps/web/src/api/historyModels.ts b/apps/web/src/api/historyModels.ts index 8a4221a1..79afc4b6 100644 --- a/apps/web/src/api/historyModels.ts +++ b/apps/web/src/api/historyModels.ts @@ -10,7 +10,15 @@ export type RecordReference = { entityId?: string; }; export type HistorySelection = - | { kind: "result"; scope: HistoryScope & { threadId: string; requestDigest: string }; operationId?: string; resultDigest?: string; record?: RecordReference; occurredAt?: string; title?: string } + | { + kind: "result"; + scope: HistoryScope & { threadId: string; requestDigest: string }; + operationId?: string; + resultDigest?: string; + record?: RecordReference; + occurredAt?: string; + title?: string; + } | { kind: "record"; scope: HistoryScope; record: RecordReference }; export type Currentness = { state: string; reasons: string[] }; export type ChangeSummary = { @@ -63,7 +71,12 @@ export type HistoryEntry = { availability: string; reasons: string[]; currentHead: string | null; - capability: { restore: string; applyReady: boolean; previewSupported: boolean; reasons: string[] }; + capability: { + restore: string; + applyReady: boolean; + previewSupported: boolean; + reasons: string[]; + }; restoreSelection: RestoreSelection | null; changeSummary?: ChangeSummary | null; technical: unknown; @@ -101,11 +114,35 @@ export type RestoreResult = { }; export function selectionKey(selection: HistorySelection): string { - return JSON.stringify(selection.kind === "result" - ? [selection.kind, selection.scope.projectId, selection.scope.threadId, selection.scope.requestDigest, selection.operationId, selection.resultDigest, selection.record?.id] - : [selection.kind, selection.scope.projectId, selection.scope.threadId, selection.scope.requestDigest, selection.record.owner, selection.record.id, selection.record.digest]); + return JSON.stringify( + selection.kind === "result" + ? [ + selection.kind, + selection.scope.projectId, + selection.scope.threadId, + selection.scope.requestDigest, + selection.operationId, + selection.resultDigest, + selection.record?.id, + ] + : [ + selection.kind, + selection.scope.projectId, + selection.scope.threadId, + selection.scope.requestDigest, + selection.record.owner, + selection.record.id, + selection.record.digest, + ], + ); } export function restoreKey(selection: RestoreSelection): string { - return JSON.stringify([selection.projectId, selection.entityType, selection.entityId, selection.targetDigest, selection.expectedHead]); + return JSON.stringify([ + selection.projectId, + selection.entityType, + selection.entityId, + selection.targetDigest, + selection.expectedHead, + ]); } diff --git a/apps/web/src/api/historySchemas.ts b/apps/web/src/api/historySchemas.ts index eef4c3ce..8b30bbcb 100644 --- a/apps/web/src/api/historySchemas.ts +++ b/apps/web/src/api/historySchemas.ts @@ -4,64 +4,113 @@ export const digestSchema = z.string().regex(/^[a-f0-9]{64}$/i); export const contentSchema = z.record(z.unknown()); export const currentnessSchema = z.object({ state: z.enum(["CURRENT", "REVIEW_REQUIRED", "INVALIDATED", "UNKNOWN_BASIS", "UNAVAILABLE"]), - reasons: z.array(z.string()), execution_eligible: z.boolean(), + reasons: z.array(z.string()), + execution_eligible: z.boolean(), }); export const capabilitySchema = z.object({ restore: z.enum(["RESTORE_SUPPORTED", "READ_ONLY", "UNSUPPORTED"]), - apply_ready: z.boolean(), preview_supported: z.boolean().optional().default(false), reason_codes: z.array(z.string()), + apply_ready: z.boolean(), + preview_supported: z.boolean().optional().default(false), + reason_codes: z.array(z.string()), }); export const coverageSchema = z.object({ - visibility: z.literal("AUTHORIZED_SUBSET"), association: z.enum(["EXACT", "PARTIAL", "UNRESOLVED"]), - scan: z.enum(["COMPLETE_PAGE", "CONTINUATION", "LIMITED"]), reasons: z.array(z.string()), + visibility: z.literal("AUTHORIZED_SUBSET"), + association: z.enum(["EXACT", "PARTIAL", "UNRESOLVED"]), + scan: z.enum(["COMPLETE_PAGE", "CONTINUATION", "LIMITED"]), + reasons: z.array(z.string()), }); export const recordRefSchema = z.object({ - owner_kind: z.enum(["SEMANTIC_REVISION", "FULL_MEMORY"]), project_id: z.string(), - immutable_id: z.string(), revision_digest: digestSchema, + owner_kind: z.enum(["SEMANTIC_REVISION", "FULL_MEMORY"]), + project_id: z.string(), + immutable_id: z.string(), + revision_digest: digestSchema, }); export const changeSummarySchema = z.object({ - lines: z.array(z.object({ label: z.string(), before: z.string(), after: z.string() })), more: z.number().int().min(0).default(0), - flags: z.array(z.enum(["NUMBER", "NEGATION", "STATUS", "CITATION", "AUTHORITY", "TIME"])).default([]), text_diff_recommended: z.boolean().default(false), - other: z.number().int().min(0).default(0), technical: z.array(z.object({ label: z.string(), before: z.string(), after: z.string() })).default([]), + lines: z.array(z.object({ label: z.string(), before: z.string(), after: z.string() })), + more: z.number().int().min(0).default(0), + flags: z + .array(z.enum(["NUMBER", "NEGATION", "STATUS", "CITATION", "AUTHORITY", "TIME"])) + .default([]), + text_diff_recommended: z.boolean().default(false), + other: z.number().int().min(0).default(0), + technical: z + .array(z.object({ label: z.string(), before: z.string(), after: z.string() })) + .default([]), }); export const historyItemSchema = z.object({ - item_id: z.string(), kind: z.enum(["REQUEST", "RESULT", "REVISION", "RESTORE", "MEMORY"]), - occurred_at: z.string().datetime({ offset: true }), record_ref: recordRefSchema, - operation_id: z.string().nullable().optional(), origin_request_revision_digest: digestSchema.nullable().optional(), - scope_links: z.array(z.object({ thread_id: z.string(), request_revision_digest: digestSchema, - association: z.enum(["PRODUCED_IN", "USED_BY", "RELATED_OBJECT"]) })), + item_id: z.string(), + kind: z.enum(["REQUEST", "RESULT", "REVISION", "RESTORE", "MEMORY"]), + occurred_at: z.string().datetime({ offset: true }), + record_ref: recordRefSchema, + operation_id: z.string().nullable().optional(), + origin_request_revision_digest: digestSchema.nullable().optional(), + scope_links: z.array( + z.object({ + thread_id: z.string(), + request_revision_digest: digestSchema, + association: z.enum(["PRODUCED_IN", "USED_BY", "RELATED_OBJECT"]), + }), + ), association: z.enum(["PRODUCED_IN", "USED_BY", "RELATED_OBJECT", "UNATTRIBUTED"]), - head_membership: z.enum(["CURRENT", "ANCESTOR", "BRANCH", "UNKNOWN"]), title: z.string(), + head_membership: z.enum(["CURRENT", "ANCESTOR", "BRANCH", "UNKNOWN"]), + title: z.string(), availability: z.enum(["AVAILABLE", "PARTIAL", "UNAVAILABLE"]), - entity_type: z.string().nullable().optional(), entity_id: z.string().nullable().optional(), - schema_family: z.string().nullable().optional(), currentness: currentnessSchema, capability: capabilitySchema, - completion: z.enum(["CHECKPOINT", "TERMINAL"]).nullable().optional(), phase: z.string().nullable().optional(), + entity_type: z.string().nullable().optional(), + entity_id: z.string().nullable().optional(), + schema_family: z.string().nullable().optional(), + currentness: currentnessSchema, + capability: capabilitySchema, + completion: z.enum(["CHECKPOINT", "TERMINAL"]).nullable().optional(), + phase: z.string().nullable().optional(), change_summary: changeSummarySchema.nullable().optional(), }); export const historyPageSchema = z.object({ - contract_version: z.literal(2), items: z.array(historyItemSchema), coverage: coverageSchema, - next_cursor: z.string().nullable(), actor_scope_digest: digestSchema, capability: capabilitySchema, + contract_version: z.literal(2), + items: z.array(historyItemSchema), + coverage: coverageSchema, + next_cursor: z.string().nullable(), + actor_scope_digest: digestSchema, + capability: capabilitySchema, }); export const historyDetailSchema = z.object({ - contract_version: z.literal(2), item: historyItemSchema, content: contentSchema.nullable(), coverage: coverageSchema, + contract_version: z.literal(2), + item: historyItemSchema, + content: contentSchema.nullable(), + coverage: coverageSchema, current_head_digest: digestSchema.nullable().optional(), }); export const historicalResultSchema = z.object({ - contract_version: z.literal(2), project_id: z.string(), thread_id: z.string(), request_revision_digest: digestSchema, - operation_id: z.string(), result_revision_digest: digestSchema.nullable().optional(), request: contentSchema, - manifest: contentSchema.nullable(), result: contentSchema.nullable(), error: contentSchema.nullable(), - operation_state: z.string().nullable().optional(), availability: z.enum(["AVAILABLE", "PARTIAL", "UNAVAILABLE"]), - basis_currentness: currentnessSchema, coverage: coverageSchema, + contract_version: z.literal(2), + project_id: z.string(), + thread_id: z.string(), + request_revision_digest: digestSchema, + operation_id: z.string(), + result_revision_digest: digestSchema.nullable().optional(), + request: contentSchema, + manifest: contentSchema.nullable(), + result: contentSchema.nullable(), + error: contentSchema.nullable(), + operation_state: z.string().nullable().optional(), + availability: z.enum(["AVAILABLE", "PARTIAL", "UNAVAILABLE"]), + basis_currentness: currentnessSchema, + coverage: coverageSchema, // thread/result/read: how the server's read view of the result relates to the stored manifest. Absent from an older server. expected_result_view: z.string().nullable().optional(), shown_result: contentSchema.nullable().optional(), projected_keys: z.array(z.string()).nullable().optional(), }); export const diffEntrySchema = z.object({ - path: z.string(), before_present: z.boolean(), after_present: z.boolean(), before: z.unknown(), after: z.unknown(), + path: z.string(), + before_present: z.boolean(), + after_present: z.boolean(), + before: z.unknown(), + after: z.unknown(), }); export const changeGroupSchema = z.object({ kind: z.enum(["CONTENT", "EVIDENCE", "CONDITION", "STATUS", "IMPACT", "OTHER"]), - trace_paths: z.array(z.string()), changes: z.array(diffEntrySchema), summary: z.string(), + trace_paths: z.array(z.string()), + changes: z.array(diffEntrySchema), + summary: z.string(), }); export type WireHistoryItem = z.infer; export type WireChange = z.infer; diff --git a/apps/web/src/api/historyServerContract.test.ts b/apps/web/src/api/historyServerContract.test.ts index 5e4de9a2..d5969751 100644 --- a/apps/web/src/api/historyServerContract.test.ts +++ b/apps/web/src/api/historyServerContract.test.ts @@ -7,27 +7,86 @@ import { createHistoryFixtureTransport, historyFixtureIds as ids } from "./histo it("matches the actual Python request and response DTOs without creating a runtime", async () => { const fixture = createHistoryFixtureTransport({ applyReady: true }); - const selection = { project_id: ids.project, entity_type: "HYPOTHESIS", entity_id: "hypothesis:example-1", - target_revision_digest: ids.old, expected_current_head: ids.current }; + const selection = { + project_id: ids.project, + entity_type: "HYPOTHESIS", + entity_id: "hypothesis:example-1", + target_revision_digest: ids.old, + expected_current_head: ids.current, + }; const scope = { project_id: ids.project, thread_id: ids.thread }; const cases = [ - { method: "revision/timeline/read", input: { project_id: ids.project, scope, cursor: null, limit: 25, contract_version: 2 } }, - { method: "revision/timeline/item/read", input: { project_id: ids.project, scope, contract_version: 2, - record_ref: { owner_kind: "SEMANTIC_REVISION", project_id: ids.project, immutable_id: "revision:aaaaaaaa", revision_digest: ids.old } } }, - { method: "thread/result/read", input: { project_id: ids.project, thread_id: ids.thread, request_revision_digest: ids.request1, contract_version: 2 } }, - { method: "revision/diff/read", input: { project_id: ids.project, from_revision_digest: ids.old, to_revision_digest: ids.current, contract_version: 2 } }, - { method: "revision/restore/preview", input: { project_id: ids.project, selection, contract_version: 2 } }, - { method: "revision/restore/apply", input: { project_id: ids.project, selection, preview_basis_digest: ids.basis, reason: "contract fixture", contract_version: 2 } }, + { + method: "revision/timeline/read", + input: { project_id: ids.project, scope, cursor: null, limit: 25, contract_version: 2 }, + }, + { + method: "revision/timeline/item/read", + input: { + project_id: ids.project, + scope, + contract_version: 2, + record_ref: { + owner_kind: "SEMANTIC_REVISION", + project_id: ids.project, + immutable_id: "revision:aaaaaaaa", + revision_digest: ids.old, + }, + }, + }, + { + method: "thread/result/read", + input: { + project_id: ids.project, + thread_id: ids.thread, + request_revision_digest: ids.request1, + contract_version: 2, + }, + }, + { + method: "revision/diff/read", + input: { + project_id: ids.project, + from_revision_digest: ids.old, + to_revision_digest: ids.current, + contract_version: 2, + }, + }, + { + method: "revision/restore/preview", + input: { project_id: ids.project, selection, contract_version: 2 }, + }, + { + method: "revision/restore/apply", + input: { + project_id: ids.project, + selection, + preview_basis_digest: ids.basis, + reason: "contract fixture", + contract_version: 2, + }, + }, ]; const captured = []; for (const item of cases) { - const response = await fixture.fetch("/fixture-only", { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", id: "fixture", method: item.method, - params: { input: item.input, _meta: { idempotencyKey: item.method } } }) }); + const response = await fixture.fetch("/fixture-only", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + id: "fixture", + method: item.method, + params: { input: item.input, _meta: { idempotencyKey: item.method } }, + }), + }); captured.push({ ...item, value: (await response.json()).result.value }); } const repository = fileURLToPath(new URL("../../../../", import.meta.url)); - const python = [join(repository, ".venv", "Scripts", "python.exe"), join(repository, ".venv", "bin", "python")].find(existsSync); - if (!python) throw new Error("Web wire contract validation requires the repository Python environment."); + const python = [ + join(repository, ".venv", "Scripts", "python.exe"), + join(repository, ".venv", "bin", "python"), + ].find(existsSync); + if (!python) + throw new Error("Web wire contract validation requires the repository Python environment."); const source = [ "import json, sys", "from thoth.domain.research_history import HistoryTimelineInput, HistoryPage, HistoryItemInput, HistoryDetail, HistoricalResultInput, HistoricalResultView, HistoryDiffInput, HistoryDiff", @@ -39,7 +98,15 @@ it("matches the actual Python request and response DTOs without creating a runti " request.model_validate(item['input']); response.model_validate(item['value'])", "print(json.dumps({'validated_methods':[item['method'] for item in items]}))", ].join("\n"); - const result = JSON.parse(execFileSync(python, ["-c", source], { shell: false, encoding: "utf8", input: JSON.stringify(captured), - cwd: repository, timeout: 20000, env: { ...process.env, PYTHONPATH: join(repository, "src"), PYTHONDONTWRITEBYTECODE: "1" } })); - expect(result.validated_methods).toEqual(cases.map(item => item.method)); + const result = JSON.parse( + execFileSync(python, ["-c", source], { + shell: false, + encoding: "utf8", + input: JSON.stringify(captured), + cwd: repository, + timeout: 20000, + env: { ...process.env, PYTHONPATH: join(repository, "src"), PYTHONDONTWRITEBYTECODE: "1" }, + }), + ); + expect(result.validated_methods).toEqual(cases.map((item) => item.method)); }); diff --git a/apps/web/src/api/historyTestFixture.ts b/apps/web/src/api/historyTestFixture.ts index 3391a32a..49814d6b 100644 --- a/apps/web/src/api/historyTestFixture.ts +++ b/apps/web/src/api/historyTestFixture.ts @@ -1,10 +1,26 @@ /** Explicit synthetic wire fixture, used only by tests and the DEV history example. */ -export const historyFixtureIds = { project: "history-example", thread: "session-example", actor: "e".repeat(64), - request1: "1".repeat(64), result1: "2".repeat(64), request2: "3".repeat(64), result2: "4".repeat(64), - old: "a".repeat(64), current: "b".repeat(64), restored: "c".repeat(64), basis: "d".repeat(64) }; +export const historyFixtureIds = { + project: "history-example", + thread: "session-example", + actor: "e".repeat(64), + request1: "1".repeat(64), + result1: "2".repeat(64), + request2: "3".repeat(64), + result2: "4".repeat(64), + old: "a".repeat(64), + current: "b".repeat(64), + restored: "c".repeat(64), + basis: "d".repeat(64), +}; type FixtureInput = Record; -type FixtureRequest = { method: string; id: string; params: { input: FixtureInput; _meta: { idempotencyKey: string } } }; -export function createHistoryFixtureTransport(options: { applyReady?: boolean; loseApplyResponse?: boolean } = {}) { +type FixtureRequest = { + method: string; + id: string; + params: { input: FixtureInput; _meta: { idempotencyKey: string } }; +}; +export function createHistoryFixtureTransport( + options: { applyReady?: boolean; loseApplyResponse?: boolean } = {}, +) { const ids = historyFixtureIds; const calls: { method: string; input: FixtureInput; key: string; url: string }[] = []; let head = ids.current; @@ -12,98 +28,331 @@ export function createHistoryFixtureTransport(options: { applyReady?: boolean; l let appliedCount = 0; const completed = new Map(); const ready = options.applyReady ?? false; - const capability = { restore: ready ? "RESTORE_SUPPORTED" : "READ_ONLY", preview_supported: true, apply_ready: ready, reason_codes: ready ? [] : ["RESTORE_APPLY_NOT_READY"] }; - const coverage = { visibility: "AUTHORIZED_SUBSET", association: "EXACT", scan: "COMPLETE_PAGE", reasons: [] }; + const capability = { + restore: ready ? "RESTORE_SUPPORTED" : "READ_ONLY", + preview_supported: true, + apply_ready: ready, + reason_codes: ready ? [] : ["RESTORE_APPLY_NOT_READY"], + }; + const coverage = { + visibility: "AUTHORIZED_SUBSET", + association: "EXACT", + scan: "COMPLETE_PAGE", + reasons: [], + }; const currentness = (state = "CURRENT") => ({ state, reasons: [], execution_eligible: false }); - const titles: Record = { [ids.old]: "연결 자료에서 초기 가설을 정리했습니다", [ids.current]: "추가 근거에 맞춰 가설의 적용 조건을 좁혔습니다", [ids.restored]: "과거 가설 내용을 새 버전으로 적용했습니다" }; - const content = (digest: string) => ({ statement: digest === ids.current - ? "현재 연결 자료만으로 원인을 확정할 수 없어 추가 비교가 필요합니다." - : "관측 조건의 차이가 결과에 영향을 주었을 가능성을 검토합니다.", - uncertainty: "아직 독립적인 검증 결과가 없습니다.", counterevidence_queries: ["같은 조건에서도 차이가 유지되는가?"] }); - const ref = (digest: string) => ({ owner_kind: "SEMANTIC_REVISION", project_id: ids.project, - immutable_id: `revision:${digest.slice(0, 8)}`, revision_digest: digest }); - const revisionItem = (digest: string, hour: string) => ({ item_id: `hypothesis:${digest}`, kind: digest === ids.restored ? "RESTORE" : "REVISION", - occurred_at: `2026-09-21T${hour}:00+09:00`, record_ref: ref(digest), operation_id: null, - origin_request_revision_digest: ids.request1, scope_links: [{ thread_id: ids.thread, request_revision_digest: ids.request1, association: "PRODUCED_IN" }], - association: "PRODUCED_IN", head_membership: head === digest ? "CURRENT" : "ANCESTOR", title: titles[digest], availability: "AVAILABLE", - entity_type: "HYPOTHESIS", entity_id: "hypothesis:example-1", schema_family: "HypothesisRecord", - currentness: currentness(head === ids.restored ? "REVIEW_REQUIRED" : head === digest ? "CURRENT" : "REVIEW_REQUIRED"), capability }); - const resultItem = (second: boolean) => ({ ...revisionItem(second ? ids.current : ids.old, second ? "14:35" : "14:02"), - item_id: second ? "result-2" : "result-1", kind: "RESULT", title: second ? "추가 질문에 대한 답변" : "첫 질문에 대한 답변", - record_ref: ref(second ? ids.result2 : ids.result1), origin_request_revision_digest: second ? ids.request2 : ids.request1, - operation_id: second ? "operation-2" : "operation-1", entity_type: "DECISION_OBJECT", entity_id: "result:example", - scope_links: [{ thread_id: ids.thread, request_revision_digest: second ? ids.request2 : ids.request1, association: "PRODUCED_IN" }], + const titles: Record = { + [ids.old]: "연결 자료에서 초기 가설을 정리했습니다", + [ids.current]: "추가 근거에 맞춰 가설의 적용 조건을 좁혔습니다", + [ids.restored]: "과거 가설 내용을 새 버전으로 적용했습니다", + }; + const content = (digest: string) => ({ + statement: + digest === ids.current + ? "현재 연결 자료만으로 원인을 확정할 수 없어 추가 비교가 필요합니다." + : "관측 조건의 차이가 결과에 영향을 주었을 가능성을 검토합니다.", + uncertainty: "아직 독립적인 검증 결과가 없습니다.", + counterevidence_queries: ["같은 조건에서도 차이가 유지되는가?"], + }); + const ref = (digest: string) => ({ + owner_kind: "SEMANTIC_REVISION", + project_id: ids.project, + immutable_id: `revision:${digest.slice(0, 8)}`, + revision_digest: digest, + }); + const revisionItem = (digest: string, hour: string) => ({ + item_id: `hypothesis:${digest}`, + kind: digest === ids.restored ? "RESTORE" : "REVISION", + occurred_at: `2026-09-21T${hour}:00+09:00`, + record_ref: ref(digest), + operation_id: null, + origin_request_revision_digest: ids.request1, + scope_links: [ + { thread_id: ids.thread, request_revision_digest: ids.request1, association: "PRODUCED_IN" }, + ], + association: "PRODUCED_IN", + head_membership: head === digest ? "CURRENT" : "ANCESTOR", + title: titles[digest], + availability: "AVAILABLE", + entity_type: "HYPOTHESIS", + entity_id: "hypothesis:example-1", + schema_family: "HypothesisRecord", + currentness: currentness( + head === ids.restored ? "REVIEW_REQUIRED" : head === digest ? "CURRENT" : "REVIEW_REQUIRED", + ), + capability, + }); + const resultItem = (second: boolean) => ({ + ...revisionItem(second ? ids.current : ids.old, second ? "14:35" : "14:02"), + item_id: second ? "result-2" : "result-1", + kind: "RESULT", + title: second ? "추가 질문에 대한 답변" : "첫 질문에 대한 답변", + record_ref: ref(second ? ids.result2 : ids.result1), + origin_request_revision_digest: second ? ids.request2 : ids.request1, + operation_id: second ? "operation-2" : "operation-1", + entity_type: "DECISION_OBJECT", + entity_id: "result:example", + scope_links: [ + { + thread_id: ids.thread, + request_revision_digest: second ? ids.request2 : ids.request1, + association: "PRODUCED_IN", + }, + ], currentness: currentness(head === ids.restored ? "REVIEW_REQUIRED" : "UNKNOWN_BASIS"), - capability: { restore: "READ_ONLY", apply_ready: false, reason_codes: [] } }); - const diff = (before: string, after: string) => before === after ? [] : [{ path: "/statement", before_present: true, after_present: true, before, after }]; - const groups = (changes: ReturnType) => changes.length ? [{ kind: "CONTENT", trace_paths: changes.map(item => item.path), changes, summary: "저장된 설명이 바뀌었습니다" }] : []; - const answer = (second: boolean) => second ? "두 번째 답변: 측정 조건과 원문 근거를 먼저 비교하세요." : "첫 번째 답변: 관측 조건의 차이를 가설로 두고 반대 근거를 확인하세요."; - const impact = { stale_refs: ["hypothesis:example-1"], invalidated_refs: [], recalculate_refs: ["action-plan:example", "result:example"] }; + capability: { restore: "READ_ONLY", apply_ready: false, reason_codes: [] }, + }); + const diff = (before: string, after: string) => + before === after + ? [] + : [{ path: "/statement", before_present: true, after_present: true, before, after }]; + const groups = (changes: ReturnType) => + changes.length + ? [ + { + kind: "CONTENT", + trace_paths: changes.map((item) => item.path), + changes, + summary: "저장된 설명이 바뀌었습니다", + }, + ] + : []; + const answer = (second: boolean) => + second + ? "두 번째 답변: 측정 조건과 원문 근거를 먼저 비교하세요." + : "첫 번째 답변: 관측 조건의 차이를 가설로 두고 반대 근거를 확인하세요."; + const impact = { + stale_refs: ["hypothesis:example-1"], + invalidated_refs: [], + recalculate_refs: ["action-plan:example", "result:example"], + }; async function handle(request: FixtureRequest): Promise { const input = request.params.input; - if (request.method === "revision/timeline/read") return { contract_version: 2, actor_scope_digest: ids.actor, capability, coverage, next_cursor: null, - items: [...(head === ids.restored ? [revisionItem(head, "14:45")] : []), revisionItem(ids.current, "14:40"), resultItem(true), revisionItem(ids.old, "14:05"), resultItem(false)] }; - if (request.method === "project/review/list") return { schema_version: "1.0.0", contract_version: 2, project_id: ids.project, coverage: "COMPLETE_PAGE", next_cursor: null, - unread_supported: false, assignment_supported: false, items: [ - { item_id: "review-result-2", project_id: ids.project, thread_id: ids.thread, request_revision_digest: ids.request2, result_revision_digest: ids.result2, - title: "추가 질문에 대한 답변", priority: "HIGH", reason_codes: ["UNKNOWN_BASIS"], currentness: currentness("REVIEW_REQUIRED"), - next_user_action: { schema_version: "1.0.0", action_type: "REVIEW_CURRENTNESS", label: "현재 자료 기준으로 다시 확인", reason_codes: ["UNKNOWN_BASIS"], requires_permission: false, target: null, basis: {} } }, - ] }; - if (request.method === "thread/result/compare/read") return { schema_version: "1.0.0", contract_version: 2, - project_id: ids.project, thread_id: ids.thread, before: input.before, after: input.after, state: "CHANGED", - reason_state: "UNKNOWN_REASON", reason_codes: [], reason_refs: [], - basis_currentness: { before: currentness("UNKNOWN_BASIS"), after: currentness("REVIEW_REQUIRED") }, - groups: [{ kind: "CONTENT", trace_paths: ["/result/answer"], changes: [{ path: "/result/answer", - before_present: true, after_present: true, before: answer(false), after: answer(true) }] }] }; + if (request.method === "revision/timeline/read") + return { + contract_version: 2, + actor_scope_digest: ids.actor, + capability, + coverage, + next_cursor: null, + items: [ + ...(head === ids.restored ? [revisionItem(head, "14:45")] : []), + revisionItem(ids.current, "14:40"), + resultItem(true), + revisionItem(ids.old, "14:05"), + resultItem(false), + ], + }; + if (request.method === "project/review/list") + return { + schema_version: "1.0.0", + contract_version: 2, + project_id: ids.project, + coverage: "COMPLETE_PAGE", + next_cursor: null, + unread_supported: false, + assignment_supported: false, + items: [ + { + item_id: "review-result-2", + project_id: ids.project, + thread_id: ids.thread, + request_revision_digest: ids.request2, + result_revision_digest: ids.result2, + title: "추가 질문에 대한 답변", + priority: "HIGH", + reason_codes: ["UNKNOWN_BASIS"], + currentness: currentness("REVIEW_REQUIRED"), + next_user_action: { + schema_version: "1.0.0", + action_type: "REVIEW_CURRENTNESS", + label: "현재 자료 기준으로 다시 확인", + reason_codes: ["UNKNOWN_BASIS"], + requires_permission: false, + target: null, + basis: {}, + }, + }, + ], + }; + if (request.method === "thread/result/compare/read") + return { + schema_version: "1.0.0", + contract_version: 2, + project_id: ids.project, + thread_id: ids.thread, + before: input.before, + after: input.after, + state: "CHANGED", + reason_state: "UNKNOWN_REASON", + reason_codes: [], + reason_refs: [], + basis_currentness: { + before: currentness("UNKNOWN_BASIS"), + after: currentness("REVIEW_REQUIRED"), + }, + groups: [ + { + kind: "CONTENT", + trace_paths: ["/result/answer"], + changes: [ + { + path: "/result/answer", + before_present: true, + after_present: true, + before: answer(false), + after: answer(true), + }, + ], + }, + ], + }; if (request.method === "revision/timeline/item/read") { const record = input.record_ref as { revision_digest: string }; - if ([ids.result1, ids.result2].includes(record.revision_digest)) return { contract_version: 2, item: resultItem(record.revision_digest === ids.result2), - content: { answer: answer(record.revision_digest === ids.result2) }, current_head_digest: ids.result2, coverage }; - return { contract_version: 2, item: revisionItem(record.revision_digest, "14:05"), content: content(record.revision_digest), current_head_digest: head, coverage }; + if ([ids.result1, ids.result2].includes(record.revision_digest)) + return { + contract_version: 2, + item: resultItem(record.revision_digest === ids.result2), + content: { answer: answer(record.revision_digest === ids.result2) }, + current_head_digest: ids.result2, + coverage, + }; + return { + contract_version: 2, + item: revisionItem(record.revision_digest, "14:05"), + content: content(record.revision_digest), + current_head_digest: head, + coverage, + }; } if (request.method === "thread/result/read") { const second = input.request_revision_digest === ids.request2; - return { contract_version: 2, project_id: ids.project, thread_id: ids.thread, - request_revision_digest: second ? ids.request2 : ids.request1, operation_id: second ? "operation-2" : "operation-1", + return { + contract_version: 2, + project_id: ids.project, + thread_id: ids.thread, + request_revision_digest: second ? ids.request2 : ids.request1, + operation_id: second ? "operation-2" : "operation-1", result_revision_digest: second ? ids.result2 : ids.result1, - request: { authored_text: second ? "조건을 같게 비교하려면 무엇을 확인해야 하나요?" : "결과가 달라진 이유를 어떻게 조사할까요?", created_at: "2026-09-21T14:00:00+09:00" }, - manifest: null, result: { answer: answer(second) }, - error: null, operation_state: "SUCCEEDED", availability: "AVAILABLE", basis_currentness: currentness(head === ids.restored ? "REVIEW_REQUIRED" : "UNKNOWN_BASIS"), coverage }; + request: { + authored_text: second + ? "조건을 같게 비교하려면 무엇을 확인해야 하나요?" + : "결과가 달라진 이유를 어떻게 조사할까요?", + created_at: "2026-09-21T14:00:00+09:00", + }, + manifest: null, + result: { answer: answer(second) }, + error: null, + operation_state: "SUCCEEDED", + availability: "AVAILABLE", + basis_currentness: currentness(head === ids.restored ? "REVIEW_REQUIRED" : "UNKNOWN_BASIS"), + coverage, + }; } if (request.method === "revision/diff/read") { const resultDiff = [ids.result1, ids.result2].includes(String(input.from_revision_digest)); - const changes = resultDiff ? diff(answer(input.from_revision_digest === ids.result2), answer(input.to_revision_digest === ids.result2)).map(item => ({ ...item, path: "/result/answer" })) - : diff(content(String(input.from_revision_digest)).statement, content(String(input.to_revision_digest)).statement); - return { contract_version: 2, from_revision_digest: input.from_revision_digest, to_revision_digest: input.to_revision_digest, diff: changes, summary_groups: groups(changes), coverage }; + const changes = resultDiff + ? diff( + answer(input.from_revision_digest === ids.result2), + answer(input.to_revision_digest === ids.result2), + ).map((item) => ({ ...item, path: "/result/answer" })) + : diff( + content(String(input.from_revision_digest)).statement, + content(String(input.to_revision_digest)).statement, + ); + return { + contract_version: 2, + from_revision_digest: input.from_revision_digest, + to_revision_digest: input.to_revision_digest, + diff: changes, + summary_groups: groups(changes), + coverage, + }; } if (request.method === "revision/restore/preview") { const selection = input.selection as FixtureInput; - const changes = diff(content(head).statement, content(String(selection.target_revision_digest)).statement); - return { contract_version: 2, selection, profile_id: "hypothesis.v1", availability: changes.length ? "AVAILABLE" : "NO_CHANGE", - reason_codes: [], capability, actor_scope_digest: ids.actor, basis_digest: ids.basis, impact, diff: changes, - summary_groups: groups(changes), source_drift: [], reanalysis: "NOT_REQUESTED", external_effects: "PRESERVED" }; + const changes = diff( + content(head).statement, + content(String(selection.target_revision_digest)).statement, + ); + return { + contract_version: 2, + selection, + profile_id: "hypothesis.v1", + availability: changes.length ? "AVAILABLE" : "NO_CHANGE", + reason_codes: [], + capability, + actor_scope_digest: ids.actor, + basis_digest: ids.basis, + impact, + diff: changes, + summary_groups: groups(changes), + source_drift: [], + reanalysis: "NOT_REQUESTED", + external_effects: "PRESERVED", + }; } if (request.method === "revision/restore/apply") { const key = request.params._meta.idempotencyKey; if (completed.has(key)) return completed.get(key); if (!ready) throw new Error("RESTORE_APPLY_NOT_READY"); - appliedCount += 1; head = ids.restored; - const result = { contract_version: 2, status: "APPLIED", selection: input.selection, new_revision_digest: head, - new_revision_id: "revision-restored", receipt_id: "receipt-example", receipt_digest: ids.basis, - restore_audit_id: "audit-example", impact, reanalysis: "NOT_REQUESTED", external_effects: "PRESERVED" }; + appliedCount += 1; + head = ids.restored; + const result = { + contract_version: 2, + status: "APPLIED", + selection: input.selection, + new_revision_digest: head, + new_revision_id: "revision-restored", + receipt_id: "receipt-example", + receipt_digest: ids.basis, + restore_audit_id: "audit-example", + impact, + reanalysis: "NOT_REQUESTED", + external_effects: "PRESERVED", + }; completed.set(key, result); - if (options.loseApplyResponse && !lost) { lost = true; throw new TypeError("검증용 응답 유실"); } + if (options.loseApplyResponse && !lost) { + lost = true; + throw new TypeError("검증용 응답 유실"); + } return result; } - if (request.method === "operation/result/read") return { operation_id: input.operation_id, state: "SUCCEEDED", result: [...completed.values()][0] ?? null, error: null }; + if (request.method === "operation/result/read") + return { + operation_id: input.operation_id, + state: "SUCCEEDED", + result: [...completed.values()][0] ?? null, + error: null, + }; throw new Error(`Unsupported fixture method: ${request.method}`); } const fetcher: typeof fetch = async (url, init) => { const request = JSON.parse(String(init?.body)) as FixtureRequest; - calls.push({ method: request.method, input: request.params.input, key: request.params._meta.idempotencyKey, url: String(url) }); + calls.push({ + method: request.method, + input: request.params.input, + key: request.params._meta.idempotencyKey, + url: String(url), + }); const value = await handle(request); - return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, - result: { operation_id: request.method === "revision/restore/apply" ? "operation-restore" : "", state: "SUCCEEDED", value } })); + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: { + operation_id: request.method === "revision/restore/apply" ? "operation-restore" : "", + state: "SUCCEEDED", + value, + }, + }), + ); + }; + return { + fetch: fetcher, + calls, + get appliedCount() { + return appliedCount; + }, }; - return { fetch: fetcher, calls, get appliedCount() { return appliedCount; } }; } diff --git a/apps/web/src/api/hypothesisLink.ts b/apps/web/src/api/hypothesisLink.ts index d35c3c56..91ec8c79 100644 --- a/apps/web/src/api/hypothesisLink.ts +++ b/apps/web/src/api/hypothesisLink.ts @@ -1,24 +1,73 @@ import { rpc } from "./rpcClient"; export type RecheckReasonCode = "UNRELATED" | "STILL_MATCHES" | "NEEDS_RESEARCH" | "OTHER"; -export type LinkRecheck = { reason_code: RecheckReasonCode; note: string; actor_id: string; created_at: string; flipped: boolean }; +export type LinkRecheck = { + reason_code: RecheckReasonCode; + note: string; + actor_id: string; + created_at: string; + flipped: boolean; +}; /** A hypothesis that came from a trace row, read against that row's verdict today. */ export type HypothesisLink = { - hypothesis_id: string; hypothesis_revision_digest: string; statement: string; subject_kind: "CRITERION" | "REQUIREMENT"; subject_id: string; subject_title: string; - state: "CURRENT" | "STALE" | "RECHECKED"; change: "CONTENT_CHANGED" | "EVIDENCE_ONLY" | "FLIPPED" | "SUBJECT_MISSING" | null; - link_state: string | null; current_state: string | null; current_verdict_revision: string | null; recheck: LinkRecheck | null; + hypothesis_id: string; + hypothesis_revision_digest: string; + statement: string; + subject_kind: "CRITERION" | "REQUIREMENT"; + subject_id: string; + subject_title: string; + state: "CURRENT" | "STALE" | "RECHECKED"; + change: "CONTENT_CHANGED" | "EVIDENCE_ONLY" | "FLIPPED" | "SUBJECT_MISSING" | null; + link_state: string | null; + current_state: string | null; + current_verdict_revision: string | null; + recheck: LinkRecheck | null; +}; +export type ReasonDistribution = { + total: number; + flipped: number; + by_reason: Partial>; +}; +export type HypothesisLinks = { + trace_digest: string | null; + links: HypothesisLink[]; + reason_distribution: ReasonDistribution; }; -export type ReasonDistribution = { total: number; flipped: number; by_reason: Partial> }; -export type HypothesisLinks = { trace_digest: string | null; links: HypothesisLink[]; reason_distribution: ReasonDistribution }; export const hypothesisLinksKey = (projectId: string) => ["hypothesis-links", projectId] as const; -export async function listHypothesisLinks(projectId: string, signal?: AbortSignal): Promise { - return (await rpc("hypothesis/link/list", { project_id: projectId }, crypto.randomUUID(), signal)).value; +export async function listHypothesisLinks( + projectId: string, + signal?: AbortSignal, +): Promise { + return ( + await rpc( + "hypothesis/link/list", + { project_id: projectId }, + crypto.randomUUID(), + signal, + ) + ).value; } -export async function recheckHypothesisLinks(projectId: string, hypothesisIds: string[], reason: RecheckReasonCode, note: string, currentVerdictRevision: string | null) { - return (await rpc("hypothesis/link/recheck", { - project_id: projectId, hypothesis_ids: hypothesisIds, reason_code: reason, note, current_verdict_revision: currentVerdictRevision, - }, crypto.randomUUID())).value; +export async function recheckHypothesisLinks( + projectId: string, + hypothesisIds: string[], + reason: RecheckReasonCode, + note: string, + currentVerdictRevision: string | null, +) { + return ( + await rpc( + "hypothesis/link/recheck", + { + project_id: projectId, + hypothesis_ids: hypothesisIds, + reason_code: reason, + note, + current_verdict_revision: currentVerdictRevision, + }, + crypto.randomUUID(), + ) + ).value; } diff --git a/apps/web/src/api/hypothesisSame.ts b/apps/web/src/api/hypothesisSame.ts index 7b9c09b7..974caa27 100644 --- a/apps/web/src/api/hypothesisSame.ts +++ b/apps/web/src/api/hypothesisSame.ts @@ -1,19 +1,54 @@ import { rpc } from "./rpcClient"; /** What a screen shows about a hypothesis a person marked as the same as another; empty for one that cannot be read. */ -export type SameMember = { statement?: string; investigated_at?: string; subject_kind?: string; subject_id?: string }; +export type SameMember = { + statement?: string; + investigated_at?: string; + subject_kind?: string; + subject_id?: string; +}; /** The groups of hypotheses people marked as the same one across investigations (each with at least two ids). */ -export type SameView = { groups: string[][]; pairs: string[][]; members: Record; events: unknown[] }; +export type SameView = { + groups: string[][]; + pairs: string[][]; + members: Record; + events: unknown[]; +}; export const sameKey = (projectId: string) => ["hypothesis-same", projectId] as const; /** An answer that lacks a part reads as no group, so a screen never fails on a short or unexpected answer. */ -const whole = (value: Partial | undefined): SameView => ({ groups: value?.groups ?? [], pairs: value?.pairs ?? [], members: value?.members ?? {}, events: value?.events ?? [] }); +const whole = (value: Partial | undefined): SameView => ({ + groups: value?.groups ?? [], + pairs: value?.pairs ?? [], + members: value?.members ?? {}, + events: value?.events ?? [], +}); export async function listSame(projectId: string, signal?: AbortSignal) { - return whole((await rpc("hypothesis/same/list", { project_id: projectId }, crypto.randomUUID(), signal)).value); + return whole( + ( + await rpc( + "hypothesis/same/list", + { project_id: projectId }, + crypto.randomUUID(), + signal, + ) + ).value, + ); } -export async function recordSame(projectId: string, hypothesisIds: [string, string], action: "LINK" | "UNLINK", note: string) { - return (await rpc("hypothesis/same/record", { project_id: projectId, hypothesis_ids: hypothesisIds, action, note }, crypto.randomUUID())).value; +export async function recordSame( + projectId: string, + hypothesisIds: [string, string], + action: "LINK" | "UNLINK", + note: string, +) { + return ( + await rpc( + "hypothesis/same/record", + { project_id: projectId, hypothesis_ids: hypothesisIds, action, note }, + crypto.randomUUID(), + ) + ).value; } diff --git a/apps/web/src/api/judgmentRecords.ts b/apps/web/src/api/judgmentRecords.ts index 22da46cb..ace77d30 100644 --- a/apps/web/src/api/judgmentRecords.ts +++ b/apps/web/src/api/judgmentRecords.ts @@ -2,9 +2,22 @@ import { rpc } from "./rpcClient"; export type MatchKind = "THIS_HYPOTHESIS" | "ALTERNATIVE" | "NEITHER" | "UNDETERMINED"; /** What a person recorded for one test: the latest result is the one that counts. */ -export type TestResult = { event_id: string; hypothesis_id: string; test_id: string; observation: string; matched: MatchKind; evidence_refs: string[]; actor_id: string; created_at: string }; +export type TestResult = { + event_id: string; + hypothesis_id: string; + test_id: string; + observation: string; + matched: MatchKind; + evidence_refs: string[]; + actor_id: string; + created_at: string; +}; export type DiscriminationItem = { - hypothesis_id: string; results: TestResult[]; result_history_count: number; refutation_conditions: string[]; conditions_history_count: number; + hypothesis_id: string; + results: TestResult[]; + result_history_count: number; + refutation_conditions: string[]; + conditions_history_count: number; elimination: "SINGLE" | "REPEATED" | null; /** Where the recorded results leave the hypothesis, read by the server when asked; a record without it counts as none. */ standing?: "NONE" | "FITS" | "AGAINST_ONCE" | "AGAINST_REPEATED" | "MIXED"; @@ -12,31 +25,113 @@ export type DiscriminationItem = { export type ClosureKind = "FIX_APPLIED" | "HUMAN_CLOSED" | "WAIVER_RECORDED" | "CONDITION_CHANGED"; export type ClosureEvent = { - event_id: string; subject_kind: "CRITERION" | "REQUIREMENT"; subject_id: string; kind: ClosureKind; basis_ref: string; note: string; scope: string | null; - verdict_revision: string; verdict_digest: string; verdict_state: string; actor_id: string; created_at: string; + event_id: string; + subject_kind: "CRITERION" | "REQUIREMENT"; + subject_id: string; + kind: ClosureKind; + basis_ref: string; + note: string; + scope: string | null; + verdict_revision: string; + verdict_digest: string; + verdict_state: string; + actor_id: string; + created_at: string; }; /** A trace row that has a closure: the events, and whether the rules confirm the effect of a recorded fix. */ export type ClosureRow = { - subject_kind: "CRITERION" | "REQUIREMENT"; subject_id: string; current_state: string | null; effect_confirmed: boolean; effect_verdict_revision: string | null; - verdict_changed_since: boolean; events: ClosureEvent[]; + subject_kind: "CRITERION" | "REQUIREMENT"; + subject_id: string; + current_state: string | null; + effect_confirmed: boolean; + effect_verdict_revision: string | null; + verdict_changed_since: boolean; + events: ClosureEvent[]; }; export const discriminationKey = (projectId: string) => ["discrimination", projectId] as const; export const closuresKey = (projectId: string) => ["trace-closures", projectId] as const; export async function listDiscrimination(projectId: string, signal?: AbortSignal) { - return (await rpc<{ items: DiscriminationItem[] }>("hypothesis/test/result/list", { project_id: projectId }, crypto.randomUUID(), signal)).value; + return ( + await rpc<{ items: DiscriminationItem[] }>( + "hypothesis/test/result/list", + { project_id: projectId }, + crypto.randomUUID(), + signal, + ) + ).value; } -export async function recordTestResult(projectId: string, hypothesisId: string, testId: string, observation: string, matched: MatchKind, evidenceRefs: string[]) { - return (await rpc("hypothesis/test/result/record", { project_id: projectId, hypothesis_id: hypothesisId, test_id: testId, observation, matched, evidence_refs: evidenceRefs }, crypto.randomUUID())).value; +export async function recordTestResult( + projectId: string, + hypothesisId: string, + testId: string, + observation: string, + matched: MatchKind, + evidenceRefs: string[], +) { + return ( + await rpc( + "hypothesis/test/result/record", + { + project_id: projectId, + hypothesis_id: hypothesisId, + test_id: testId, + observation, + matched, + evidence_refs: evidenceRefs, + }, + crypto.randomUUID(), + ) + ).value; } -export async function recordRefutationConditions(projectId: string, hypothesisId: string, conditions: string[]) { - return (await rpc("hypothesis/refutation/record", { project_id: projectId, hypothesis_id: hypothesisId, conditions }, crypto.randomUUID())).value; +export async function recordRefutationConditions( + projectId: string, + hypothesisId: string, + conditions: string[], +) { + return ( + await rpc( + "hypothesis/refutation/record", + { project_id: projectId, hypothesis_id: hypothesisId, conditions }, + crypto.randomUUID(), + ) + ).value; } export async function listClosures(projectId: string, signal?: AbortSignal) { - return (await rpc<{ closures: ClosureRow[] }>("trace/closure/list", { project_id: projectId }, crypto.randomUUID(), signal)).value; + return ( + await rpc<{ closures: ClosureRow[] }>( + "trace/closure/list", + { project_id: projectId }, + crypto.randomUUID(), + signal, + ) + ).value; } -export async function recordClosure(input: { projectId: string; subjectKind: string; subjectId: string; kind: ClosureKind; basisRef: string; note: string; scope: string; verdictRevision: string }) { - return (await rpc("trace/closure/record", { project_id: input.projectId, subject_kind: input.subjectKind, subject_id: input.subjectId, kind: input.kind, basis_ref: input.basisRef, - note: input.note, ...(input.scope ? { scope: input.scope } : {}), current_verdict_revision: input.verdictRevision }, crypto.randomUUID())).value; +export async function recordClosure(input: { + projectId: string; + subjectKind: string; + subjectId: string; + kind: ClosureKind; + basisRef: string; + note: string; + scope: string; + verdictRevision: string; +}) { + return ( + await rpc( + "trace/closure/record", + { + project_id: input.projectId, + subject_kind: input.subjectKind, + subject_id: input.subjectId, + kind: input.kind, + basis_ref: input.basisRef, + note: input.note, + ...(input.scope ? { scope: input.scope } : {}), + current_verdict_revision: input.verdictRevision, + }, + crypto.randomUUID(), + ) + ).value; } diff --git a/apps/web/src/api/koreanParticles.test.ts b/apps/web/src/api/koreanParticles.test.ts index 2d527aa9..a28428d1 100644 --- a/apps/web/src/api/koreanParticles.test.ts +++ b/apps/web/src/api/koreanParticles.test.ts @@ -13,9 +13,12 @@ describe("finalHasBatchim", () => { it("reads a digit by how it is spoken", () => { const spoken = [true, true, false, true, false, false, true, true, true, false]; - spoken.forEach((expected, digit) => expect(finalHasBatchim(String(digit)), String(digit)).toBe(expected)); + spoken.forEach((expected, digit) => + expect(finalHasBatchim(String(digit)), String(digit)).toBe(expected), + ); // 십, 백, 천 and 만 all carry a final consonant, and so does 영. - for (const value of ["10", "100", "1000", "10000"]) expect(finalHasBatchim(value), value).toBe(true); + for (const value of ["10", "100", "1000", "10000"]) + expect(finalHasBatchim(value), value).toBe(true); expect(finalHasBatchim("12")).toBe(false); expect(finalHasBatchim("A3")).toBe(true); expect(finalHasBatchim("p.6")).toBe(true); @@ -23,8 +26,32 @@ describe("finalHasBatchim", () => { }); it("reads a Latin letter by its name and ignores case", () => { - for (const letter of ["l", "m", "n", "r", "L", "M", "N", "R"]) expect(finalHasBatchim(letter), letter).toBe(true); - for (const letter of ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "o", "p", "q", "s", "t", "u", "v", "w", "x", "y", "z"]) { + for (const letter of ["l", "m", "n", "r", "L", "M", "N", "R"]) + expect(finalHasBatchim(letter), letter).toBe(true); + for (const letter of [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "o", + "p", + "q", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + ]) { expect(finalHasBatchim(letter), letter).toBe(false); expect(finalHasBatchim(letter.toUpperCase()), letter.toUpperCase()).toBe(false); } diff --git a/apps/web/src/api/koreanParticles.ts b/apps/web/src/api/koreanParticles.ts index cf7577b6..3a0dd8e3 100644 --- a/apps/web/src/api/koreanParticles.ts +++ b/apps/web/src/api/koreanParticles.ts @@ -23,7 +23,9 @@ export function finalHasBatchim(text: string): boolean | null { function withParticle(text: string, withBatchim: string, without: string): string { const batchim = finalHasBatchim(text); - return batchim === null ? `${text}${withBatchim}(${without})` : `${text}${batchim ? withBatchim : without}`; + return batchim === null + ? `${text}${withBatchim}(${without})` + : `${text}${batchim ? withBatchim : without}`; } /** 을/를 */ diff --git a/apps/web/src/api/lessons.ts b/apps/web/src/api/lessons.ts index a6f94a65..52471b52 100644 --- a/apps/web/src/api/lessons.ts +++ b/apps/web/src/api/lessons.ts @@ -4,13 +4,31 @@ import type { MatchKind } from "./judgmentRecords"; export type LessonKind = "TEST_RESULT" | "ELIMINATION" | "EFFECT_CONFIRMED" | "HUMAN_CLOSURE"; export type LessonState = "SAME_CONDITION" | "STALE" | "REFUTED"; export type LessonDetail = { - observations?: { test_id: string; matched: MatchKind; observation: string; evidence_refs: string[] }[]; - closure_kind?: string; basis_ref?: string; note?: string; scope?: string | null; original_state?: string; + observations?: { + test_id: string; + matched: MatchKind; + observation: string; + evidence_refs: string[]; + }[]; + closure_kind?: string; + basis_ref?: string; + note?: string; + scope?: string | null; + original_state?: string; }; /** A lesson: a reference to a record a rule computed or a person made, read back for the row's exact condition. */ export type LessonItem = { - lesson_id: string; kind: LessonKind; outcome: string; state: LessonState; subject_kind: "CRITERION" | "REQUIREMENT"; subject_id: string; - hypothesis_id: string | null; test_id: string | null; actor_id: string; created_at: string; detail: LessonDetail; + lesson_id: string; + kind: LessonKind; + outcome: string; + state: LessonState; + subject_kind: "CRITERION" | "REQUIREMENT"; + subject_id: string; + hypothesis_id: string | null; + test_id: string | null; + actor_id: string; + created_at: string; + detail: LessonDetail; /** The other hypotheses a person marked as the same as this lesson's hypothesis; absent when none. */ same_hypothesis_ids?: string[]; }; @@ -18,5 +36,12 @@ export type LessonItem = { export const lessonsKey = (projectId: string) => ["lessons", projectId] as const; export async function listLessons(projectId: string, signal?: AbortSignal) { - return (await rpc<{ lessons: LessonItem[] }>("trace/lesson/list", { project_id: projectId }, crypto.randomUUID(), signal)).value; + return ( + await rpc<{ lessons: LessonItem[] }>( + "trace/lesson/list", + { project_id: projectId }, + crypto.randomUUID(), + signal, + ) + ).value; } diff --git a/apps/web/src/api/localRetrieval.ts b/apps/web/src/api/localRetrieval.ts index 7408dab0..195b22af 100644 --- a/apps/web/src/api/localRetrieval.ts +++ b/apps/web/src/api/localRetrieval.ts @@ -14,20 +14,51 @@ import { z } from "zod"; export const WORKSPACE_PROJECT_ID = "system:workspace"; const assetReadSchema = z.object({ - schema_version: z.literal("1.0.0"), supported: z.boolean(), can_manage: z.boolean(), - model_name: z.string(), manifest_digest: z.string(), total_bytes: z.number().nonnegative(), - asset_state: z.enum(["MODEL_REQUIRED", "READY", "FAILED"]), dependency_available: z.boolean(), - worker_loaded: z.boolean(), reason_code: z.string().nullable(), - active: z.object({ operation_id: z.string(), state: z.enum(["COPYING", "DOWNLOADING", "VERIFYING"]), - transferred_bytes: z.number().nonnegative(), total_bytes: z.number().nonnegative() }).nullable(), + schema_version: z.literal("1.0.0"), + supported: z.boolean(), + can_manage: z.boolean(), + model_name: z.string(), + manifest_digest: z.string(), + total_bytes: z.number().nonnegative(), + asset_state: z.enum(["MODEL_REQUIRED", "READY", "FAILED"]), + dependency_available: z.boolean(), + worker_loaded: z.boolean(), + reason_code: z.string().nullable(), + active: z + .object({ + operation_id: z.string(), + state: z.enum(["COPYING", "DOWNLOADING", "VERIFYING"]), + transferred_bytes: z.number().nonnegative(), + total_bytes: z.number().nonnegative(), + }) + .nullable(), }); const projectReadSchema = z.object({ - project_id: z.string(), can_prepare: z.boolean(), enabled: z.boolean(), generation: z.number().int().nonnegative(), - state: z.enum(["OFF", "NO_SOURCES", "INDEX_PENDING", "INDEXING", "READY", "FAILED", "CANCEL_REQUESTED"]), - ready_sources: z.number().nonnegative(), total_sources: z.number().nonnegative(), - pending_sources: z.number().nonnegative(), failed_sources: z.number().nonnegative(), - jobs: z.array(z.object({ job_id: z.string(), generation: z.number().int(), - state: z.enum(["PENDING", "RUNNING", "READY", "FAILED", "CANCELLED", "STALE"]), reason_code: z.string().nullable() })), + project_id: z.string(), + can_prepare: z.boolean(), + enabled: z.boolean(), + generation: z.number().int().nonnegative(), + state: z.enum([ + "OFF", + "NO_SOURCES", + "INDEX_PENDING", + "INDEXING", + "READY", + "FAILED", + "CANCEL_REQUESTED", + ]), + ready_sources: z.number().nonnegative(), + total_sources: z.number().nonnegative(), + pending_sources: z.number().nonnegative(), + failed_sources: z.number().nonnegative(), + jobs: z.array( + z.object({ + job_id: z.string(), + generation: z.number().int(), + state: z.enum(["PENDING", "RUNNING", "READY", "FAILED", "CANCELLED", "STALE"]), + reason_code: z.string().nullable(), + }), + ), active_operation_id: z.string().nullable(), // True while sources are indexed only under an earlier preprocessing version. outdated_index: z.boolean().optional(), @@ -39,12 +70,25 @@ export type RetrievalExperimentRead = z.infer; export type RetrievalJob = RetrievalExperimentRead["jobs"][number]; export async function readLocalAsset(signal?: AbortSignal): Promise> { - const result = await rpc("model/localAsset/read", { project_id: WORKSPACE_PROJECT_ID }, crypto.randomUUID(), signal); + const result = await rpc( + "model/localAsset/read", + { project_id: WORKSPACE_PROJECT_ID }, + crypto.randomUUID(), + signal, + ); return { ...result, value: assetReadSchema.parse(result.value) }; } -export async function readRetrievalExperiment(projectId: string, signal?: AbortSignal): Promise> { - const result = await rpc("project/retrieval/experiment/read", { project_id: projectId }, crypto.randomUUID(), signal); +export async function readRetrievalExperiment( + projectId: string, + signal?: AbortSignal, +): Promise> { + const result = await rpc( + "project/retrieval/experiment/read", + { project_id: projectId }, + crypto.randomUUID(), + signal, + ); const value = projectReadSchema.parse(result.value); if (value.project_id !== projectId) throw new Error("LOCAL_RETRIEVAL_SCOPE_MISMATCH"); return { ...result, value }; @@ -55,7 +99,11 @@ export async function configureRetrievalExperiment( input: { project_id: string; enabled: boolean; expected_generation: number }, idempotencyKey: string, ): Promise> { - const result = await rpc("project/retrieval/experiment/configure", input, idempotencyKey); + const result = await rpc( + "project/retrieval/experiment/configure", + input, + idempotencyKey, + ); const value = projectReadSchema.parse(result.value); if (value.project_id !== input.project_id) throw new Error("LOCAL_RETRIEVAL_SCOPE_MISMATCH"); return { ...result, value }; @@ -81,7 +129,11 @@ export type OperationTimeouts = { }; export const DEFAULT_OPERATION_TIMEOUTS: OperationTimeouts = { - startMs: 15_000, pollMs: 15_000, pollIntervalMs: 900, totalMs: 610_000, cancelMs: 15_000, + startMs: 15_000, + pollMs: 15_000, + pollIntervalMs: 900, + totalMs: 610_000, + cancelMs: 15_000, }; /** Every way a long action can end, as the client can actually tell it apart. */ @@ -110,18 +162,31 @@ type BoundedJson = | { ok: false; cause: "TIMEOUT" | "NETWORK" | "ABORTED" }; /** One request whose headers *and* body must arrive within `timeoutMs`; a stalled body is a timeout too. */ -async function boundedJson(url: string, init: RequestInit, signal: AbortSignal | undefined, timeoutMs: number): Promise { +async function boundedJson( + url: string, + init: RequestInit, + signal: AbortSignal | undefined, + timeoutMs: number, +): Promise { if (signal?.aborted) return { ok: false, cause: "ABORTED" }; const controller = new AbortController(); let timedOut = false; - const timer = globalThis.setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); + const timer = globalThis.setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); const abort = () => controller.abort(); signal?.addEventListener("abort", abort, { once: true }); try { - const response = await fetch(url, { ...init, signal: controller.signal, credentials: "same-origin" }); + const response = await fetch(url, { + ...init, + signal: controller.signal, + credentials: "same-origin", + }); let body: unknown; - try { body = await response.json(); } - catch (error) { + try { + body = await response.json(); + } catch (error) { if (controller.signal.aborted) throw error; body = undefined; } @@ -136,13 +201,23 @@ async function boundedJson(url: string, init: RequestInit, signal: AbortSignal | } /** An HTTP status that leaves it open whether the server took the request (it may be working on it). */ -const startUnconfirmedStatus = (status: number) => status === 408 || status === 429 || status >= 500; +const startUnconfirmedStatus = (status: number) => + status === 408 || status === 429 || status >= 500; function sleep(ms: number, signal: AbortSignal | undefined): Promise { - return new Promise(resolve => { - if (signal?.aborted) { resolve(); return; } - const timer = globalThis.setTimeout(() => { signal?.removeEventListener("abort", done); resolve(); }, ms); - const done = () => { globalThis.clearTimeout(timer); resolve(); }; + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = globalThis.setTimeout(() => { + signal?.removeEventListener("abort", done); + resolve(); + }, ms); + const done = () => { + globalThis.clearTimeout(timer); + resolve(); + }; signal?.addEventListener("abort", done, { once: true }); }); } @@ -150,7 +225,9 @@ function sleep(ms: number, signal: AbortSignal | undefined): Promise { const codeShape = /^[A-Z][A-Z0-9_]{2,79}$/; /** Only a code-shaped value is kept; free text (which may carry paths) is dropped. */ -export function reasonCodeOf(error: { message?: unknown; data?: unknown } | null | undefined): string | null { +export function reasonCodeOf( + error: { message?: unknown; data?: unknown } | null | undefined, +): string | null { if (!error) return null; const data = error.data; if (data && typeof data === "object" && "reason_code" in data) { @@ -173,7 +250,9 @@ export type RunLocalOperationRequest = { timeouts?: Partial; }; -export async function runLocalOperation(request: RunLocalOperationRequest): Promise { +export async function runLocalOperation( + request: RunLocalOperationRequest, +): Promise { const timeouts = { ...DEFAULT_OPERATION_TIMEOUTS, ...request.timeouts }; const deadline = Date.now() + timeouts.totalMs; /** Each request gets its own limit, never more than what is left of the whole wait. */ @@ -184,16 +263,21 @@ export async function runLocalOperation(request: RunLocalOperationRequest): Prom if (!operationId) { if (signal?.aborted) return { kind: "ABANDONED", operationId: null }; - const started = await boundedJson("/rpc/async", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: crypto.randomUUID(), - method: request.method, - params: { _meta: { idempotencyKey: request.idempotencyKey }, input: request.input }, - }), - }, signal, remaining(timeouts.startMs)); + const started = await boundedJson( + "/rpc/async", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: crypto.randomUUID(), + method: request.method, + params: { _meta: { idempotencyKey: request.idempotencyKey }, input: request.input }, + }), + }, + signal, + remaining(timeouts.startMs), + ); if (!started.ok) { if (started.cause === "ABORTED") return { kind: "ABANDONED", operationId: null }; return { kind: "UNCONFIRMED", operationId: null }; @@ -202,10 +286,15 @@ export async function runLocalOperation(request: RunLocalOperationRequest): Prom if (!started.httpOk) { // 408/429/5xx do not prove that nothing started: keep the same key for a later check. if (startUnconfirmedStatus(started.status)) return { kind: "UNCONFIRMED", operationId: null }; - return { kind: "REJECTED", reasonCode: parsed.success ? reasonCodeOf(parsed.data.error) : null, httpStatus: started.status }; + return { + kind: "REJECTED", + reasonCode: parsed.success ? reasonCodeOf(parsed.data.error) : null, + httpStatus: started.status, + }; } if (!parsed.success) return { kind: "UNCONFIRMED", operationId: null }; - if (parsed.data.error) return { kind: "REJECTED", reasonCode: reasonCodeOf(parsed.data.error), httpStatus: null }; + if (parsed.data.error) + return { kind: "REJECTED", reasonCode: reasonCodeOf(parsed.data.error), httpStatus: null }; const id = parsed.data.result?.operation_id; if (typeof id !== "string" || !id) return { kind: "UNCONFIRMED", operationId: null }; operationId = id; @@ -217,7 +306,9 @@ export async function runLocalOperation(request: RunLocalOperationRequest): Prom if (Date.now() >= deadline) return { kind: "UNCONFIRMED", operationId }; const read = await boundedJson( `/operations/${encodeURIComponent(operationId)}?project_id=${encodeURIComponent(projectId)}`, - { method: "GET" }, signal, remaining(timeouts.pollMs), + { method: "GET" }, + signal, + remaining(timeouts.pollMs), ); if (!read.ok) { if (read.cause === "ABORTED") return { kind: "ABANDONED", operationId }; @@ -229,7 +320,8 @@ export async function runLocalOperation(request: RunLocalOperationRequest): Prom if (record.state === "SUCCEEDED" && record.result !== null && record.result !== undefined) { return { kind: "SUCCEEDED", operationId, value: record.result }; } - if (record.state === "FAILED") return { kind: "FAILED", operationId, reasonCode: reasonCodeOf(record.error) }; + if (record.state === "FAILED") + return { kind: "FAILED", operationId, reasonCode: reasonCodeOf(record.error) }; if (record.state === "CANCELLED") return { kind: "CANCELLED", operationId }; await sleep(timeouts.pollIntervalMs, signal); } @@ -243,20 +335,33 @@ export type CancelRequestResult = /** Asks the server to cancel. This never claims that the work stopped. */ export async function cancelLocalOperation( - projectId: string, operationId: string, signal?: AbortSignal, timeoutMs: number = DEFAULT_OPERATION_TIMEOUTS.cancelMs, + projectId: string, + operationId: string, + signal?: AbortSignal, + timeoutMs: number = DEFAULT_OPERATION_TIMEOUTS.cancelMs, ): Promise { - const sent = await boundedJson(`/operations/${encodeURIComponent(operationId)}/cancel`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ project_id: projectId }), - }, signal, timeoutMs); + const sent = await boundedJson( + `/operations/${encodeURIComponent(operationId)}/cancel`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project_id: projectId }), + }, + signal, + timeoutMs, + ); if (!sent.ok) return { kind: "UNCONFIRMED" }; const parsed = jsonRpcResponseSchema.safeParse(sent.body); if (!sent.httpOk) { if (startUnconfirmedStatus(sent.status)) return { kind: "UNCONFIRMED" }; - return { kind: "REJECTED", reasonCode: parsed.success ? reasonCodeOf(parsed.data.error) : null, httpStatus: sent.status }; + return { + kind: "REJECTED", + reasonCode: parsed.success ? reasonCodeOf(parsed.data.error) : null, + httpStatus: sent.status, + }; } if (!parsed.success) return { kind: "UNCONFIRMED" }; - if (parsed.data.error) return { kind: "REJECTED", reasonCode: reasonCodeOf(parsed.data.error), httpStatus: null }; + if (parsed.data.error) + return { kind: "REJECTED", reasonCode: reasonCodeOf(parsed.data.error), httpStatus: null }; return { kind: "REQUESTED" }; } diff --git a/apps/web/src/api/localRetrievalInstall.test.ts b/apps/web/src/api/localRetrievalInstall.test.ts index 49942819..903f8c6a 100644 --- a/apps/web/src/api/localRetrievalInstall.test.ts +++ b/apps/web/src/api/localRetrievalInstall.test.ts @@ -3,14 +3,19 @@ import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; import { LOCAL_RETRIEVAL_INSTALL_COMMAND } from "./localRetrievalInstall"; -const install = readFileSync(fileURLToPath(new URL("../../../../docs/INSTALL.md", import.meta.url)), "utf8").split(/\r?\n/); +const install = readFileSync( + fileURLToPath(new URL("../../../../docs/INSTALL.md", import.meta.url)), + "utf8", +).split(/\r?\n/); it("shows the same install command in docs/INSTALL.md as the settings card", () => { expect(install).toContain(LOCAL_RETRIEVAL_INSTALL_COMMAND); }); it("is the basic install command from INSTALL.md plus the local-retrieval extra", () => { - const basic = install.find(line => line.startsWith("uv sync ") && line.endsWith("--extra dev --dev")); + const basic = install.find( + (line) => line.startsWith("uv sync ") && line.endsWith("--extra dev --dev"), + ); expect(basic).toBeDefined(); expect(LOCAL_RETRIEVAL_INSTALL_COMMAND).toBe(`${basic} --extra local-retrieval`); }); diff --git a/apps/web/src/api/localWorkspacePersistence.test.ts b/apps/web/src/api/localWorkspacePersistence.test.ts index 4207b85a..de4fca93 100644 --- a/apps/web/src/api/localWorkspacePersistence.test.ts +++ b/apps/web/src/api/localWorkspacePersistence.test.ts @@ -2,22 +2,41 @@ import { afterEach, expect, it, vi } from "vitest"; import { readDraft, writeDraft } from "./conversation"; import { readWorkspaceContext, saveWorkspaceContext } from "./research"; -import { clearLocalPending, inspectLocalContext, inspectLocalDraft, readLocalPending, submissionSignature, writeLocalPending, type BrowserScope, type PendingSubmission } from "./localWorkspacePersistence"; +import { + clearLocalPending, + inspectLocalContext, + inspectLocalDraft, + readLocalPending, + submissionSignature, + writeLocalPending, + type BrowserScope, + type PendingSubmission, +} from "./localWorkspacePersistence"; const a: BrowserScope = { mode: "LOCAL", workspaceId: `workspace:${"a".repeat(32)}` }; const b: BrowserScope = { mode: "LOCAL", workspaceId: `workspace:${"b".repeat(32)}` }; const hosted: BrowserScope = { mode: "HOSTED" }; -afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); }); +afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.restoreAllMocks(); +}); it("restores only the selected LOCAL workspace context after a browser restart", () => { - sessionStorage.setItem("thoth:web-context:v1", JSON.stringify({ version: 1, projectId: "legacy", threadId: "legacy" })); + sessionStorage.setItem( + "thoth:web-context:v1", + JSON.stringify({ version: 1, projectId: "legacy", threadId: "legacy" }), + ); expect(saveWorkspaceContext("project-a", "thread-a", a)).toBe(true); expect(saveWorkspaceContext("project-b", "thread-b", b)).toBe(true); expect(readWorkspaceContext(a)).toEqual({ projectId: "project-a", threadId: "thread-a" }); expect(readWorkspaceContext(b)).toEqual({ projectId: "project-b", threadId: "thread-b" }); expect(readWorkspaceContext(hosted)).toMatchObject({ projectId: "legacy", threadId: "legacy" }); - expect(readWorkspaceContext({ mode: "LOCAL", workspaceId: null })).toEqual({ projectId: "", threadId: "" }); + expect(readWorkspaceContext({ mode: "LOCAL", workspaceId: null })).toEqual({ + projectId: "", + threadId: "", + }); }); it("isolates unsent drafts by workspace, project and thread without borrowing the Hosted draft", () => { @@ -35,61 +54,130 @@ it("does not truncate oversized drafts or erase the last saved version on storag expect(writeDraft("p", "t", "saved", a)).toBe("SAVED"); expect(writeDraft("p", "t", "x".repeat(20_001), a)).toBe("TOO_LARGE"); expect(readDraft("p", "t", a)).toBe("saved"); - vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); expect(writeDraft("p", "t", "new text", a)).toBe("UNAVAILABLE"); expect(readDraft("p", "t", a)).toBe("saved"); }); it("ignores corrupt LOCAL context and draft records without consulting shared session keys", () => { - localStorage.setItem(`thoth:local-workspace:v1:context:${a.mode === "LOCAL" ? a.workspaceId : ""}`, "{bad-json"); + localStorage.setItem( + `thoth:local-workspace:v1:context:${a.mode === "LOCAL" ? a.workspaceId : ""}`, + "{bad-json", + ); expect(readWorkspaceContext(a)).toEqual({ projectId: "", threadId: "" }); expect(inspectLocalContext(a.mode === "LOCAL" ? a.workspaceId : null).kind).toBe("CORRUPT"); - localStorage.setItem(`thoth:local-workspace:v1:draft:${JSON.stringify([a.mode === "LOCAL" ? a.workspaceId : "", "p", "t"])}`, "{bad-json"); + localStorage.setItem( + `thoth:local-workspace:v1:draft:${JSON.stringify([a.mode === "LOCAL" ? a.workspaceId : "", "p", "t"])}`, + "{bad-json", + ); expect(readDraft("p", "t", a)).toBe(""); - expect(inspectLocalDraft(a.mode === "LOCAL" ? a.workspaceId : null, "p", "t").kind).toBe("CORRUPT"); + expect(inspectLocalDraft(a.mode === "LOCAL" ? a.workspaceId : null, "p", "t").kind).toBe( + "CORRUPT", + ); }); it("distinguishes unreadable browser storage from a missing or corrupt draft", () => { const workspaceId = a.mode === "LOCAL" ? a.workspaceId : null; expect(inspectLocalDraft(workspaceId, "p", "t").kind).toBe("MISSING"); - vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("storage denied"); }); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage denied"); + }); expect(inspectLocalDraft(workspaceId, "p", "t").kind).toBe("UNAVAILABLE"); expect(inspectLocalContext(workspaceId).kind).toBe("UNAVAILABLE"); }); it("pins a bounded pending key to one workspace and exact submitted payload", () => { - const value: PendingSubmission = { version: 1, workspaceId: a.mode === "LOCAL" ? a.workspaceId! : "", projectId: "p", threadId: "t", - problem: "synthetic question", selection: { provider: "test", model: "test", reasoning_effort: "high" }, - signature: submissionSignature("p", "t", "synthetic question", { provider: "test", model: "test", reasoning_effort: "high" }), - key: "11111111-1111-4111-8111-111111111111", createdAt: 1 }; + const value: PendingSubmission = { + version: 1, + workspaceId: a.mode === "LOCAL" ? a.workspaceId! : "", + projectId: "p", + threadId: "t", + problem: "synthetic question", + selection: { provider: "test", model: "test", reasoning_effort: "high" }, + signature: submissionSignature("p", "t", "synthetic question", { + provider: "test", + model: "test", + reasoning_effort: "high", + }), + key: "11111111-1111-4111-8111-111111111111", + createdAt: 1, + }; expect(writeLocalPending(value)).toBe("SAVED"); - expect(readLocalPending(a.mode === "LOCAL" ? a.workspaceId : null, "p", "t")).toEqual({ kind: "PENDING", value }); - expect(readLocalPending(b.mode === "LOCAL" ? b.workspaceId : null, "p", "t")).toEqual({ kind: "NONE" }); - expect(readLocalPending(a.mode === "LOCAL" ? a.workspaceId : null, "p", "other")).toEqual({ kind: "NONE" }); + expect(readLocalPending(a.mode === "LOCAL" ? a.workspaceId : null, "p", "t")).toEqual({ + kind: "PENDING", + value, + }); + expect(readLocalPending(b.mode === "LOCAL" ? b.workspaceId : null, "p", "t")).toEqual({ + kind: "NONE", + }); + expect(readLocalPending(a.mode === "LOCAL" ? a.workspaceId : null, "p", "other")).toEqual({ + kind: "NONE", + }); expect(writeLocalPending({ ...value, signature: "different payload" })).toBe("UNAVAILABLE"); - expect(writeLocalPending({ ...value, problem: "x".repeat(20_001), signature: submissionSignature("p", "t", "x".repeat(20_001), value.selection) })).toBe("TOO_LARGE"); + expect( + writeLocalPending({ + ...value, + problem: "x".repeat(20_001), + signature: submissionSignature("p", "t", "x".repeat(20_001), value.selection), + }), + ).toBe("TOO_LARGE"); expect(clearLocalPending(a.mode === "LOCAL" ? a.workspaceId : null, "p", "t")).toBe(true); - expect(readLocalPending(a.mode === "LOCAL" ? a.workspaceId : null, "p", "t")).toEqual({ kind: "NONE" }); + expect(readLocalPending(a.mode === "LOCAL" ? a.workspaceId : null, "p", "t")).toEqual({ + kind: "NONE", + }); }); it("keeps a trace-row origin with the pending request, and a different origin is a different request", () => { const id = a.mode === "LOCAL" ? a.workspaceId : null; - const origin = { kind: "TRACE_VERDICT" as const, project_id: "p", subject_kind: "CRITERION" as const, subject_id: "C-1", verdict_revision: "a".repeat(64) }; - const value: PendingSubmission = { version: 1, workspaceId: id!, projectId: "p", threadId: "t", problem: "synthetic question", selection: null, origin, - signature: submissionSignature("p", "t", "synthetic question", null, origin), key: "11111111-1111-4111-8111-111111111111", createdAt: 1 }; + const origin = { + kind: "TRACE_VERDICT" as const, + project_id: "p", + subject_kind: "CRITERION" as const, + subject_id: "C-1", + verdict_revision: "a".repeat(64), + }; + const value: PendingSubmission = { + version: 1, + workspaceId: id!, + projectId: "p", + threadId: "t", + problem: "synthetic question", + selection: null, + origin, + signature: submissionSignature("p", "t", "synthetic question", null, origin), + key: "11111111-1111-4111-8111-111111111111", + createdAt: 1, + }; expect(writeLocalPending(value)).toBe("SAVED"); expect(readLocalPending(id, "p", "t")).toEqual({ kind: "PENDING", value }); - expect(submissionSignature("p", "t", "synthetic question", null, origin)).not.toBe(submissionSignature("p", "t", "synthetic question", null)); - expect(submissionSignature("p", "t", "synthetic question", null, null)).toBe(submissionSignature("p", "t", "synthetic question", null)); // no origin: the old signature - expect(writeLocalPending({ ...value, signature: submissionSignature("p", "t", "synthetic question", null) })).toBe("UNAVAILABLE"); // the origin is part of the exact payload + expect(submissionSignature("p", "t", "synthetic question", null, origin)).not.toBe( + submissionSignature("p", "t", "synthetic question", null), + ); + expect(submissionSignature("p", "t", "synthetic question", null, null)).toBe( + submissionSignature("p", "t", "synthetic question", null), + ); // no origin: the old signature + expect( + writeLocalPending({ + ...value, + signature: submissionSignature("p", "t", "synthetic question", null), + }), + ).toBe("UNAVAILABLE"); // the origin is part of the exact payload // a made-up origin shape is not a pending request - localStorage.setItem(`thoth:local-workspace:v1:pending:${JSON.stringify([id, "p", "t"])}`, JSON.stringify({ ...value, origin: { ...origin, extra: 1 } })); + localStorage.setItem( + `thoth:local-workspace:v1:pending:${JSON.stringify([id, "p", "t"])}`, + JSON.stringify({ ...value, origin: { ...origin, extra: 1 } }), + ); expect(readLocalPending(id, "p", "t")).toEqual({ kind: "INVALID" }); clearLocalPending(id, "p", "t"); }); it("blocks a corrupt pending marker instead of assigning a new key", () => { const workspaceId = a.mode === "LOCAL" ? a.workspaceId : ""; - localStorage.setItem(`thoth:local-workspace:v1:pending:${JSON.stringify([workspaceId, "p", "t"])}`, "{broken"); + localStorage.setItem( + `thoth:local-workspace:v1:pending:${JSON.stringify([workspaceId, "p", "t"])}`, + "{broken", + ); expect(readLocalPending(workspaceId, "p", "t")).toEqual({ kind: "INVALID" }); }); diff --git a/apps/web/src/api/localWorkspacePersistence.ts b/apps/web/src/api/localWorkspacePersistence.ts index c93f0020..e4fa5aa8 100644 --- a/apps/web/src/api/localWorkspacePersistence.ts +++ b/apps/web/src/api/localWorkspacePersistence.ts @@ -3,17 +3,42 @@ import { isResponseLanguage, type ResponseLanguage } from "./responseLanguages"; export type { ResponseLanguage }; /** Browser convenience only. Research records and permissions remain server-owned. */ -export type BrowserScope = { mode: "LOCAL"; workspaceId: string | null } | { mode: "HOSTED"; browserScopeId?: string | null }; +export type BrowserScope = + | { mode: "LOCAL"; workspaceId: string | null } + | { mode: "HOSTED"; browserScopeId?: string | null }; export type WorkspaceContext = { projectId: string; threadId: string }; -export type ContextReadResult = { kind: "MISSING" | "VALID" | "CORRUPT" | "UNAVAILABLE"; context: WorkspaceContext }; +export type ContextReadResult = { + kind: "MISSING" | "VALID" | "CORRUPT" | "UNAVAILABLE"; + context: WorkspaceContext; +}; export type DraftWriteResult = "SAVED" | "TOO_LARGE" | "UNAVAILABLE"; -export type DraftReadResult = { kind: "MISSING" | "VALID" | "CORRUPT" | "UNAVAILABLE"; text: string }; +export type DraftReadResult = { + kind: "MISSING" | "VALID" | "CORRUPT" | "UNAVAILABLE"; + text: string; +}; export type PendingModelSelection = { provider: string; model: string; reasoning_effort?: string }; -export type PendingOrigin = { kind: "TRACE_VERDICT"; project_id: string; subject_kind: "CRITERION" | "REQUIREMENT"; subject_id: string; verdict_revision: string }; -type PendingFields = { workspaceId: string; projectId: string; threadId: string; - problem: string; selection: PendingModelSelection | null; origin?: PendingOrigin | null; signature: string; key: string; createdAt: number }; -export type PendingSubmission = PendingFields & ({ version: 1; responseLanguage?: never } | { version: 2; responseLanguage: ResponseLanguage }); -export type PendingRead = { kind: "NONE" } | { kind: "PENDING"; value: PendingSubmission } | { kind: "INVALID" }; +export type PendingOrigin = { + kind: "TRACE_VERDICT"; + project_id: string; + subject_kind: "CRITERION" | "REQUIREMENT"; + subject_id: string; + verdict_revision: string; +}; +type PendingFields = { + workspaceId: string; + projectId: string; + threadId: string; + problem: string; + selection: PendingModelSelection | null; + origin?: PendingOrigin | null; + signature: string; + key: string; + createdAt: number; +}; +export type PendingSubmission = PendingFields & + ({ version: 1; responseLanguage?: never } | { version: 2; responseLanguage: ResponseLanguage }); +export type PendingRead = + { kind: "NONE" } | { kind: "PENDING"; value: PendingSubmission } | { kind: "INVALID" }; const prefix = "thoth:local-workspace:v1:"; const maxDraftLength = 20_000; @@ -22,14 +47,25 @@ const maxIdentityLength = 160; const emptyContext: WorkspaceContext = { projectId: "", threadId: "" }; export function validWorkspaceId(value: string | null | undefined): value is string { - return typeof value === "string" && value.length >= 8 && value.length <= maxIdentityLength && /^[a-zA-Z0-9:_-]+$/.test(value); + return ( + typeof value === "string" && + value.length >= 8 && + value.length <= maxIdentityLength && + /^[a-zA-Z0-9:_-]+$/.test(value) + ); } function validRecordId(value: unknown): value is string { - return typeof value === "string" && value.length <= maxIdentityLength && Array.from(value).every(char => char.charCodeAt(0) >= 32); + return ( + typeof value === "string" && + value.length <= maxIdentityLength && + Array.from(value).every((char) => char.charCodeAt(0) >= 32) + ); } -function contextKey(workspaceId: string) { return `${prefix}context:${workspaceId}`; } +function contextKey(workspaceId: string) { + return `${prefix}context:${workspaceId}`; +} function draftKey(workspaceId: string, projectId: string, threadId: string) { return `${prefix}draft:${JSON.stringify([workspaceId, projectId, threadId])}`; } @@ -38,27 +74,67 @@ function pendingKey(workspaceId: string, projectId: string, threadId: string) { } /** The exact payload of a request; a request that starts from a trace row has that row in it, any other request keeps its old signature. */ -export function submissionSignature(projectId: string, threadId: string, problem: string, selection: PendingModelSelection | null, origin: PendingOrigin | null = null, responseLanguage?: ResponseLanguage): string { - if (responseLanguage !== undefined) return JSON.stringify(["response-language:v2", submissionSignature(projectId, threadId, problem, selection, origin), responseLanguage]); - return JSON.stringify([projectId, threadId, problem, selection ? [selection.provider, selection.model, selection.reasoning_effort ?? null] : null, - ...(origin ? [[origin.kind, origin.project_id, origin.subject_kind, origin.subject_id, origin.verdict_revision]] : [])]); +export function submissionSignature( + projectId: string, + threadId: string, + problem: string, + selection: PendingModelSelection | null, + origin: PendingOrigin | null = null, + responseLanguage?: ResponseLanguage, +): string { + if (responseLanguage !== undefined) + return JSON.stringify([ + "response-language:v2", + submissionSignature(projectId, threadId, problem, selection, origin), + responseLanguage, + ]); + return JSON.stringify([ + projectId, + threadId, + problem, + selection ? [selection.provider, selection.model, selection.reasoning_effort ?? null] : null, + ...(origin + ? [ + [ + origin.kind, + origin.project_id, + origin.subject_kind, + origin.subject_id, + origin.verdict_revision, + ], + ] + : []), + ]); } export function inspectLocalContext(workspaceId: string | null): ContextReadResult { if (!validWorkspaceId(workspaceId)) return { kind: "UNAVAILABLE", context: { ...emptyContext } }; let raw: string | null; - try { raw = localStorage.getItem(contextKey(workspaceId)); } - catch { return { kind: "UNAVAILABLE", context: { ...emptyContext } }; } + try { + raw = localStorage.getItem(contextKey(workspaceId)); + } catch { + return { kind: "UNAVAILABLE", context: { ...emptyContext } }; + } if (raw === null) return { kind: "MISSING", context: { ...emptyContext } }; try { const value: unknown = JSON.parse(raw); - if (value && typeof value === "object" && "version" in value && value.version === 1 && - "workspaceId" in value && value.workspaceId === workspaceId && - "projectId" in value && validRecordId(value.projectId) && - "threadId" in value && validRecordId(value.threadId)) { + if ( + value && + typeof value === "object" && + "version" in value && + value.version === 1 && + "workspaceId" in value && + value.workspaceId === workspaceId && + "projectId" in value && + validRecordId(value.projectId) && + "threadId" in value && + validRecordId(value.threadId) + ) { return { kind: "VALID", context: { projectId: value.projectId, threadId: value.threadId } }; } - } catch { /* Preserve the raw record for an explicit user choice. */ } + } catch { + /* Preserve the raw record for an explicit user choice. */ + } return { kind: "CORRUPT", context: { ...emptyContext } }; } @@ -67,101 +143,262 @@ export function readLocalContext(workspaceId: string | null): WorkspaceContext { } export function writeLocalContext(workspaceId: string | null, context: WorkspaceContext): boolean { - if (!validWorkspaceId(workspaceId) || !validRecordId(context.projectId) || !validRecordId(context.threadId)) return false; + if ( + !validWorkspaceId(workspaceId) || + !validRecordId(context.projectId) || + !validRecordId(context.threadId) + ) + return false; try { - localStorage.setItem(contextKey(workspaceId), JSON.stringify({ version: 1, workspaceId, ...context })); + localStorage.setItem( + contextKey(workspaceId), + JSON.stringify({ version: 1, workspaceId, ...context }), + ); return true; - } catch { return false; } + } catch { + return false; + } } -export function inspectLocalDraft(workspaceId: string | null, projectId: string, threadId: string): DraftReadResult { - if (!validWorkspaceId(workspaceId) || !validRecordId(projectId) || !validRecordId(threadId)) return { kind: "UNAVAILABLE", text: "" }; +export function inspectLocalDraft( + workspaceId: string | null, + projectId: string, + threadId: string, +): DraftReadResult { + if (!validWorkspaceId(workspaceId) || !validRecordId(projectId) || !validRecordId(threadId)) + return { kind: "UNAVAILABLE", text: "" }; let raw: string | null; - try { raw = localStorage.getItem(draftKey(workspaceId, projectId, threadId)); } - catch { return { kind: "UNAVAILABLE", text: "" }; } + try { + raw = localStorage.getItem(draftKey(workspaceId, projectId, threadId)); + } catch { + return { kind: "UNAVAILABLE", text: "" }; + } if (raw === null) return { kind: "MISSING", text: "" }; try { const value: unknown = JSON.parse(raw); - if (value && typeof value === "object" && "version" in value && value.version === 1 && - "workspaceId" in value && value.workspaceId === workspaceId && - "projectId" in value && value.projectId === projectId && - "threadId" in value && value.threadId === threadId && - "text" in value && typeof value.text === "string" && value.text.length <= maxDraftLength) return { kind: "VALID", text: value.text }; - } catch { /* Keep the current editor usable and preserve the raw record. */ } + if ( + value && + typeof value === "object" && + "version" in value && + value.version === 1 && + "workspaceId" in value && + value.workspaceId === workspaceId && + "projectId" in value && + value.projectId === projectId && + "threadId" in value && + value.threadId === threadId && + "text" in value && + typeof value.text === "string" && + value.text.length <= maxDraftLength + ) + return { kind: "VALID", text: value.text }; + } catch { + /* Keep the current editor usable and preserve the raw record. */ + } return { kind: "CORRUPT", text: "" }; } -export function readLocalDraft(workspaceId: string | null, projectId: string, threadId: string): string { +export function readLocalDraft( + workspaceId: string | null, + projectId: string, + threadId: string, +): string { return inspectLocalDraft(workspaceId, projectId, threadId).text; } -export function writeLocalDraft(workspaceId: string | null, projectId: string, threadId: string, text: string): DraftWriteResult { +export function writeLocalDraft( + workspaceId: string | null, + projectId: string, + threadId: string, + text: string, +): DraftWriteResult { if (text.length > maxDraftLength) return "TOO_LARGE"; - if (!validWorkspaceId(workspaceId) || !validRecordId(projectId) || !validRecordId(threadId)) return "UNAVAILABLE"; + if (!validWorkspaceId(workspaceId) || !validRecordId(projectId) || !validRecordId(threadId)) + return "UNAVAILABLE"; try { const key = draftKey(workspaceId, projectId, threadId); - if (text) localStorage.setItem(key, JSON.stringify({ version: 1, workspaceId, projectId, threadId, text })); + if (text) + localStorage.setItem( + key, + JSON.stringify({ version: 1, workspaceId, projectId, threadId, text }), + ); else localStorage.removeItem(key); return "SAVED"; - } catch { return "UNAVAILABLE"; } + } catch { + return "UNAVAILABLE"; + } } -export function readLocalPending(workspaceId: string | null, projectId: string, threadId: string, storage?: Storage): PendingRead { - if (!validWorkspaceId(workspaceId) || !projectId || !validRecordId(projectId) || !validRecordId(threadId)) return { kind: "NONE" }; +export function readLocalPending( + workspaceId: string | null, + projectId: string, + threadId: string, + storage?: Storage, +): PendingRead { + if ( + !validWorkspaceId(workspaceId) || + !projectId || + !validRecordId(projectId) || + !validRecordId(threadId) + ) + return { kind: "NONE" }; try { const raw = (storage ?? localStorage).getItem(pendingKey(workspaceId, projectId, threadId)); if (raw === null) return { kind: "NONE" }; if (raw.length > maxPendingLength) return { kind: "INVALID" }; const value: unknown = JSON.parse(raw); - if (!value || typeof value !== "object" || !("version" in value) || (value.version !== 1 && value.version !== 2) || - !Object.keys(value).every(key => ["version", "workspaceId", "projectId", "threadId", "problem", "selection", "origin", "signature", "key", "createdAt", ...(value.version === 2 ? ["responseLanguage"] : [])].includes(key)) || - !("workspaceId" in value) || value.workspaceId !== workspaceId || - !("projectId" in value) || value.projectId !== projectId || - !("threadId" in value) || value.threadId !== threadId || - !("problem" in value) || typeof value.problem !== "string" || value.problem.length > maxDraftLength || - !("selection" in value) || !(value.selection === null || validPendingSelection(value.selection)) || + if ( + !value || + typeof value !== "object" || + !("version" in value) || + (value.version !== 1 && value.version !== 2) || + !Object.keys(value).every((key) => + [ + "version", + "workspaceId", + "projectId", + "threadId", + "problem", + "selection", + "origin", + "signature", + "key", + "createdAt", + ...(value.version === 2 ? ["responseLanguage"] : []), + ].includes(key), + ) || + !("workspaceId" in value) || + value.workspaceId !== workspaceId || + !("projectId" in value) || + value.projectId !== projectId || + !("threadId" in value) || + value.threadId !== threadId || + !("problem" in value) || + typeof value.problem !== "string" || + value.problem.length > maxDraftLength || + !("selection" in value) || + !(value.selection === null || validPendingSelection(value.selection)) || ("origin" in value && !(value.origin === null || validPendingOrigin(value.origin))) || - !("signature" in value) || typeof value.signature !== "string" || - !("key" in value) || typeof value.key !== "string" || !/^[a-f0-9-]{36}$/i.test(value.key) || - !("createdAt" in value) || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return { kind: "INVALID" }; - if (value.version === 2 && (!("responseLanguage" in value) || !isResponseLanguage(value.responseLanguage))) return { kind: "INVALID" }; + !("signature" in value) || + typeof value.signature !== "string" || + !("key" in value) || + typeof value.key !== "string" || + !/^[a-f0-9-]{36}$/i.test(value.key) || + !("createdAt" in value) || + typeof value.createdAt !== "number" || + !Number.isFinite(value.createdAt) + ) + return { kind: "INVALID" }; + if ( + value.version === 2 && + (!("responseLanguage" in value) || !isResponseLanguage(value.responseLanguage)) + ) + return { kind: "INVALID" }; const pending = value as PendingSubmission; - return pending.signature === submissionSignature(projectId, threadId, pending.problem, pending.selection, pending.origin ?? null, pending.responseLanguage) - ? { kind: "PENDING", value: pending } : { kind: "INVALID" }; - } catch { return { kind: "INVALID" }; } + return pending.signature === + submissionSignature( + projectId, + threadId, + pending.problem, + pending.selection, + pending.origin ?? null, + pending.responseLanguage, + ) + ? { kind: "PENDING", value: pending } + : { kind: "INVALID" }; + } catch { + return { kind: "INVALID" }; + } } function validPendingOrigin(value: unknown): value is PendingOrigin { - const text = (item: unknown, max: number) => typeof item === "string" && item.length > 0 && item.length <= max; - return Boolean(value && typeof value === "object" && !Array.isArray(value) && - Object.keys(value).every(key => ["kind", "project_id", "subject_kind", "subject_id", "verdict_revision"].includes(key)) && - "kind" in value && value.kind === "TRACE_VERDICT" && "project_id" in value && text(value.project_id, maxIdentityLength) && - "subject_kind" in value && (value.subject_kind === "CRITERION" || value.subject_kind === "REQUIREMENT") && - "subject_id" in value && text(value.subject_id, 500) && "verdict_revision" in value && text(value.verdict_revision, 64)); + const text = (item: unknown, max: number) => + typeof item === "string" && item.length > 0 && item.length <= max; + return Boolean( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).every((key) => + ["kind", "project_id", "subject_kind", "subject_id", "verdict_revision"].includes(key), + ) && + "kind" in value && + value.kind === "TRACE_VERDICT" && + "project_id" in value && + text(value.project_id, maxIdentityLength) && + "subject_kind" in value && + (value.subject_kind === "CRITERION" || value.subject_kind === "REQUIREMENT") && + "subject_id" in value && + text(value.subject_id, 500) && + "verdict_revision" in value && + text(value.verdict_revision, 64), + ); } function validPendingSelection(value: unknown): value is PendingModelSelection { - return Boolean(value && typeof value === "object" && !Array.isArray(value) && - Object.keys(value).every(key => ["provider", "model", "reasoning_effort"].includes(key)) && - "provider" in value && typeof value.provider === "string" && value.provider.length > 0 && value.provider.length <= 160 && - "model" in value && typeof value.model === "string" && value.model.length > 0 && value.model.length <= 160 && - (!("reasoning_effort" in value) || typeof value.reasoning_effort === "string" && value.reasoning_effort.length <= 32)); + return Boolean( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).every((key) => ["provider", "model", "reasoning_effort"].includes(key)) && + "provider" in value && + typeof value.provider === "string" && + value.provider.length > 0 && + value.provider.length <= 160 && + "model" in value && + typeof value.model === "string" && + value.model.length > 0 && + value.model.length <= 160 && + (!("reasoning_effort" in value) || + (typeof value.reasoning_effort === "string" && value.reasoning_effort.length <= 32)), + ); } export function writeLocalPending(value: PendingSubmission, storage?: Storage): DraftWriteResult { if (value.problem.length > maxDraftLength) return "TOO_LARGE"; - if (!validWorkspaceId(value.workspaceId) || !value.projectId || !validRecordId(value.projectId) || !validRecordId(value.threadId) || - !(value.selection === null || validPendingSelection(value.selection)) || !(value.origin == null || validPendingOrigin(value.origin)) || - value.signature !== submissionSignature(value.projectId, value.threadId, value.problem, value.selection, value.origin ?? null, value.responseLanguage)) return "UNAVAILABLE"; + if ( + !validWorkspaceId(value.workspaceId) || + !value.projectId || + !validRecordId(value.projectId) || + !validRecordId(value.threadId) || + !(value.selection === null || validPendingSelection(value.selection)) || + !(value.origin == null || validPendingOrigin(value.origin)) || + value.signature !== + submissionSignature( + value.projectId, + value.threadId, + value.problem, + value.selection, + value.origin ?? null, + value.responseLanguage, + ) + ) + return "UNAVAILABLE"; if (value.version === 2 && !isResponseLanguage(value.responseLanguage)) return "UNAVAILABLE"; const raw = JSON.stringify(value); if (raw.length > maxPendingLength) return "TOO_LARGE"; - try { (storage ?? localStorage).setItem(pendingKey(value.workspaceId, value.projectId, value.threadId), raw); return "SAVED"; } - catch { return "UNAVAILABLE"; } + try { + (storage ?? localStorage).setItem( + pendingKey(value.workspaceId, value.projectId, value.threadId), + raw, + ); + return "SAVED"; + } catch { + return "UNAVAILABLE"; + } } -export function clearLocalPending(workspaceId: string | null, projectId: string, threadId: string, storage?: Storage): boolean { - if (!validWorkspaceId(workspaceId) || !validRecordId(projectId) || !validRecordId(threadId)) return false; - try { (storage ?? localStorage).removeItem(pendingKey(workspaceId, projectId, threadId)); return true; } - catch { return false; } +export function clearLocalPending( + workspaceId: string | null, + projectId: string, + threadId: string, + storage?: Storage, +): boolean { + if (!validWorkspaceId(workspaceId) || !validRecordId(projectId) || !validRecordId(threadId)) + return false; + try { + (storage ?? localStorage).removeItem(pendingKey(workspaceId, projectId, threadId)); + return true; + } catch { + return false; + } } diff --git a/apps/web/src/api/parserError.test.ts b/apps/web/src/api/parserError.test.ts index 9cadbf1f..d4337f39 100644 --- a/apps/web/src/api/parserError.test.ts +++ b/apps/web/src/api/parserError.test.ts @@ -8,11 +8,18 @@ const RAW_CODE = /[A-Z]{3,}_[A-Z_]{3,}/; /** The values of ParserErrorCode, read from the server source. The public schemas under * schemas/protocol do not list them, so this file is the one place the web test can read them from. */ function serverCodes(): string[] { - const source = readFileSync(new URL("../../../../src/thoth/domain/enums.py", import.meta.url), "utf8"); - const block = /class ParserErrorCode\(StrEnum\):\r?\n([\s\S]*?)\r?\n\r?\n/.exec(source)?.[1] ?? ""; - return [...block.matchAll(/^\s+[A-Z_]+ = "([A-Z_]+)"/gm)].map(match => match[1]); + const source = readFileSync( + new URL("../../../../src/thoth/domain/enums.py", import.meta.url), + "utf8", + ); + const block = + /class ParserErrorCode\(StrEnum\):\r?\n([\s\S]*?)\r?\n\r?\n/.exec(source)?.[1] ?? ""; + return [...block.matchAll(/^\s+[A-Z_]+ = "([A-Z_]+)"/gm)].map((match) => match[1]); } -const failure = (code: unknown) => ({ details: { parser_error: code }, message: "source parsing failed [-32001]" }); +const failure = (code: unknown) => ({ + details: { parser_error: code }, + message: "source parsing failed [-32001]", +}); afterEach(() => setLocale("ko")); @@ -23,15 +30,18 @@ describe("parser error sentences", () => { expect([...PARSER_ERROR_CODES].sort()).toEqual([...server].sort()); }); - it.each(["ko", "en"] as const)("gives each code a plain %s sentence without the raw code", locale => { - setLocale(locale); - for (const code of PARSER_ERROR_CODES) { - const sentence = parserFailureText(failure(code))!.text; - expect(sentence.length, code).toBeGreaterThan(15); - expect(sentence, code).not.toMatch(RAW_CODE); - expect(sentence, code).toBe(t(`parserError.${code}` as MessageKey)); - } - }); + it.each(["ko", "en"] as const)( + "gives each code a plain %s sentence without the raw code", + (locale) => { + setLocale(locale); + for (const code of PARSER_ERROR_CODES) { + const sentence = parserFailureText(failure(code))!.text; + expect(sentence.length, code).toBeGreaterThan(15); + expect(sentence, code).not.toMatch(RAW_CODE); + expect(sentence, code).toBe(t(`parserError.${code}` as MessageKey)); + } + }, + ); it("tells the person what to do about an encoding that cannot be confirmed", () => { expect(parserFailureText(failure("TEXT_ENCODING_UNCONFIRMED"))).toEqual({ @@ -39,7 +49,9 @@ describe("parser error sentences", () => { text: "글자 인코딩을 확인하지 못했습니다. 파일을 UTF-8로 저장해 다시 올려 주세요.", }); setLocale("en"); - expect(parserFailureText(failure("TEXT_ENCODING_UNCONFIRMED"))!.text).toContain("Save the file as UTF-8"); + expect(parserFailureText(failure("TEXT_ENCODING_UNCONFIRMED"))!.text).toContain( + "Save the file as UTF-8", + ); }); it("reads a code it has no sentence for as the general sentence and keeps the code", () => { @@ -48,8 +60,15 @@ describe("parser error sentences", () => { expect(result.text).toBe(t("parserError.unknown")); }); - it.each([undefined, null, "text", { details: null }, { details: {} }, { details: { parser_error: "" } }, failure(7)])( - "returns nothing when the error carries no parser reason (%j)", - error => expect(parserFailureText(error)).toBeNull(), + it.each([ + undefined, + null, + "text", + { details: null }, + { details: {} }, + { details: { parser_error: "" } }, + failure(7), + ])("returns nothing when the error carries no parser reason (%j)", (error) => + expect(parserFailureText(error)).toBeNull(), ); }); diff --git a/apps/web/src/api/parserError.ts b/apps/web/src/api/parserError.ts index f3ad3b9f..80cd76e8 100644 --- a/apps/web/src/api/parserError.ts +++ b/apps/web/src/api/parserError.ts @@ -6,8 +6,8 @@ const UNKNOWN_KEY = "parserError.unknown" satisfies MessageKey; /** The parser reason codes the screen has a sentence for. */ export const PARSER_ERROR_CODES: readonly string[] = Object.keys(messages) - .filter(key => key.startsWith(PREFIX) && key !== UNKNOWN_KEY) - .map(key => key.slice(PREFIX.length)); + .filter((key) => key.startsWith(PREFIX) && key !== UNKNOWN_KEY) + .map((key) => key.slice(PREFIX.length)); export type ParserFailureText = { code: string; text: string }; @@ -15,7 +15,13 @@ export type ParserFailureText = { code: string; text: string }; * the general sentence; the code itself stays available for the technical details. */ export function parserFailureText(error: unknown): ParserFailureText | null { const details = (error as { details?: unknown } | null)?.details; - const code = details && typeof details === "object" ? (details as Record).parser_error : undefined; + const code = + details && typeof details === "object" + ? (details as Record).parser_error + : undefined; if (typeof code !== "string" || code === "") return null; - return { code, text: t(PARSER_ERROR_CODES.includes(code) ? PREFIX + code as MessageKey : UNKNOWN_KEY) }; + return { + code, + text: t(PARSER_ERROR_CODES.includes(code) ? ((PREFIX + code) as MessageKey) : UNKNOWN_KEY), + }; } diff --git a/apps/web/src/api/presentation.ts b/apps/web/src/api/presentation.ts index 6f064da2..a5744dd2 100644 --- a/apps/web/src/api/presentation.ts +++ b/apps/web/src/api/presentation.ts @@ -1,8 +1,19 @@ -export const objectValue = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -export const objectList = (value: unknown): Record[] => Array.isArray(value) ? value.map(objectValue) : []; -export const textValue = (value: unknown): string => typeof value === "string" ? value : ""; -export const stringValues = (value: unknown): string[] => Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; +export const objectValue = (value: unknown): Record => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +export const objectList = (value: unknown): Record[] => + Array.isArray(value) ? value.map(objectValue) : []; +export const textValue = (value: unknown): string => (typeof value === "string" ? value : ""); +export const stringValues = (value: unknown): string[] => + Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; -export function hasResearchContent(result: Record | null | undefined): boolean { - return Boolean(result && (textValue(result.answer).trim() || objectList(objectValue(result.portfolio).hypotheses).length || objectList(objectValue(result.action_plan).alternatives).length || stringValues(objectValue(result.assessment).missing_items).length)); +export function hasResearchContent(result: Record | null | undefined): boolean { + return Boolean( + result && + (textValue(result.answer).trim() || + objectList(objectValue(result.portfolio).hypotheses).length || + objectList(objectValue(result.action_plan).alternatives).length || + stringValues(objectValue(result.assessment).missing_items).length), + ); } diff --git a/apps/web/src/api/progressEvidenceLocations.test.tsx b/apps/web/src/api/progressEvidenceLocations.test.tsx index 5612cf15..bb38ae69 100644 --- a/apps/web/src/api/progressEvidenceLocations.test.tsx +++ b/apps/web/src/api/progressEvidenceLocations.test.tsx @@ -7,22 +7,57 @@ import { composerActivity, evidenceLabels, liveResearchProgress } from "./resear afterEach(() => setLocale("ko")); -function source(id: string, locator: UserActivityTarget["locator"], access: UserActivityTarget["access_state"] = "allowed"): UserActivityEvent { - return { schema_version: "thoth.user_activity_event.v1", event_id: `event:${id}`, seq: 1, - severity: "info", visibility: "default", announce: "none", activity_kind: "source", action: "read", - label_ko: "연결된 자료를 읽었습니다.", state: "succeeded", research_relation: "read", +function source( + id: string, + locator: UserActivityTarget["locator"], + access: UserActivityTarget["access_state"] = "allowed", +): UserActivityEvent { + return { + schema_version: "thoth.user_activity_event.v1", + event_id: `event:${id}`, + seq: 1, + severity: "info", + visibility: "default", + announce: "none", + activity_kind: "source", + action: "read", + label_ko: "연결된 자료를 읽었습니다.", + state: "succeeded", + research_relation: "read", target: { kind: "span", title: "연결된 자료", locator, access_state: access }, - refs: { source_ref: `source:${id}` }, redaction: { applied: true, source_content_included: false }, + refs: { source_ref: `source:${id}` }, + redaction: { applied: true, source_content_included: false }, }; } function status(text: string, activity?: UserActivityEvent[]): ResearchStatus { - return { project_id: "p", thread_id: "t", cycle_id: "c", problem: "Inspect the test result", - lifecycle: "ACTIVE", execution_state: "RUNNING", current_object_ids: [], working_head_digest: "0".repeat(64), - operation_state: "RUNNING", attempt: { operation_id: "o", phase: "EVIDENCE_FOCUS", status: "RUNNING", - draft_progress: { evidence_focus: { locators: [{ span_id: "span:1", page: 6, exact_text: text }], total: 1 } } }, + return { + project_id: "p", + thread_id: "t", + cycle_id: "c", + problem: "Inspect the test result", + lifecycle: "ACTIVE", + execution_state: "RUNNING", + current_object_ids: [], + working_head_digest: "0".repeat(64), + operation_state: "RUNNING", + attempt: { + operation_id: "o", + phase: "EVIDENCE_FOCUS", + status: "RUNNING", + draft_progress: { + evidence_focus: { locators: [{ span_id: "span:1", page: 6, exact_text: text }], total: 1 }, + }, + }, completed_stages: [{ role: "EVIDENCE_RERANKER", state: "COMPLETED" }], - activity_events: [{ seq: 7, kind: "action", action_type: "source_read", payload: { span_id: "span:1", page: 6, exact_text: text } }], + activity_events: [ + { + seq: 7, + kind: "action", + action_type: "source_read", + payload: { span_id: "span:1", page: 6, exact_text: text }, + }, + ], ...(activity === undefined ? {} : { user_activity_events: activity }), }; } @@ -34,17 +69,19 @@ const texts = [ "일반 사용자 파일의 긴 첫 줄과 표 설명입니다. ".repeat(12), ]; -describe.each(["ko", "en"] as const)("progress-only source presentation (%s)", language => { - it.each(texts)("does not render the original excerpt: %s", text => { +describe.each(["ko", "en"] as const)("progress-only source presentation (%s)", (language) => { + it.each(texts)("does not render the original excerpt: %s", (text) => { setLocale(language); const input = status(text, [source("1", { line_start: 7, line_end: 7 })]); const saved = structuredClone(input); const live = liveResearchProgress(input)!; expect(live.evidenceLabels).toEqual([language === "ko" ? "7행" : "line 7"]); - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).not.toContain(text.split("\n")[0]); expect(JSON.stringify(live)).not.toContain(text.split("\n")[0]); - expect(live.events.find(event => event.actionType === "source_read")?.payload).toBe(language === "ko" ? "위치 미확인" : "Location unavailable"); + expect(live.events.find((event) => event.actionType === "source_read")?.payload).toBe( + language === "ko" ? "위치 미확인" : "Location unavailable", + ); expect(composerActivity(input)).toBe(live.headline); expect(input).toEqual(saved); }); @@ -54,29 +91,49 @@ describe.each(["ko", "en"] as const)("progress-only source presentation (%s)", l const input = status("ORIGINAL_SOURCE_MUST_STAY_STORED"); expect(evidenceLabels(input)).toEqual(["p.6"]); const live = liveResearchProgress(input)!; - expect(live.events.find(event => event.actionType === "source_read")).toMatchObject({ id: "event-action-7", payload: "p.6" }); + expect(live.events.find((event) => event.actionType === "source_read")).toMatchObject({ + id: "event-action-7", + payload: "p.6", + }); expect(JSON.stringify(live)).not.toContain("ORIGINAL_SOURCE_MUST_STAY_STORED"); - input.attempt!.draft_progress = { evidence_focus: { locators: [{ exact_text: "ORIGINAL_SOURCE_MUST_STAY_STORED" }], total: 1 } }; - expect(evidenceLabels(input)).toEqual([language === "ko" ? "원문 위치 1" : "Source location 1"]); + input.attempt!.draft_progress = { + evidence_focus: { locators: [{ exact_text: "ORIGINAL_SOURCE_MUST_STAY_STORED" }], total: 1 }, + }; + expect(evidenceLabels(input)).toEqual([ + language === "ko" ? "원문 위치 1" : "Source location 1", + ]); }); - it.each(["blocked", "out_of_scope", "unknown"] as const)("does not revive location metadata for %s access", access => { - setLocale(language); - const input = status("SECRET_SOURCE", [source("1", { page: 99, line_start: 777, cell: "A3" }, access)]); - const live = liveResearchProgress(input)!; - expect(live.evidenceLabels).toEqual([language === "ko" ? "위치 미확인" : "Location unavailable"]); - expect(JSON.stringify(live)).not.toMatch(/SECRET_SOURCE|p\.99|777|A3|p\.6/); - }); + it.each(["blocked", "out_of_scope", "unknown"] as const)( + "does not revive location metadata for %s access", + (access) => { + setLocale(language); + const input = status("SECRET_SOURCE", [ + source("1", { page: 99, line_start: 777, cell: "A3" }, access), + ]); + const live = liveResearchProgress(input)!; + expect(live.evidenceLabels).toEqual([ + language === "ko" ? "위치 미확인" : "Location unavailable", + ]); + expect(JSON.stringify(live)).not.toMatch(/SECRET_SOURCE|p\.99|777|A3|p\.6/); + }, + ); }); it("does not match typed sources to legacy source actions by array position", () => { const input = status("ORIGINAL", [source("different", { page: 99 })]); - const action = liveResearchProgress(input)!.events.find(event => event.actionType === "source_read")!; + const action = liveResearchProgress(input)!.events.find( + (event) => event.actionType === "source_read", + )!; expect(action).toMatchObject({ id: "event-action-7", payload: "위치 미확인" }); }); it("keeps distinct sources with the same location and deduplicates repeated identity", () => { - const input = status("ORIGINAL", [source("a", { line_start: 7 }), source("b", { line_start: 7 }), source("a", { line_start: 7 })]); + const input = status("ORIGINAL", [ + source("a", { line_start: 7 }), + source("b", { line_start: 7 }), + source("a", { line_start: 7 }), + ]); expect(evidenceLabels(input)).toEqual(["7행", "7행"]); }); @@ -86,17 +143,30 @@ it("treats conflicting or denied copies of the same source conservatively", () = }); it("uses at most two bounded locations in both current and completed-stage titles", () => { - const input = status("ORIGINAL", [source("a", { page: 1 }), source("b", { page: 2 }), source("c", { page: 3 })]); + const input = status("ORIGINAL", [ + source("a", { page: 1 }), + source("b", { page: 2 }), + source("c", { page: 3 }), + ]); input.attempt!.draft_progress = { evidence_focus: { total: 18, truncated: true } }; const live = liveResearchProgress(input)!; expect(live.evidenceLabels).toEqual(["p.1", "p.2", "p.3"]); expect(live.headline).toContain("p.1, p.2"); expect(live.headline).not.toContain("p.3"); - expect(live.events.filter(e => e.kind === "note" && !e.live).every(e => !e.text.includes("p.3"))).toBe(true); - const html = renderToStaticMarkup(); + expect( + live.events.filter((e) => e.kind === "note" && !e.live).every((e) => !e.text.includes("p.3")), + ).toBe(true); + const html = renderToStaticMarkup(); expect(html).toContain("외 1곳"); expect(html).toContain("전체 18곳 중 3곳 표시"); - input.user_activity_events = [source("large", { page: Number.MAX_SAFE_INTEGER, line_start: 1, line_end: Number.MAX_SAFE_INTEGER, cell: `AAAA${"9".repeat(60)}` })]; + input.user_activity_events = [ + source("large", { + page: Number.MAX_SAFE_INTEGER, + line_start: 1, + line_end: Number.MAX_SAFE_INTEGER, + cell: `AAAA${"9".repeat(60)}`, + }), + ]; expect(evidenceLabels(input)[0].length).toBeLessThanOrEqual(64); expect(evidenceLabels(input)[0]).toMatch(/…$/); }); @@ -104,7 +174,9 @@ it("uses at most two bounded locations in both current and completed-stage title it("does not turn missing typed metadata into an empty corpus or use unsafe fields", () => { const input = status("ORIGINAL", []); expect(evidenceLabels(input)).toEqual(["위치 미확인"]); - input.user_activity_events = [source("bad", { page: NaN, line_start: -2, cell: "A1; ORIGINAL", section: "ORIGINAL" })]; + input.user_activity_events = [ + source("bad", { page: NaN, line_start: -2, cell: "A1; ORIGINAL", section: "ORIGINAL" }), + ]; input.user_activity_events[0].target!.title = "ORIGINAL"; input.user_activity_events[0].target!.display_ref = "ORIGINAL"; expect(JSON.stringify(liveResearchProgress(input))).not.toContain("ORIGINAL"); @@ -115,17 +187,45 @@ it("does not turn missing typed metadata into an empty corpus or use unsafe fiel it("does not reread exact_text when rendering the old response", () => { const input = status("ORIGINAL"); - input.attempt!.draft_progress = { evidence_focus: { total: 1, locators: [{ page: 6, get exact_text() { throw new Error("Original source was read by progress UI"); } }] } }; + input.attempt!.draft_progress = { + evidence_focus: { + total: 1, + locators: [ + { + page: 6, + get exact_text() { + throw new Error("Original source was read by progress UI"); + }, + }, + ], + }, + }; expect(evidenceLabels(input)).toEqual(["p.6"]); }); -it.each(["ko", "en"] as const)("formats validated page, line range and cell coordinates (%s)", language => { - setLocale(language); - const input = status("ORIGINAL", [source("range", { page: 6, line_start: 2, line_end: 5, cell: "$A$1:C3", section: "ORIGINAL" })]); - expect(evidenceLabels(input)).toEqual([language === "ko" ? "p.6 · 2~5행 · $A$1:C3" : "p.6 · lines 2–5 · $A$1:C3"]); -}); +it.each(["ko", "en"] as const)( + "formats validated page, line range and cell coordinates (%s)", + (language) => { + setLocale(language); + const input = status("ORIGINAL", [ + source("range", { + page: 6, + line_start: 2, + line_end: 5, + cell: "$A$1:C3", + section: "ORIGINAL", + }), + ]); + expect(evidenceLabels(input)).toEqual([ + language === "ko" ? "p.6 · 2~5행 · $A$1:C3" : "p.6 · lines 2–5 · $A$1:C3", + ]); + }, +); it("does not trust an unsupported typed schema or fall back to its old page", () => { - const event = { ...source("next", { page: 99 }), schema_version: "unknown-version" } as unknown as UserActivityEvent; + const event = { + ...source("next", { page: 99 }), + schema_version: "unknown-version", + } as unknown as UserActivityEvent; expect(evidenceLabels(status("ORIGINAL", [event]))).toEqual(["위치 미확인"]); }); diff --git a/apps/web/src/api/progressEvidenceLocations.ts b/apps/web/src/api/progressEvidenceLocations.ts index ccf8c25e..86028897 100644 --- a/apps/web/src/api/progressEvidenceLocations.ts +++ b/apps/web/src/api/progressEvidenceLocations.ts @@ -4,7 +4,8 @@ import { objectList, objectValue, textValue } from "./presentation"; import type { ResearchStatus } from "./research"; const unavailable = () => message("progressLocationUnavailable"); -const positiveInteger = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0; +const positiveInteger = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value > 0; const cellPattern = /^\$?[A-Z]{1,4}\$?[1-9][0-9]*(?::\$?[A-Z]{1,4}\$?[1-9][0-9]*)?$/; function location(value: unknown): string | null { @@ -13,16 +14,24 @@ function location(value: unknown): string | null { if (positiveInteger(locator.page)) parts.push(`p.${locator.page}`); if (positiveInteger(locator.line_start)) { const end = locator.line_end ?? locator.line_start; - if (positiveInteger(end) && end >= locator.line_start) parts.push(message("progressLineRange", locator.line_start, end)); + if (positiveInteger(end) && end >= locator.line_start) + parts.push(message("progressLineRange", locator.line_start, end)); } - if (typeof locator.cell === "string" && locator.cell.length <= 128 && cellPattern.test(locator.cell)) parts.push(locator.cell); + if ( + typeof locator.cell === "string" && + locator.cell.length <= 128 && + cellPattern.test(locator.cell) + ) + parts.push(locator.cell); const text = parts.join(" · "); - return text ? text.length > 64 ? `${text.slice(0, 63)}…` : text : null; + return text ? (text.length > 64 ? `${text.slice(0, 63)}…` : text) : null; } function legacyLocators(status?: ResearchStatus): Record[] { const focus = objectValue(objectValue(status?.attempt?.draft_progress).evidence_focus); - return objectList(focus.locators).filter(item => "page" in item || "span_id" in item || "exact_text" in item); + return objectList(focus.locators).filter( + (item) => "page" in item || "span_id" in item || "exact_text" in item, + ); } function legacyLocation(value: Record, index: number): string { @@ -34,12 +43,24 @@ export function progressEvidenceLabels(status?: ResearchStatus): string[] { if (status?.user_activity_events === undefined) return legacy.map(legacyLocation); const sources = new Map(); for (const [index, event] of objectList(status.user_activity_events).entries()) { - if (event.schema_version !== "thoth.user_activity_event.v1" || event.activity_kind !== "source" || event.action !== "read") continue; + if ( + event.schema_version !== "thoth.user_activity_event.v1" || + event.activity_kind !== "source" || + event.action !== "read" + ) + continue; const target = objectValue(event.target); - const label = target.kind === "span" && target.access_state === "allowed" ? location(target.locator) ?? unavailable() : unavailable(); + const label = + target.kind === "span" && target.access_state === "allowed" + ? (location(target.locator) ?? unavailable()) + : unavailable(); const sourceRef = textValue(objectValue(event.refs).source_ref); const eventId = textValue(event.event_id); - const key = sourceRef ? `source:${sourceRef}` : eventId ? `event:${eventId}` : `unknown:${index}`; + const key = sourceRef + ? `source:${sourceRef}` + : eventId + ? `event:${eventId}` + : `unknown:${index}`; const previous = sources.get(key); // A contradictory or denied copy must not be overwritten by an allowed one. sources.set(key, previous !== undefined && previous !== label ? unavailable() : label); @@ -48,7 +69,11 @@ export function progressEvidenceLabels(status?: ResearchStatus): string[] { return sources.size ? [...sources.values()] : legacy.map(unavailable); } -export function progressSourceActionLocation(status: ResearchStatus, payload: Record, index: number): string { +export function progressSourceActionLocation( + status: ResearchStatus, + payload: Record, + index: number, +): string { // Legacy actions name raw span IDs; typed events expose aliases. Do not zip them by order. return status.user_activity_events === undefined ? legacyLocation(payload, index) : unavailable(); } diff --git a/apps/web/src/api/projectActionFailure.ts b/apps/web/src/api/projectActionFailure.ts index 17689780..591fba54 100644 --- a/apps/web/src/api/projectActionFailure.ts +++ b/apps/web/src/api/projectActionFailure.ts @@ -5,15 +5,20 @@ import { RpcError } from "./rpcClient"; const REVISION_MISMATCH = -32030; /** An onError handler: a refused project action usually means the list is stale, so read it again. */ -export const rereadProjects = (client: QueryClient) => () => { void client.invalidateQueries({ queryKey: ["projects"] }); }; +export const rereadProjects = (client: QueryClient) => () => { + void client.invalidateQueries({ queryKey: ["projects"] }); +}; /** * A refused project action (archive, restore) in words a person can act on. Re-evaluated on every * render so a language change applies. A revision mismatch means the screen held an older copy of * the project; the caller has already asked for the list again, and nothing is retried here. */ -export function projectActionFailure(error: unknown, fallbackKey: "archive.archiveFailed" | "archive.restoreFailed" | "manage.renameFailed"): string { - if (error instanceof RpcError && error.code === REVISION_MISMATCH) return t("archive.revisionChanged"); +export function projectActionFailure( + error: unknown, + fallbackKey: "archive.archiveFailed" | "archive.restoreFailed" | "manage.renameFailed", +): string { + if (error instanceof RpcError && error.code === REVISION_MISMATCH) + return t("archive.revisionChanged"); return t(fallbackKey); } - diff --git a/apps/web/src/api/projectCreation.ts b/apps/web/src/api/projectCreation.ts index 782a84d5..17114adc 100644 --- a/apps/web/src/api/projectCreation.ts +++ b/apps/web/src/api/projectCreation.ts @@ -1,7 +1,12 @@ export function captureProjectCreation(name: string, cutoff: string, overlay: string) { return { key: crypto.randomUUID(), - input: {project_id:`project:web-${crypto.randomUUID()}`,name:name.trim(),cutoff_at:new Date(cutoff).toISOString(),overlay:overlay.trim()}, + input: { + project_id: `project:web-${crypto.randomUUID()}`, + name: name.trim(), + cutoff_at: new Date(cutoff).toISOString(), + overlay: overlay.trim(), + }, }; // Omit policy_binding_ref: the backend's policy:default expands to a project-unique binding. } diff --git a/apps/web/src/api/projectDeletion.ts b/apps/web/src/api/projectDeletion.ts index cc1d5a16..a6a76e93 100644 --- a/apps/web/src/api/projectDeletion.ts +++ b/apps/web/src/api/projectDeletion.ts @@ -21,7 +21,8 @@ export function permanentDeleteFailure(error: unknown): string { const reason = String(error.details.reason_code ?? ""); if (reason === "PROJECT_NOT_ARCHIVED") return t("archive.deleteNotArchived"); if (reason === "PROJECT_NAME_MISMATCH") return t("archive.deleteNameMismatch"); - if (reason === "PROJECT_HAS_ACTIVE_OPERATION" || reason === "PROJECT_EMBEDDING_JOB_RUNNING") return t("archive.deleteActive"); + if (reason === "PROJECT_HAS_ACTIVE_OPERATION" || reason === "PROJECT_EMBEDDING_JOB_RUNNING") + return t("archive.deleteActive"); if (reason === "PROJECT_REFERENCED_BY_OTHER_PROJECTS") { const ids = error.details.referencing_project_ids; return message("deleteReferenced", Array.isArray(ids) ? ids.map(String).join(", ") : ""); @@ -32,4 +33,5 @@ export function permanentDeleteFailure(error: unknown): string { return t("archive.deleteFailed"); } -export const projectAlreadyGone = (error: unknown) => error instanceof RpcError && error.code === PROJECT_NOT_FOUND; +export const projectAlreadyGone = (error: unknown) => + error instanceof RpcError && error.code === PROJECT_NOT_FOUND; diff --git a/apps/web/src/api/protocol.ts b/apps/web/src/api/protocol.ts index 4dfb53d8..df91f63a 100644 --- a/apps/web/src/api/protocol.ts +++ b/apps/web/src/api/protocol.ts @@ -1,39 +1,40 @@ -import { z } from 'zod' +import { z } from "zod"; -export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } +export type JsonValue = + null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; export const rpcMetaSchema = z.object({ idempotencyKey: z.string().min(1).max(260), expectedHeadDigest: z.string().nullable().optional(), progressToken: z.string().nullable().optional(), -}) +}); export const jsonRpcRequestSchema = z.object({ - jsonrpc: z.literal('2.0').default('2.0'), + jsonrpc: z.literal("2.0").default("2.0"), id: z.union([z.string(), z.number().int()]), method: z.string().min(1).max(160), params: z.object({ _meta: rpcMetaSchema, input: z.record(z.string(), z.unknown()), }), -}) +}); export const jsonRpcErrorSchema = z.object({ code: z.number().int(), message: z.string(), data: z.record(z.string(), z.unknown()).default({}), -}) +}); export const jsonRpcResponseSchema = z .object({ - jsonrpc: z.literal('2.0'), + jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number().int()]).nullable(), result: z.record(z.string(), z.unknown()).optional(), error: jsonRpcErrorSchema.optional(), }) .refine((value) => Number(value.result !== undefined) + Number(value.error !== undefined) === 1, { - message: 'exactly one of result or error is required', - }) + message: "exactly one of result or error is required", + }); -export type JsonRpcRequest = z.infer -export type JsonRpcResponse = z.infer +export type JsonRpcRequest = z.infer; +export type JsonRpcResponse = z.infer; diff --git a/apps/web/src/api/publicWeb.test.ts b/apps/web/src/api/publicWeb.test.ts index 4883dc52..0233b458 100644 --- a/apps/web/src/api/publicWeb.test.ts +++ b/apps/web/src/api/publicWeb.test.ts @@ -9,7 +9,9 @@ import { describe("public web update contract", () => { it("requires a strict boolean and normalizes hosts", () => { - expect(() => publicWebUpdateInputSchema.parse({ enabled: "true", preferred_hosts: ["arxiv.org"] })).toThrow(); + expect(() => + publicWebUpdateInputSchema.parse({ enabled: "true", preferred_hosts: ["arxiv.org"] }), + ).toThrow(); const parsed = parsePublicWebUpdate({ enabled: true, preferred_hosts: ["https://ArXiv.org/list", "arxiv.org"], @@ -43,7 +45,9 @@ describe("public web update contract", () => { describe("public web execution status", () => { it("keeps typed execution states separate from the saved toggle", () => { - expect(publicWebExecutionSchema.parse({ desired_enabled: true, state: "READY" }).state).toBe("READY"); + expect(publicWebExecutionSchema.parse({ desired_enabled: true, state: "READY" }).state).toBe( + "READY", + ); expect(executionLabel("READY")).toBe("실행: 준비됨"); expect(executionLabel("OFF")).toBe("실행: 꺼짐"); expect(executionLabel("BLOCKED", ["PUBLIC_WEB_POLICY_INCONSISTENT"])).toBe("실행: 권한 불일치"); diff --git a/apps/web/src/api/readAccess.ts b/apps/web/src/api/readAccess.ts index d545ca1e..17cd1ae9 100644 --- a/apps/web/src/api/readAccess.ts +++ b/apps/web/src/api/readAccess.ts @@ -1,7 +1,11 @@ /** Stable error fields only; never infer permission from translated prose. */ const resourceDenials = new Set([ - "RESOURCE_ACCESS_DENIED", "RESOURCE_AUTHORITY_CHANGED", "RESOURCE_SCOPE_PROJECT_MISMATCH", - "RESOURCE_SCOPE_UNKNOWN", "RESOURCE_REFERENCE_UNRESOLVED", "RESOURCE_LINEAGE_UNKNOWN", + "RESOURCE_ACCESS_DENIED", + "RESOURCE_AUTHORITY_CHANGED", + "RESOURCE_SCOPE_PROJECT_MISMATCH", + "RESOURCE_SCOPE_UNKNOWN", + "RESOURCE_REFERENCE_UNRESOLVED", + "RESOURCE_LINEAGE_UNKNOWN", ]); export function isReadAccessDenied(error: unknown): boolean { @@ -10,6 +14,11 @@ export function isReadAccessDenied(error: unknown): boolean { if (value.code === -32040) return true; if (value.kind === "HTTP" && (value.status === 401 || value.status === 403)) return true; const details = value.details; - return Boolean(details && typeof details === "object" && "reason_code" in details - && typeof details.reason_code === "string" && resourceDenials.has(details.reason_code)); + return Boolean( + details && + typeof details === "object" && + "reason_code" in details && + typeof details.reason_code === "string" && + resourceDenials.has(details.reason_code), + ); } diff --git a/apps/web/src/api/recordViews.ts b/apps/web/src/api/recordViews.ts index ecb41b5c..8fc45ace 100644 --- a/apps/web/src/api/recordViews.ts +++ b/apps/web/src/api/recordViews.ts @@ -1,29 +1,242 @@ import { t } from "../i18n/locale"; -export type RecordView = { namespace: string; label: string; description: string; method: string | null; scope?: "thread" | "operation"; note?: string }; +export type RecordView = { + namespace: string; + label: string; + description: string; + method: string | null; + scope?: "thread" | "operation"; + note?: string; +}; /** Dedicated read entrypoints; backend command schemas remain the authority. */ export const recordViews: RecordView[] = [ - {namespace:"project",get label() { return t("workspace.projects"); },get description() { return t("advanced.projectHint"); },method:"project/read"}, - {namespace:"thread",get label() { return t("advanced.task"); },get description() { return t("advanced.taskHint"); },method:"thread/read",scope:"thread"}, - {namespace:"investigation",get label() { return t("advanced.discovery"); },get description() { return t("advanced.discoveryHint"); },method:"investigation/list"}, - {namespace:"evidence",get label() { return t("history.evidence"); },get description() { return t("advanced.evidenceHint"); },method:"evidence/list"}, - {namespace:"object",get label() { return t("advanced.object"); },get description() { return t("advanced.objectHint"); },method:"object/list"}, - {namespace:"criteria",get label() { return t("record.criterion"); },get description() { return t("advanced.criteriaHint"); },method:"criteria/list"}, - {namespace:"hypothesis",get label() { return t("hypotheses.hypothesis"); },get description() { return t("advanced.hypothesisHint"); },method:"hypothesis/list"}, - {namespace:"action",get label() { return t("history.action"); },get description() { return t("advanced.actionHint"); },method:"action/list"}, - {namespace:"execution",get label() { return t("advanced.execution"); },get description() { return t("advanced.executionHint"); },method:"execution/list"}, - {namespace:"outcome",get label() { return t("advanced.outcomes"); },get description() { return t("advanced.outcomesHint"); },method:"outcome/list"}, - {namespace:"revision",get label() { return t("advanced.revisions"); },get description() { return t("advanced.revisionsHint"); },method:"revision/list"}, - {namespace:"memory",get label() { return t("result.memory"); },get description() { return t("advanced.memoryHint"); },method:"memory/list"}, - {namespace:"improvement",get label() { return t("advanced.improvement"); },get description() { return t("advanced.improvementHint"); },method:"improvement/list"}, - {namespace:"receipt",get label() { return t("advanced.receipt"); },get description() { return t("advanced.receiptHint"); },method:"receipt/list"}, - {namespace:"closure",get label() { return t("advanced.closure"); },get description() { return t("advanced.closureHint"); },method:"closure/list"}, - {namespace:"export",get label() { return t("advanced.export"); },get description() { return t("advanced.exportHint"); },method:"export/list"}, - {namespace:"operation",get label() { return t("advanced.async"); },get description() { return t("advanced.asyncHint"); },method:"operation/read",scope:"operation"}, - {namespace:"model",get label() { return t("advanced.models"); },get description() { return t("advanced.modelsHint"); },method:"model/settings/read"}, - {namespace:"trace",get label() { return t("workspace.trace"); },get description() { return t("advanced.traceHint"); },method:"trace/read"}, - {namespace:"workspace",get label() { return t("advanced.workspace"); },get description() { return t("advanced.workspaceHint"); },method:"workspace/setup/read"}, - {namespace:"projectpack",get label() { return t("advanced.examples"); },get description() { return t("advanced.examplesHint"); },method:"projectpack/list",get note() { return t("advanced.examplesNotice"); }}, - {namespace:"field",get label() { return t("advanced.field"); },get description() { return t("advanced.fieldHint"); },method:null,get note() { return t("advanced.fieldNotice"); }}, + { + namespace: "project", + get label() { + return t("workspace.projects"); + }, + get description() { + return t("advanced.projectHint"); + }, + method: "project/read", + }, + { + namespace: "thread", + get label() { + return t("advanced.task"); + }, + get description() { + return t("advanced.taskHint"); + }, + method: "thread/read", + scope: "thread", + }, + { + namespace: "investigation", + get label() { + return t("advanced.discovery"); + }, + get description() { + return t("advanced.discoveryHint"); + }, + method: "investigation/list", + }, + { + namespace: "evidence", + get label() { + return t("history.evidence"); + }, + get description() { + return t("advanced.evidenceHint"); + }, + method: "evidence/list", + }, + { + namespace: "object", + get label() { + return t("advanced.object"); + }, + get description() { + return t("advanced.objectHint"); + }, + method: "object/list", + }, + { + namespace: "criteria", + get label() { + return t("record.criterion"); + }, + get description() { + return t("advanced.criteriaHint"); + }, + method: "criteria/list", + }, + { + namespace: "hypothesis", + get label() { + return t("hypotheses.hypothesis"); + }, + get description() { + return t("advanced.hypothesisHint"); + }, + method: "hypothesis/list", + }, + { + namespace: "action", + get label() { + return t("history.action"); + }, + get description() { + return t("advanced.actionHint"); + }, + method: "action/list", + }, + { + namespace: "execution", + get label() { + return t("advanced.execution"); + }, + get description() { + return t("advanced.executionHint"); + }, + method: "execution/list", + }, + { + namespace: "outcome", + get label() { + return t("advanced.outcomes"); + }, + get description() { + return t("advanced.outcomesHint"); + }, + method: "outcome/list", + }, + { + namespace: "revision", + get label() { + return t("advanced.revisions"); + }, + get description() { + return t("advanced.revisionsHint"); + }, + method: "revision/list", + }, + { + namespace: "memory", + get label() { + return t("result.memory"); + }, + get description() { + return t("advanced.memoryHint"); + }, + method: "memory/list", + }, + { + namespace: "improvement", + get label() { + return t("advanced.improvement"); + }, + get description() { + return t("advanced.improvementHint"); + }, + method: "improvement/list", + }, + { + namespace: "receipt", + get label() { + return t("advanced.receipt"); + }, + get description() { + return t("advanced.receiptHint"); + }, + method: "receipt/list", + }, + { + namespace: "closure", + get label() { + return t("advanced.closure"); + }, + get description() { + return t("advanced.closureHint"); + }, + method: "closure/list", + }, + { + namespace: "export", + get label() { + return t("advanced.export"); + }, + get description() { + return t("advanced.exportHint"); + }, + method: "export/list", + }, + { + namespace: "operation", + get label() { + return t("advanced.async"); + }, + get description() { + return t("advanced.asyncHint"); + }, + method: "operation/read", + scope: "operation", + }, + { + namespace: "model", + get label() { + return t("advanced.models"); + }, + get description() { + return t("advanced.modelsHint"); + }, + method: "model/settings/read", + }, + { + namespace: "trace", + get label() { + return t("workspace.trace"); + }, + get description() { + return t("advanced.traceHint"); + }, + method: "trace/read", + }, + { + namespace: "workspace", + get label() { + return t("advanced.workspace"); + }, + get description() { + return t("advanced.workspaceHint"); + }, + method: "workspace/setup/read", + }, + { + namespace: "projectpack", + get label() { + return t("advanced.examples"); + }, + get description() { + return t("advanced.examplesHint"); + }, + method: "projectpack/list", + get note() { + return t("advanced.examplesNotice"); + }, + }, + { + namespace: "field", + get label() { + return t("advanced.field"); + }, + get description() { + return t("advanced.fieldHint"); + }, + method: null, + get note() { + return t("advanced.fieldNotice"); + }, + }, ]; export const visibleRecordViews = recordViews.filter( diff --git a/apps/web/src/api/requestLanguage.test.ts b/apps/web/src/api/requestLanguage.test.ts index 87ee183f..cb235e5e 100644 --- a/apps/web/src/api/requestLanguage.test.ts +++ b/apps/web/src/api/requestLanguage.test.ts @@ -1,13 +1,31 @@ // @vitest-environment jsdom import { afterEach, expect, it } from "vitest"; -import { readLocalPending, submissionSignature, writeLocalPending, type PendingSubmission } from "./localWorkspacePersistence"; -import { readResponseLanguageDefault, writeResponseLanguageDefault } from "./responseLanguageDefault"; +import { + readLocalPending, + submissionSignature, + writeLocalPending, + type PendingSubmission, +} from "./localWorkspacePersistence"; +import { + readResponseLanguageDefault, + writeResponseLanguageDefault, +} from "./responseLanguageDefault"; import { setLocale } from "../i18n/locale"; -afterEach(() => { localStorage.clear(); setLocale("ko"); }); +afterEach(() => { + localStorage.clear(); + setLocale("ko"); +}); const scope = { mode: "LOCAL" as const, workspaceId: "workspace:language-test" }; -const fields = { workspaceId: scope.workspaceId, projectId: "p", threadId: "t", problem: "Question", selection: null, - key: "11111111-1111-1111-1111-111111111111", createdAt: 1 }; +const fields = { + workspaceId: scope.workspaceId, + projectId: "p", + threadId: "t", + problem: "Question", + selection: null, + key: "11111111-1111-1111-1111-111111111111", + createdAt: 1, +}; it("preserves v1 signatures and keeps explicit ko distinct from omission", () => { const legacy = submissionSignature("p", "t", "Question", null); @@ -20,8 +38,12 @@ it("preserves v1 signatures and keeps explicit ko distinct from omission", () => }); it("replays v2 with its saved language regardless of display and default-language changes", () => { - const saved: PendingSubmission = { ...fields, version: 2, responseLanguage: "en", - signature: submissionSignature("p", "t", "Question", null, null, "en") }; + const saved: PendingSubmission = { + ...fields, + version: 2, + responseLanguage: "en", + signature: submissionSignature("p", "t", "Question", null, null, "en"), + }; expect(writeLocalPending(saved)).toBe("SAVED"); writeResponseLanguageDefault(scope, "ko"); setLocale("en"); diff --git a/apps/web/src/api/research.ts b/apps/web/src/api/research.ts index d9ca7e0e..c63db258 100644 --- a/apps/web/src/api/research.ts +++ b/apps/web/src/api/research.ts @@ -3,20 +3,35 @@ import type { ResponseLanguage } from "./responseLanguages"; import { z } from "zod"; import type { Currentness } from "./historyModels"; import type { CoverageMatrix, NextUserAction, UserProgressSummary } from "./researchFollowup"; -import { readLocalContext, writeLocalContext, type BrowserScope } from "./localWorkspacePersistence"; +import { + readLocalContext, + writeLocalContext, + type BrowserScope, +} from "./localWorkspacePersistence"; export type ThreadAnalysis = PackRunResult["cycle"] & { selected_evidence_refs: string[] }; export type ResearchFailure = { - primary: {reason_code:string; origin:string; detail?:string | null}; - secondary?: {reason_code:string; origin:string; detail?:string | null} | null; + primary: { reason_code: string; origin: string; detail?: string | null }; + secondary?: { reason_code: string; origin: string; detail?: string | null } | null; remote_observation?: string; }; -export type ExecutionError = {message?:string; data?:{reason_code?:string; failure?:ResearchFailure}}; +export type ExecutionError = { + message?: string; + data?: { reason_code?: string; failure?: ResearchFailure }; +}; export type ResultManifest = { request_ref?: { revision_digest: string }; - operation_id: string; phase: string; state: string; completion: string; terminal_reason: string | null; - basis_digest: string; result: Record; gaps: string[]; next_steps: string[]; - source_refs: string[]; /** Not sent by the summary read; the result read has it. */ record_refs?: unknown[]; + operation_id: string; + phase: string; + state: string; + completion: string; + terminal_reason: string | null; + basis_digest: string; + result: Record; + gaps: string[]; + next_steps: string[]; + source_refs: string[]; + /** Not sent by the summary read; the result read has it. */ record_refs?: unknown[]; }; export type CompletedStage = { role?: string; @@ -58,7 +73,16 @@ export type UserActivityLocator = { line_end?: number | null; }; export type UserActivityTarget = { - kind: "file" | "uri" | "artifact" | "span" | "table_cell" | "line_range" | "database_view" | "sandbox" | "model"; + kind: + | "file" + | "uri" + | "artifact" + | "span" + | "table_cell" + | "line_range" + | "database_view" + | "sandbox" + | "model"; title: string; display_ref?: string | null; host_alias?: string | null; @@ -95,11 +119,50 @@ export type UserActivityEvent = { announce: "none" | "polite" | "assertive"; phase?: string | null; activity_kind: "tool" | "source" | "model" | "sandbox" | "connector" | "judgement" | "control"; - action: "search" | "open" | "fetch" | "parse" | "read" | "screen" | "select_candidate" | "adopt_support" | "adopt_counter" | "run" | "call_model" | "retry" | "cancel" | "hold" | "stage_complete"; + action: + | "search" + | "open" + | "fetch" + | "parse" + | "read" + | "screen" + | "select_candidate" + | "adopt_support" + | "adopt_counter" + | "run" + | "call_model" + | "retry" + | "cancel" + | "hold" + | "stage_complete"; label_ko: string; why_ko?: string | null; - state: "planned" | "running" | "succeeded" | "failed" | "blocked" | "cancel_requested" | "cancelled" | "timed_out" | "unknown_external_effect"; - research_relation: "none" | "discovered" | "opened" | "fetched" | "parsed" | "read" | "screened" | "selected_candidate" | "supports" | "contradicts" | "inconclusive" | "held" | "excluded" | "stale" | "access_blocked"; + state: + | "planned" + | "running" + | "succeeded" + | "failed" + | "blocked" + | "cancel_requested" + | "cancelled" + | "timed_out" + | "unknown_external_effect"; + research_relation: + | "none" + | "discovered" + | "opened" + | "fetched" + | "parsed" + | "read" + | "screened" + | "selected_candidate" + | "supports" + | "contradicts" + | "inconclusive" + | "held" + | "excluded" + | "stale" + | "access_blocked"; target?: UserActivityTarget | null; tool?: UserActivityTool | null; result?: UserActivityResult | null; @@ -117,8 +180,15 @@ export type UserActivityEvent = { }; }; export type ResultUsage = { - operation_id: string; calls: number; input_tokens: number | null; output_tokens: number | null; total_tokens: number | null; - cached_input_tokens: number | null; unreported_calls: number; state: string; wall_ms: number | null; + operation_id: string; + calls: number; + input_tokens: number | null; + output_tokens: number | null; + total_tokens: number | null; + cached_input_tokens: number | null; + unreported_calls: number; + state: string; + wall_ms: number | null; /** Calls sent again on their own after an earlier call was cut off. */ auto_retries?: number; /** Part of the figures above that a purpose-marked call used, such as widening the memory question. */ @@ -126,41 +196,107 @@ export type ResultUsage = { }; /** A closed-loop test that was held instead of run. Only a hold with an unsettled earlier run has anything to clear (exits). */ export type ExecutionHold = { - schema_version: string; reason_code: string | null; plan_execution_id: string | null; execution_revision: number | null; - attempt_id: string | null; attempt_state: string | null; exits: string[]; - input_difference: { same?: boolean; previous_count?: number; current_count?: number; removed?: string[]; added?: string[] } | null; + schema_version: string; + reason_code: string | null; + plan_execution_id: string | null; + execution_revision: number | null; + attempt_id: string | null; + attempt_state: string | null; + exits: string[]; + input_difference: { + same?: boolean; + previous_count?: number; + current_count?: number; + removed?: string[]; + added?: string[]; + } | null; }; export type ResearchStatus = WorkThread & { - usage?: { input_tokens: number | null; output_tokens: number | null; total_tokens: number | null; state: string; unreported_calls: number; cumulative_token_limit_enforced: boolean; cached_input_tokens?: number | null }; - operation_state?: string; operation_error?: ExecutionError | null; request_epoch?: number; + usage?: { + input_tokens: number | null; + output_tokens: number | null; + total_tokens: number | null; + state: string; + unreported_calls: number; + cumulative_token_limit_enforced: boolean; + cached_input_tokens?: number | null; + }; + operation_state?: string; + operation_error?: ExecutionError | null; + request_epoch?: number; /** Usage per result (operation id), computed by the server when the status is read. */ result_usage?: Record; failure?: ResearchFailure | null; - execution_summary?: {effective_execution_state:string;state_inconsistent:boolean;last_checkpoint_only:boolean;automatic_retry:boolean}; - freshness?: string; current_result?: ResultManifest | null; previous_result?: ResultManifest | null; + execution_summary?: { + effective_execution_state: string; + state_inconsistent: boolean; + last_checkpoint_only: boolean; + automatic_retry: boolean; + }; + freshness?: string; + current_result?: ResultManifest | null; + previous_result?: ResultManifest | null; basis_currentness?: Currentness; /** Present when the stored result carries a held closed-loop test. */ execution_hold?: ExecutionHold | null; user_progress_summary?: UserProgressSummary | null; coverage_matrix?: CoverageMatrix | null; next_user_action?: NextUserAction | null; - request?: { response_language?: ResponseLanguage; operation_id: string; authored_text?: string; effective_question?: string; model_settings?: unknown; policy_digest?: string; cutoff_at?: string }; + request?: { + response_language?: ResponseLanguage; + operation_id: string; + authored_text?: string; + effective_question?: string; + model_settings?: unknown; + policy_digest?: string; + cutoff_at?: string; + }; completed_stages?: CompletedStage[]; stage_reuse?: { reused: number; new: number }; activity_events?: ActivityEvent[]; user_activity_events?: UserActivityEvent[]; model_dispatches?: ModelDispatch[]; - attempt?: { operation_id: string; phase: string; status: string; draft_progress?: unknown; external_effect_state?: string; remote_observation?: string; + attempt?: { + operation_id: string; + phase: string; + status: string; + draft_progress?: unknown; + external_effect_state?: string; + remote_observation?: string; request_ref?: { revision_digest: string }; - checkpoint_ref?: { project_id: string; entity_type: string; entity_id: string; revision_digest: string } | null; + checkpoint_ref?: { + project_id: string; + entity_type: string; + entity_id: string; + revision_digest: string; + } | null; + } | null; + budget?: { + calls: number; + max_calls: number | null; + reserved_tokens: number; + max_reserved_tokens: number | null; + actual_tokens?: number | null; + actual_cost?: string | null; + max_seconds: number | null; + started_at: string; } | null; - budget?: { calls: number; max_calls: number | null; reserved_tokens: number; max_reserved_tokens: number | null; actual_tokens?: number | null; actual_cost?: string | null; max_seconds: number | null; started_at: string } | null; - cleanup?: unknown; post_execution_learning_current?: unknown[]; inputs?: unknown[]; + cleanup?: unknown; + post_execution_learning_current?: unknown[]; + inputs?: unknown[]; +}; +export type ResearchAdmission = { + contract_version: number; + project_id?: string; + thread_id: string; + operation_id?: string; + status?: string; + request_epoch?: number | null; + terminal_reason?: string | null; }; -export type ResearchAdmission = { contract_version: number; project_id?: string; thread_id: string; operation_id?: string; - status?: string; request_epoch?: number | null; terminal_reason?: string | null }; export type WorkspaceLocationInfo = { - classification: "KNOWN_LOCAL" | "KNOWN_SYNC" | "REMOTE_SHARE" | "UNSUPPORTED_BRIDGE" | "REMOVABLE" | "UNKNOWN"; + classification: + "KNOWN_LOCAL" | "KNOWN_SYNC" | "REMOTE_SHARE" | "UNSUPPORTED_BRIDGE" | "REMOVABLE" | "UNKNOWN"; reason_codes: string[]; confirmed: boolean; message_ko: string; @@ -184,44 +320,119 @@ export type WorkspaceReady = { workspace_id?: string; }; -const stringList=z.array(z.string()); -const dimension=z.object({status:z.string(),reason:z.string()}); -const analysisDisplaySchema=z.object({ - assessment:z.object({assessment_id:z.string(),derived_status:stringList,missing_items:stringList, - scope_identity:dimension,criterion_authority:dimension,evidence_coverage:dimension,comparability:dimension}), - portfolio:z.object({portfolio_id:z.string(),hypotheses:z.array(z.object({ - hypothesis_id:z.string(),statement:z.string(),primary_locus:z.string().nullable(),uncertainty:z.string(), - support_evidence_refs:stringList.optional(),evidence_refs:stringList.optional(),counterevidence_refs:stringList.optional(), - critical_review:z.object({terminal:z.string()}).passthrough().nullable().optional(), - counterevidence_queries:stringList,predicted_observations:stringList, - discriminating_tests:z.array(z.object({procedure_candidate:z.string(),expected_if_true:z.string(),expected_if_alternative:z.string(),risk_tier:z.string()})), - }))}), - action_plan:z.object({plan_id:z.string(),frontier:stringList,alternatives:z.array(z.object({ - action_id:z.string(),action_family:z.string(),specification:z.string(),expected_information_value:z.string(), - risk_tier:z.string(),execution_authority:z.string(),reversibility:z.string(),state:z.string(),source_refs:stringList,missing_evidence:stringList, - }))}), - commit:z.object({disposition:z.string(),committed_revision_ids:stringList,receipt:z.object({ - receipt_id:z.string(),receipt_digest:z.string(),claim_scopes:stringList,integrity_state:z.string(),provenance_state:z.string(),semantic_truth_certified:z.boolean(), - })}), - selected_evidence_refs:stringList, +const stringList = z.array(z.string()); +const dimension = z.object({ status: z.string(), reason: z.string() }); +const analysisDisplaySchema = z.object({ + assessment: z.object({ + assessment_id: z.string(), + derived_status: stringList, + missing_items: stringList, + scope_identity: dimension, + criterion_authority: dimension, + evidence_coverage: dimension, + comparability: dimension, + }), + portfolio: z.object({ + portfolio_id: z.string(), + hypotheses: z.array( + z.object({ + hypothesis_id: z.string(), + statement: z.string(), + primary_locus: z.string().nullable(), + uncertainty: z.string(), + support_evidence_refs: stringList.optional(), + evidence_refs: stringList.optional(), + counterevidence_refs: stringList.optional(), + critical_review: z.object({ terminal: z.string() }).passthrough().nullable().optional(), + counterevidence_queries: stringList, + predicted_observations: stringList, + discriminating_tests: z.array( + z.object({ + procedure_candidate: z.string(), + expected_if_true: z.string(), + expected_if_alternative: z.string(), + risk_tier: z.string(), + }), + ), + }), + ), + }), + action_plan: z.object({ + plan_id: z.string(), + frontier: stringList, + alternatives: z.array( + z.object({ + action_id: z.string(), + action_family: z.string(), + specification: z.string(), + expected_information_value: z.string(), + risk_tier: z.string(), + execution_authority: z.string(), + reversibility: z.string(), + state: z.string(), + source_refs: stringList, + missing_evidence: stringList, + }), + ), + }), + commit: z.object({ + disposition: z.string(), + committed_revision_ids: stringList, + receipt: z.object({ + receipt_id: z.string(), + receipt_digest: z.string(), + claim_scopes: stringList, + integrity_state: z.string(), + provenance_state: z.string(), + semantic_truth_certified: z.boolean(), + }), + }), + selected_evidence_refs: stringList, }); /** Validate only: no default values, coercion or mutation of partial server records. */ -export function isThreadAnalysis(value: Record): value is ThreadAnalysis & Record { +export function isThreadAnalysis( + value: Record, +): value is ThreadAnalysis & Record { return analysisDisplaySchema.safeParse(value).success; } const contextKey = "thoth:web-context:v1"; -export function readWorkspaceContext(scope?: BrowserScope): { projectId: string; threadId: string } { +export function readWorkspaceContext(scope?: BrowserScope): { + projectId: string; + threadId: string; +} { if (scope?.mode === "LOCAL") return readLocalContext(scope.workspaceId); try { - const value = JSON.parse(sessionStorage.getItem(scope?.mode === "HOSTED" && scope.browserScopeId ? `${contextKey}:${scope.browserScopeId}` : contextKey) ?? "null"); - if (value?.version === 1 && typeof value.projectId === "string" && typeof value.threadId === "string") return value; - } catch { /* Storage can be disabled; canonical data is always fetched from the server. */ } + const value = JSON.parse( + sessionStorage.getItem( + scope?.mode === "HOSTED" && scope.browserScopeId + ? `${contextKey}:${scope.browserScopeId}` + : contextKey, + ) ?? "null", + ); + if ( + value?.version === 1 && + typeof value.projectId === "string" && + typeof value.threadId === "string" + ) + return value; + } catch { + /* Storage can be disabled; canonical data is always fetched from the server. */ + } return { projectId: "", threadId: "" }; } export function saveWorkspaceContext(projectId: string, threadId: string, scope?: BrowserScope) { if (scope?.mode === "LOCAL") return writeLocalContext(scope.workspaceId, { projectId, threadId }); - try { sessionStorage.setItem(scope?.mode === "HOSTED" && scope.browserScopeId ? `${contextKey}:${scope.browserScopeId}` : contextKey, JSON.stringify({ version: 1, projectId, threadId })); return true; } - catch { return false; } + try { + sessionStorage.setItem( + scope?.mode === "HOSTED" && scope.browserScopeId + ? `${contextKey}:${scope.browserScopeId}` + : contextKey, + JSON.stringify({ version: 1, projectId, threadId }), + ); + return true; + } catch { + return false; + } } diff --git a/apps/web/src/api/researchFollowup.ts b/apps/web/src/api/researchFollowup.ts index 3b675737..1ba87363 100644 --- a/apps/web/src/api/researchFollowup.ts +++ b/apps/web/src/api/researchFollowup.ts @@ -11,7 +11,14 @@ const basisCurrentnessSchema = z.object({ const nextUserActionSchema = z.object({ schema_version: z.literal("1.0.0").optional().default("1.0.0"), - action_type: z.enum(["NONE", "REVIEW_GAPS", "REVIEW_CURRENTNESS", "OPEN_RESULT_DETAIL", "START_FOLLOWUP", "UNSUPPORTED"]), + action_type: z.enum([ + "NONE", + "REVIEW_GAPS", + "REVIEW_CURRENTNESS", + "OPEN_RESULT_DETAIL", + "START_FOLLOWUP", + "UNSUPPORTED", + ]), label: z.string(), reason_codes: z.array(z.string()).optional().default([]), requires_permission: z.boolean().optional().default(false), @@ -23,7 +30,16 @@ export const userProgressSummarySchema = z.object({ schema_version: z.literal("1.0.0").optional().default("1.0.0"), request_revision_digest: digestSchema, result_revision_digest: digestSchema.nullable().optional().default(null), - state: z.enum(["PENDING_OR_NOT_PRODUCED", "IN_PROGRESS", "COMPLETE", "NEEDS_REVIEW", "HOLD", "FAILED", "CANCELLED", "UNKNOWN"]), + state: z.enum([ + "PENDING_OR_NOT_PRODUCED", + "IN_PROGRESS", + "COMPLETE", + "NEEDS_REVIEW", + "HOLD", + "FAILED", + "CANCELLED", + "UNKNOWN", + ]), currentness: basisCurrentnessSchema, progress_items: z.array(z.string()).optional().default([]), recorded_checks: z.array(z.string()).optional().default([]), @@ -54,14 +70,17 @@ export const coverageMatrixSchema = z.object({ availability: z.enum(["AVAILABLE", "PARTIAL", "UNAVAILABLE"]).optional().default("AVAILABLE"), reason_codes: z.array(z.string()).optional().default([]), rows: z.array(coverageRowSchema).optional().default([]), - summary: z.object({ - satisfied: z.number().int().optional().default(0), - unresolved: z.number().int().optional().default(0), - not_applicable: z.number().int().optional().default(0), - not_assessed: z.number().int().optional().default(0), - hold_targets: z.array(z.string()).optional().default([]), - reason_codes: z.array(z.string()).optional().default([]), - }).optional().default({}), + summary: z + .object({ + satisfied: z.number().int().optional().default(0), + unresolved: z.number().int().optional().default(0), + not_applicable: z.number().int().optional().default(0), + not_assessed: z.number().int().optional().default(0), + hold_targets: z.array(z.string()).optional().default([]), + reason_codes: z.array(z.string()).optional().default([]), + }) + .optional() + .default({}), }); const resultIdentitySchema = z.object({ @@ -71,11 +90,15 @@ const resultIdentitySchema = z.object({ const criterionStateSchema = z.object({ status: z.enum(["SATISFIED", "UNRESOLVED", "NOT_APPLICABLE", "NOT_ASSESSED"]), - relation: z.string(), validation: z.string(), blocker: z.string(), + relation: z.string(), + validation: z.string(), + blocker: z.string(), }); const criterionDeltaSchema = z.object({ - requirement_id: z.string(), target: z.string(), question: z.string(), + requirement_id: z.string(), + target: z.string(), + question: z.string(), match: z.enum(["SAME", "ADDED", "REMOVED"]), before: criterionStateSchema.nullable().optional().default(null), after: criterionStateSchema.nullable().optional().default(null), @@ -96,17 +119,24 @@ const decisionDeltaSchema = z.object({ reason_codes: z.array(z.string()).optional().default([]), reason_refs: z.array(z.record(z.unknown())).optional().default([]), basis_currentness: z.record(basisCurrentnessSchema), - groups: z.array(z.object({ - kind: z.enum(["CONTENT", "EVIDENCE", "CONDITION", "STATUS", "ACTION", "OTHER"]), - trace_paths: z.array(z.string()), - changes: z.array(z.object({ - path: z.string(), - before_present: z.boolean(), - after_present: z.boolean(), - before: z.unknown().nullable().optional(), - after: z.unknown().nullable().optional(), - })), - })).optional().default([]), + groups: z + .array( + z.object({ + kind: z.enum(["CONTENT", "EVIDENCE", "CONDITION", "STATUS", "ACTION", "OTHER"]), + trace_paths: z.array(z.string()), + changes: z.array( + z.object({ + path: z.string(), + before_present: z.boolean(), + after_present: z.boolean(), + before: z.unknown().nullable().optional(), + after: z.unknown().nullable().optional(), + }), + ), + }), + ) + .optional() + .default([]), // Results saved by an older server carry neither field: criteria are then reported as not comparable. criteria: z.array(criterionDeltaSchema).optional().default([]), criteria_state: z.enum(["AVAILABLE", "UNAVAILABLE"]).optional().default("UNAVAILABLE"), @@ -146,28 +176,66 @@ export type ResultIdentity = z.infer; export type ProjectReviewList = z.infer; export type ProjectReviewItem = z.infer; -async function query(method: string, input: Record, signal?: AbortSignal): Promise { +async function query( + method: string, + input: Record, + signal?: AbortSignal, +): Promise { const response = await rpc(method, input, crypto.randomUUID(), signal); if (response.state !== "SUCCEEDED") throw new UiError("followup.queryIncomplete"); return response.value; } -export async function readDecisionDelta(projectId: string, threadId: string, before: ResultIdentity, after: ResultIdentity, signal?: AbortSignal): Promise { - const value = decisionDeltaSchema.parse(await query("thread/result/compare/read", { - project_id: projectId, thread_id: threadId, before, after, contract_version: 2, - }, signal)); - if (value.project_id !== projectId || value.thread_id !== threadId || - value.before.request_revision_digest !== before.request_revision_digest || value.before.result_revision_digest !== before.result_revision_digest || - value.after.request_revision_digest !== after.request_revision_digest || value.after.result_revision_digest !== after.result_revision_digest) { +export async function readDecisionDelta( + projectId: string, + threadId: string, + before: ResultIdentity, + after: ResultIdentity, + signal?: AbortSignal, +): Promise { + const value = decisionDeltaSchema.parse( + await query( + "thread/result/compare/read", + { + project_id: projectId, + thread_id: threadId, + before, + after, + contract_version: 2, + }, + signal, + ), + ); + if ( + value.project_id !== projectId || + value.thread_id !== threadId || + value.before.request_revision_digest !== before.request_revision_digest || + value.before.result_revision_digest !== before.result_revision_digest || + value.after.request_revision_digest !== after.request_revision_digest || + value.after.result_revision_digest !== after.result_revision_digest + ) { throw new UiError("followup.binding"); } return value; } -export async function readProjectReviewList(projectId: string, cursor: string | null, signal?: AbortSignal): Promise { - const value = projectReviewListSchema.parse(await query("project/review/list", { - project_id: projectId, cursor, limit: 20, contract_version: 2, - }, signal)); +export async function readProjectReviewList( + projectId: string, + cursor: string | null, + signal?: AbortSignal, +): Promise { + const value = projectReviewListSchema.parse( + await query( + "project/review/list", + { + project_id: projectId, + cursor, + limit: 20, + contract_version: 2, + }, + signal, + ), + ); if (value.project_id !== projectId) throw new UiError("followup.projectBinding"); return value; } diff --git a/apps/web/src/api/researchProgress.test.tsx b/apps/web/src/api/researchProgress.test.tsx index f76a467d..d952399b 100644 --- a/apps/web/src/api/researchProgress.test.tsx +++ b/apps/web/src/api/researchProgress.test.tsx @@ -18,7 +18,9 @@ function running(overrides: Partial = {}): ResearchStatus { working_head_digest: "0".repeat(64), operation_state: "RUNNING", attempt: { operation_id: "operation:o", phase: "HYPOTHESIS_REVIEW", status: "RUNNING" }, - completed_stages: [{ role: "RESEARCH_PLANNER", state: "COMPLETED", elapsed_ms: 91700, context_bytes: 12000 }], + completed_stages: [ + { role: "RESEARCH_PLANNER", state: "COMPLETED", elapsed_ms: 91700, context_bytes: 12000 }, + ], ...overrides, }; } @@ -27,8 +29,15 @@ describe("live research progress", () => { it("exposes Korean scenes while RUNNING and keeps them after HOLD", () => { const live = liveResearchProgress(running()); expect(live?.headline).toBe("방금 만든 설명을 원문과 대조하고 있습니다"); - expect(live?.events[0]).toMatchObject({ kind: "note", text: "질문에서 꼭 지킬 조건을 고정했습니다" }); - expect(live?.events[1]).toMatchObject({ kind: "action", label: "단계 완료", payload: "1분 32초 · 맥락 12KB" }); + expect(live?.events[0]).toMatchObject({ + kind: "note", + text: "질문에서 꼭 지킬 조건을 고정했습니다", + }); + expect(live?.events[1]).toMatchObject({ + kind: "action", + label: "단계 완료", + payload: "1분 32초 · 맥락 12KB", + }); expect(live?.evidenceLabels).toEqual([]); const held = liveResearchProgress( running({ @@ -96,8 +105,12 @@ describe("live research progress", () => { ], }), ); - const notes = live?.events.filter((event) => event.kind === "note" && !event.live).map((event) => event.text); - const payloads = live?.events.filter((event) => event.actionType === "stage_completed").map((event) => event.payload); + const notes = live?.events + .filter((event) => event.kind === "note" && !event.live) + .map((event) => event.text); + const payloads = live?.events + .filter((event) => event.actionType === "stage_completed") + .map((event) => event.payload); expect(notes).toEqual(["질문 조건을 원문과 대조했습니다", "질문 조건을 원문과 대조했습니다"]); expect(payloads).toEqual(["4초 · 맥락 2.0KB", "8초 · 맥락 4.0KB"]); expect(notes?.join(" ")).not.toContain("2회"); @@ -108,7 +121,8 @@ describe("live research progress", () => { "C:/papers/psyche-irb-2022.pdf", "C:/papers/gao-23-106021.pdf", ]); - const notes = live?.events.filter((event) => event.kind === "note").map((event) => event.text) ?? []; + const notes = + live?.events.filter((event) => event.kind === "note").map((event) => event.text) ?? []; expect(notes[0]).toContain("표 3 발사 준비 조건 4개"); expect(notes[0]).toContain("표 숫자만 인용"); expect(notes[0]).toContain("추측 금지"); @@ -157,8 +171,17 @@ describe("live research progress", () => { "Psyche 표 3 발사 준비 조건 4개가 원문에서 각각 확인되는지 적어라. 표 숫자만 인용하고 추측 금지.", model_settings: { model: "grok-4.6", reasoning_effort: "high" }, }, - usage: { input_tokens: 8000, output_tokens: 4400, total_tokens: 12400, state: "PARTIAL", unreported_calls: 1, cumulative_token_limit_enforced: false }, - model_dispatches: [{ state: "RESERVED", transport_observation: { elapsed_ms: 47000, received_bytes: 0 } }], + usage: { + input_tokens: 8000, + output_tokens: 4400, + total_tokens: 12400, + state: "PARTIAL", + unreported_calls: 1, + cumulative_token_limit_enforced: false, + }, + model_dispatches: [ + { state: "RESERVED", transport_observation: { elapsed_ms: 47000, received_bytes: 0 } }, + ], })} sourceUris={["C:/papers/mobilenets.pdf"]} />, @@ -216,7 +239,12 @@ describe("live research progress", () => { operation_id: "operation:o", phase: "EVIDENCE_FOCUS", status: "RUNNING", - draft_progress: { evidence_focus: { locators: [{ page: 1 }, { page: 1 }, { page: 1 }, { page: 2 }], total: 4 } }, + draft_progress: { + evidence_focus: { + locators: [{ page: 1 }, { page: 1 }, { page: 1 }, { page: 2 }], + total: 4, + }, + }, }, }); const live = liveResearchProgress(status)!; @@ -273,7 +301,9 @@ describe("live research progress", () => { ], }), ); - expect(live?.events.map((event) => [event.kind, event.text, event.payload ?? event.live])).toEqual([ + expect( + live?.events.map((event) => [event.kind, event.text, event.payload ?? event.live]), + ).toEqual([ ["note", "질문에서 꼭 지킬 조건을 고정했습니다", false], ["action", "단계 완료", "1분 32초 · 맥락 12KB · 모델 1회"], ["note", "방금 만든 설명을 원문과 대조하고 있습니다", true], @@ -289,6 +319,8 @@ describe("live research progress", () => { }), ), ).toBe("표와 원문 위치를 확인하고 있습니다"); - expect(composerActivity(running({ execution_state: "PAUSED" }))).toBe("조사가 일시정지되어 있습니다."); + expect(composerActivity(running({ execution_state: "PAUSED" }))).toBe( + "조사가 일시정지되어 있습니다.", + ); }); }); diff --git a/apps/web/src/api/researchProgress.ts b/apps/web/src/api/researchProgress.ts index 14986a6b..5aa72213 100644 --- a/apps/web/src/api/researchProgress.ts +++ b/apps/web/src/api/researchProgress.ts @@ -30,7 +30,8 @@ export type TimelineEvent = { kind: "note" | "action"; text: string; live?: boolean; - actionType?: "stage_completed" | "source_read" | "source_attached" | "model_call" | "hypothesis_review"; + actionType?: + "stage_completed" | "source_read" | "source_attached" | "model_call" | "hypothesis_review"; label?: string; payload?: string | null; icon?: TimelineIcon; @@ -67,7 +68,10 @@ function numberOrNull(value: unknown): number | null { function questionText(status?: ResearchStatus): string { return [status?.request?.authored_text, status?.request?.effective_question, status?.problem] - .filter((value): value is string => typeof value === "string" && value.trim().length > 1 && value.trim() !== "q") + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 1 && value.trim() !== "q", + ) .join("\n"); } @@ -104,7 +108,9 @@ function extractConstraints(status?: ResearchStatus): string[] { if (/확인 안 되면|부족한지|HOLD/u.test(text)) { found.push(t("progress.explainMissing")); } - const quotes = [...text.matchAll(/[“「"]([^”」"]{2,48})[”」"]/gu)].map((match) => match[1].trim()); + const quotes = [...text.matchAll(/[“「"]([^”」"]{2,48})[”」"]/gu)].map((match) => + match[1].trim(), + ); return unique([...found, ...quotes]); } @@ -138,7 +144,9 @@ function decidedHypothesisIds(status?: ResearchStatus): Set { function workFacts(status: ResearchStatus, sourceUris: string[] = []): WorkFacts { const records = hypothesisRecords(status); const decidedIds = decidedHypothesisIds(status); - const quotes = [...questionText(status).matchAll(/[“「]([^”」]{2,48})[”」]/gu)].map((match) => match[1].trim()); + const quotes = [...questionText(status).matchAll(/[“「]([^”」]{2,48})[”」]/gu)].map((match) => + match[1].trim(), + ); const hypotheses = unique([...records.map((item) => item.statement), ...quotes]); const decided = records.filter((item) => decidedIds.has(item.id)).map((item) => item.statement); const pending = records.filter((item) => !decidedIds.has(item.id)).map((item) => item.statement); @@ -218,7 +226,11 @@ function actionEvent( }; } -function timelineFromStages(status: ResearchStatus, sourceUris: string[], facts: WorkFacts): TimelineEvent[] { +function timelineFromStages( + status: ResearchStatus, + sourceUris: string[], + facts: WorkFacts, +): TimelineEvent[] { const events: TimelineEvent[] = []; const stages = status.completed_stages ?? []; stages.forEach((stage, index) => { @@ -241,7 +253,11 @@ function timelineFromStages(status: ResearchStatus, sourceUris: string[], facts: return events; } -function timelineFromPublished(status: ResearchStatus, sourceUris: string[], facts: WorkFacts): TimelineEvent[] | null { +function timelineFromPublished( + status: ResearchStatus, + sourceUris: string[], + facts: WorkFacts, +): TimelineEvent[] | null { const published = status.activity_events; if (!Array.isArray(published) || published.length === 0) { return null; @@ -322,8 +338,14 @@ function timelineFromPublished(status: ResearchStatus, sourceUris: string[], fac `event-action-${event.seq ?? index}`, "model_call", waiting - ? compactPayload([elapsed ?? t("progress.elapsedUnknown"), firstByte ? formatMessage("firstByte", firstByte) : t("progress.noFirstByte")]) - : compactPayload([received != null ? `${received.toLocaleString(localeTag())}B` : null, elapsed]), + ? compactPayload([ + elapsed ?? t("progress.elapsedUnknown"), + firstByte ? formatMessage("firstByte", firstByte) : t("progress.noFirstByte"), + ]) + : compactPayload([ + received != null ? `${received.toLocaleString(localeTag())}B` : null, + elapsed, + ]), waiting, ), ); @@ -390,12 +412,14 @@ export function liveResearchProgress( const reviews = reviewCount(status); const facts = workFacts(status, sourceUris); const current = sceneFor(phase, facts, running); - const missing = phase === "EVIDENCE_FOCUS" && labels.length === 0 && total === null - ? formatMessage("progressLocationUnavailable") - : missingFor(status, facts, labels, total, hypotheses, reviews); + const missing = + phase === "EVIDENCE_FOCUS" && labels.length === 0 && total === null + ? formatMessage("progressLocationUnavailable") + : missingFor(status, facts, labels, total, hypotheses, reviews); const liveText = missing ? `${current.title} ${missing}` : current.title; let events = - timelineFromPublished(status, sourceUris, facts) ?? timelineFromStages(status, sourceUris, facts); + timelineFromPublished(status, sourceUris, facts) ?? + timelineFromStages(status, sourceUris, facts); if (!running) { events = events.map((event) => (event.live ? { ...event, live: false } : event)); } @@ -428,7 +452,10 @@ export function composerActivity(status: ResearchStatus): string | null { if (status.execution_state === "PAUSED") { return t("progress.paused"); } - if (status.attempt?.remote_observation === "UNKNOWN" && status.attempt.external_effect_state === "DISPATCHING") { + if ( + status.attempt?.remote_observation === "UNKNOWN" && + status.attempt.external_effect_state === "DISPATCHING" + ) { return t("progress.externalUnknown"); } if (status.operation_state !== "RUNNING") { diff --git a/apps/web/src/api/researchProgressCharacterization.test.ts b/apps/web/src/api/researchProgressCharacterization.test.ts index 33149f73..cebdb6b8 100644 --- a/apps/web/src/api/researchProgressCharacterization.test.ts +++ b/apps/web/src/api/researchProgressCharacterization.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { composerActivity, evidenceLabels, formatDuration, liveResearchProgress } from "./researchProgress"; +import { + composerActivity, + evidenceLabels, + formatDuration, + liveResearchProgress, +} from "./researchProgress"; import type { ResearchStatus } from "./research"; import { sceneFor, type WorkFacts } from "../i18n/progressKo"; @@ -28,7 +33,11 @@ function status(overrides: Record = {}): ResearchStatus { } as ResearchStatus; } -function inPhase(phase: string, extra: Record = {}, draft: Record = {}) { +function inPhase( + phase: string, + extra: Record = {}, + draft: Record = {}, +) { return status({ attempt: { operation_id: "operation:o", phase, status: "RUNNING", draft_progress: draft }, ...extra, @@ -36,7 +45,13 @@ function inPhase(phase: string, extra: Record = {}, draft: Reco } const RICH_DRAFT = { - evidence_focus: { locators: [{ page: 6, exact_text: "70.6" }, { page: 8, exact_text: "GNC" }], total: 5 }, + evidence_focus: { + locators: [ + { page: 6, exact_text: "70.6" }, + { page: 8, exact_text: "GNC" }, + ], + total: 5, + }, portfolio: { hypotheses: [ { hypothesis_id: "h1", statement: "일정만 늘리면 해결된다" }, @@ -49,24 +64,59 @@ const RICH_DRAFT = { const QUESTION = { request: { operation_id: "operation:o", - authored_text: "Psyche 표 3 발사 준비 조건 4개가 원문에서 각각 확인되는지 적어라. 표 숫자만 인용하고 추측 금지. 원문 위치와 함께 답.", + authored_text: + "Psyche 표 3 발사 준비 조건 4개가 원문에서 각각 확인되는지 적어라. 표 숫자만 인용하고 추측 금지. 원문 위치와 함께 답.", }, }; -const SOURCES = ["C:/papers/psyche-irb-2022.pdf", "C:/papers/gao-23-106021.pdf", "C:/papers/third.pdf"]; +const SOURCES = [ + "C:/papers/psyche-irb-2022.pdf", + "C:/papers/gao-23-106021.pdf", + "C:/papers/third.pdf", +]; const KEYS = [ - "RESEARCH_PLANNER", "REQUIREMENTS", "EVIDENCE_RERANKER", "SEMANTIC_REVIEWER", "EVIDENCE_REVIEW", - "REVIEW_ADJUDICATOR", "SOURCE_PLANNER", "HYPOTHESIS_GENERATOR", "HYPOTHESES", "HYPOTHESIS_REVIEWER", - "HYPOTHESIS_REVIEW", "ACTION_PLANNER", "ACTION_COMPARISON", "EVIDENCE_EXTRACTOR", "EVIDENCE_FOCUS", - "SUFFICIENCY_EXPLAINER", "COUNTEREVIDENCE_CHALLENGER", "USER_EXPLAINER", "REFERENCE_MAPPER", - "CONNECTED_SOURCES", "SOURCE_SHORTLIST", "HOLD", "SOMETHING_ELSE", + "RESEARCH_PLANNER", + "REQUIREMENTS", + "EVIDENCE_RERANKER", + "SEMANTIC_REVIEWER", + "EVIDENCE_REVIEW", + "REVIEW_ADJUDICATOR", + "SOURCE_PLANNER", + "HYPOTHESIS_GENERATOR", + "HYPOTHESES", + "HYPOTHESIS_REVIEWER", + "HYPOTHESIS_REVIEW", + "ACTION_PLANNER", + "ACTION_COMPARISON", + "EVIDENCE_EXTRACTOR", + "EVIDENCE_FOCUS", + "SUFFICIENCY_EXPLAINER", + "COUNTEREVIDENCE_CHALLENGER", + "USER_EXPLAINER", + "REFERENCE_MAPPER", + "CONNECTED_SOURCES", + "SOURCE_SHORTLIST", + "HOLD", + "SOMETHING_ELSE", ]; describe("durations", () => { it.each([ - [null, null], [undefined, null], [Number.NaN, null], [-1, null], [Number.POSITIVE_INFINITY, null], - [0, "0ms"], [999, "999ms"], [1000, "1초"], [1499, "1초"], [59499, "59초"], [59500, "1분"], - [60000, "1분"], [61000, "1분 1초"], [91700, "1분 32초"], [3600000, "60분"], + [null, null], + [undefined, null], + [Number.NaN, null], + [-1, null], + [Number.POSITIVE_INFINITY, null], + [0, "0ms"], + [999, "999ms"], + [1000, "1초"], + [1499, "1초"], + [59499, "59초"], + [59500, "1분"], + [60000, "1분"], + [61000, "1분 1초"], + [91700, "1분 32초"], + [3600000, "60분"], ])("formats %s as %s", (input, expected) => { expect(formatDuration(input as number | null | undefined)).toBe(expected); }); @@ -77,8 +127,13 @@ describe("scene sentences", () => { const lines: string[] = []; for (const key of KEYS) { for (const state of ["RUNNING", "SUCCEEDED"]) { - const live = liveResearchProgress(inPhase(key, { operation_state: state, ...QUESTION }, RICH_DRAFT), SOURCES); - lines.push(`${key} ${state}: ${live?.headline} // ${live?.current.detail} // ${live?.missing}`); + const live = liveResearchProgress( + inPhase(key, { operation_state: state, ...QUESTION }, RICH_DRAFT), + SOURCES, + ); + lines.push( + `${key} ${state}: ${live?.headline} // ${live?.current.detail} // ${live?.missing}`, + ); } } expect(lines).toMatchInlineSnapshot(` @@ -174,7 +229,15 @@ describe("scene sentences", () => { return `${problem}: ${live?.headline}`; }); const withLocators = ["책", "사과", "A3", "표 3", "ABC"].map((label) => { - const facts: WorkFacts = { focus: null, constraints: [], locators: [label], hypotheses: [], pendingHypotheses: [], decidedHypotheses: [], sources: [] }; + const facts: WorkFacts = { + focus: null, + constraints: [], + locators: [label], + hypotheses: [], + pendingHypotheses: [], + decidedHypotheses: [], + sources: [], + }; const reranked = sceneFor("EVIDENCE_RERANKER", facts, true); const reviewed = sceneFor("SEMANTIC_REVIEWER", { ...facts, focus: "발사 조건" }, true); return `${label}: ${reranked.title} | ${reviewed.title}`; @@ -197,7 +260,8 @@ describe("scene sentences", () => { }); it("clips, deduplicates and limits the quoted hypotheses and the joined lists", () => { - const long = "아주 긴 가설 문장은 스물여덟 글자를 넘기면 말줄임표로 잘려야 합니다 그래서 길게 적습니다"; + const long = + "아주 긴 가설 문장은 스물여덟 글자를 넘기면 말줄임표로 잘려야 합니다 그래서 길게 적습니다"; const draft = { portfolio: { hypotheses: [ @@ -224,21 +288,66 @@ describe("scene sentences", () => { describe("one-line texts", () => { it("pins the model, token and wait lines", () => { const cases: Record[] = [ - { request: { operation_id: "o", model_settings: { model: "gpt-x", reasoning_effort: "high" } } }, + { + request: { + operation_id: "o", + model_settings: { model: "gpt-x", reasoning_effort: "high" }, + }, + }, { request: { operation_id: "o", model_settings: { model: "gpt-x" } } }, { request: { operation_id: "o", model_settings: { reasoning_effort: "low" } } }, { request: { operation_id: "o", model_settings: {} } }, { request: { operation_id: "o" } }, - { usage: { input_tokens: 1, output_tokens: 2, total_tokens: 12400, state: "COMPLETE", unreported_calls: 0, cumulative_token_limit_enforced: false } }, - { usage: { input_tokens: 1, output_tokens: 2, total_tokens: null, state: "PARTIAL", unreported_calls: 1, cumulative_token_limit_enforced: false, cached_input_tokens: 3000 } }, - { model_dispatches: [{ state: "RESERVED", transport_observation: { elapsed_ms: 47000, received_bytes: 0 } }] }, - { model_dispatches: [{ state: "RESERVED", transport_observation: { elapsed_ms: 47000, first_byte_ms: 1200 } }] }, + { + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 12400, + state: "COMPLETE", + unreported_calls: 0, + cumulative_token_limit_enforced: false, + }, + }, + { + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: null, + state: "PARTIAL", + unreported_calls: 1, + cumulative_token_limit_enforced: false, + cached_input_tokens: 3000, + }, + }, + { + model_dispatches: [ + { state: "RESERVED", transport_observation: { elapsed_ms: 47000, received_bytes: 0 } }, + ], + }, + { + model_dispatches: [ + { state: "RESERVED", transport_observation: { elapsed_ms: 47000, first_byte_ms: 1200 } }, + ], + }, { model_dispatches: [{ state: "RESERVED" }] }, - { model_dispatches: [{ state: "OBSERVED", transport_observation: { elapsed_ms: 173, received_bytes: 2048 } }] }, - { model_dispatches: [{ state: "OBSERVED", received_bytes: 5, transport_observation: { elapsed_ms: 90000 } }] }, + { + model_dispatches: [ + { state: "OBSERVED", transport_observation: { elapsed_ms: 173, received_bytes: 2048 } }, + ], + }, + { + model_dispatches: [ + { state: "OBSERVED", received_bytes: 5, transport_observation: { elapsed_ms: 90000 } }, + ], + }, { model_dispatches: [{ state: "OBSERVED", transport_observation: { elapsed_ms: 5000 } }] }, { model_dispatches: [{ state: "OBSERVED" }] }, - { model_dispatches: [{ state: "OBSERVED", received_bytes: 1 }, { state: "RESERVED", transport_observation: { elapsed_ms: 1000 } }] }, + { + model_dispatches: [ + { state: "OBSERVED", received_bytes: 1 }, + { state: "RESERVED", transport_observation: { elapsed_ms: 1000 } }, + ], + }, ]; const lines = cases.map((extra) => { const live = liveResearchProgress(status(extra)); @@ -266,27 +375,85 @@ describe("one-line texts", () => { }); it("pins the finished-stage and model-call payload texts, including sizes", () => { - const stages = [500, 1023, 1024, 1536, 10239, 10240, 1048576, 5242880, 20971520, null, -1].map((context_bytes) => ({ - role: "RESEARCH_PLANNER", elapsed_ms: 2000, context_bytes, dispatch_ids: ["a", "b"], - })); + const stages = [500, 1023, 1024, 1536, 10239, 10240, 1048576, 5242880, 20971520, null, -1].map( + (context_bytes) => ({ + role: "RESEARCH_PLANNER", + elapsed_ms: 2000, + context_bytes, + dispatch_ids: ["a", "b"], + }), + ); const live = liveResearchProgress(status({ completed_stages: stages as unknown })); const published = liveResearchProgress( status({ activity_events: [ - { seq: 1, kind: "action", action_type: "stage_completed", live: false, payload: { elapsed_ms: 1500, context_bytes: 2048, dispatch_count: 2 } }, - { seq: 2, kind: "action", action_type: "source_read", live: false, payload: { page: 3, exact_text: " 표 3 " } }, - { seq: 3, kind: "action", action_type: "source_read", live: false, payload: { page: null, exact_text: null } }, - { seq: 4, kind: "action", action_type: "hypothesis_review", live: false, payload: { hypothesis_count: 4, review_count: 4 } }, - { seq: 5, kind: "action", action_type: "hypothesis_review", live: false, payload: { hypothesis_count: 4, review_count: 1 } }, - { seq: 6, kind: "action", action_type: "hypothesis_review", live: false, payload: { hypothesis_count: 4 } }, - { seq: 7, kind: "action", action_type: "model_call", live: false, payload: { state: "OBSERVED", received_bytes: 1200, elapsed_ms: 800 } }, - { seq: 8, kind: "action", action_type: "model_call", live: true, payload: { state: "RESERVED", elapsed_ms: 3000, first_byte_ms: 400 } }, + { + seq: 1, + kind: "action", + action_type: "stage_completed", + live: false, + payload: { elapsed_ms: 1500, context_bytes: 2048, dispatch_count: 2 }, + }, + { + seq: 2, + kind: "action", + action_type: "source_read", + live: false, + payload: { page: 3, exact_text: " 표 3 " }, + }, + { + seq: 3, + kind: "action", + action_type: "source_read", + live: false, + payload: { page: null, exact_text: null }, + }, + { + seq: 4, + kind: "action", + action_type: "hypothesis_review", + live: false, + payload: { hypothesis_count: 4, review_count: 4 }, + }, + { + seq: 5, + kind: "action", + action_type: "hypothesis_review", + live: false, + payload: { hypothesis_count: 4, review_count: 1 }, + }, + { + seq: 6, + kind: "action", + action_type: "hypothesis_review", + live: false, + payload: { hypothesis_count: 4 }, + }, + { + seq: 7, + kind: "action", + action_type: "model_call", + live: false, + payload: { state: "OBSERVED", received_bytes: 1200, elapsed_ms: 800 }, + }, + { + seq: 8, + kind: "action", + action_type: "model_call", + live: true, + payload: { state: "RESERVED", elapsed_ms: 3000, first_byte_ms: 400 }, + }, ], }), ); - const flat = (events: { kind: string; text: string; payload?: string | null; live?: boolean }[] | undefined) => - events?.map((event) => `${event.kind}|${event.text}|${event.payload ?? ""}|${event.live ?? false}`); - expect({ stages: flat(live?.events), published: flat(published?.events) }).toMatchInlineSnapshot(` + const flat = ( + events: { kind: string; text: string; payload?: string | null; live?: boolean }[] | undefined, + ) => + events?.map( + (event) => `${event.kind}|${event.text}|${event.payload ?? ""}|${event.live ?? false}`, + ); + expect({ stages: flat(live?.events), published: flat(published?.events) }) + .toMatchInlineSnapshot(` { "published": [ "action|단계 완료|2초 · 맥락 2.0KB · 모델 2회|false", @@ -333,11 +500,23 @@ describe("one-line texts", () => { composerActivity(status({ execution_state: "PAUSE_PENDING" })), composerActivity(status({ execution_state: "PAUSED" })), composerActivity( - status({ attempt: { operation_id: "o", phase: "HOLD", status: "RUNNING", external_effect_state: "DISPATCHING", remote_observation: "UNKNOWN" } }), + status({ + attempt: { + operation_id: "o", + phase: "HOLD", + status: "RUNNING", + external_effect_state: "DISPATCHING", + remote_observation: "UNKNOWN", + }, + }), ), composerActivity(status({ operation_state: "SUCCEEDED" })), composerActivity(status({ attempt: undefined })), - composerActivity(inPhase("EVIDENCE_FOCUS", { model_dispatches: [{ state: "RESERVED", transport_observation: { elapsed_ms: 47000 } }] })), + composerActivity( + inPhase("EVIDENCE_FOCUS", { + model_dispatches: [{ state: "RESERVED", transport_observation: { elapsed_ms: 47000 } }], + }), + ), ]; expect(lines).toMatchInlineSnapshot(` [ @@ -352,16 +531,37 @@ describe("one-line texts", () => { }); it("pins the hold reasons and the evidence labels", () => { - const reasons = ["HTTP 429", "AUTH_REQUIRED", "x403", "MODEL_NOT_SUPPORTED", "400", "other", ""].map((terminal_reason) => { + const reasons = [ + "HTTP 429", + "AUTH_REQUIRED", + "x403", + "MODEL_NOT_SUPPORTED", + "400", + "other", + "", + ].map((terminal_reason) => { const live = liveResearchProgress( inPhase("HOLD", { operation_state: "SUCCEEDED", - current_result: { operation_id: "o", phase: "HOLD", state: "PARTIAL", completion: "TERMINAL", terminal_reason, basis_digest: "0", result: {}, gaps: [], next_steps: [], source_refs: [], record_refs: [] }, + current_result: { + operation_id: "o", + phase: "HOLD", + state: "PARTIAL", + completion: "TERMINAL", + terminal_reason, + basis_digest: "0", + result: {}, + gaps: [], + next_steps: [], + source_refs: [], + record_refs: [], + }, }), ); return `${terminal_reason}: ${live?.missing}`; }); - expect({ reasons, labels: evidenceLabels(inPhase("EVIDENCE_FOCUS", {}, RICH_DRAFT)) }).toMatchInlineSnapshot(` + expect({ reasons, labels: evidenceLabels(inPhase("EVIDENCE_FOCUS", {}, RICH_DRAFT)) }) + .toMatchInlineSnapshot(` { "labels": [ "p.6", diff --git a/apps/web/src/api/researchProgressText.ts b/apps/web/src/api/researchProgressText.ts index d2f26606..f492de86 100644 --- a/apps/web/src/api/researchProgressText.ts +++ b/apps/web/src/api/researchProgressText.ts @@ -5,20 +5,62 @@ import * as en from "../i18n/progressEn"; import * as zh from "../i18n/progressZh"; import * as zhHant from "../i18n/progressZhHant"; import * as ja from "../i18n/progressJa"; -export type {ProgressScene, TimelineIcon, WorkFacts} from "../i18n/progressKo"; -export {clip, unique, compactPayload, locatorPayload} from "../i18n/progressKo"; -type Wording = Pick; -const wording: Record, Wording> = { ko, en, "zh-Hans": zh, "zh-Hant": zhHant, ja }; +export type { ProgressScene, TimelineIcon, WorkFacts } from "../i18n/progressKo"; +export { clip, unique, compactPayload, locatorPayload } from "../i18n/progressKo"; +type Wording = Pick< + typeof ko, + | "ACTION_COPY" + | "formatDuration" + | "formatBytes" + | "sceneFor" + | "waitLine" + | "usageLine" + | "missingFor" + | "stageActionPayload" + | "modelActionPayload" + | "hypothesisActionPayload" +>; +const wording: Record, Wording> = { + ko, + en, + "zh-Hans": zh, + "zh-Hant": zhHant, + ja, +}; const current = () => wording[getLocale()]; -export const ACTION_COPY: typeof ko.ACTION_COPY = Object.fromEntries(Object.keys(ko.ACTION_COPY).map(key => [key, { - get label() { return current().ACTION_COPY[key].label; }, icon: ko.ACTION_COPY[key].icon, -}])); -export const formatDuration = (...args: Parameters): ReturnType => current().formatDuration(...args); -export const formatBytes = (...args: Parameters): ReturnType => current().formatBytes(...args); -export const sceneFor = (...args: Parameters): ReturnType => current().sceneFor(...args); -export const waitLine = (...args: Parameters): ReturnType => current().waitLine(...args); -export const usageLine = (...args: Parameters): ReturnType => current().usageLine(...args); -export const missingFor = (...args: Parameters): ReturnType => current().missingFor(...args); -export const stageActionPayload = (...args: Parameters): ReturnType => current().stageActionPayload(...args); -export const modelActionPayload = (...args: Parameters): ReturnType => current().modelActionPayload(...args); -export const hypothesisActionPayload = (...args: Parameters): ReturnType => current().hypothesisActionPayload(...args); \ No newline at end of file +export const ACTION_COPY: typeof ko.ACTION_COPY = Object.fromEntries( + Object.keys(ko.ACTION_COPY).map((key) => [ + key, + { + get label() { + return current().ACTION_COPY[key].label; + }, + icon: ko.ACTION_COPY[key].icon, + }, + ]), +); +export const formatDuration = ( + ...args: Parameters +): ReturnType => current().formatDuration(...args); +export const formatBytes = ( + ...args: Parameters +): ReturnType => current().formatBytes(...args); +export const sceneFor = (...args: Parameters): ReturnType => + current().sceneFor(...args); +export const waitLine = (...args: Parameters): ReturnType => + current().waitLine(...args); +export const usageLine = ( + ...args: Parameters +): ReturnType => current().usageLine(...args); +export const missingFor = ( + ...args: Parameters +): ReturnType => current().missingFor(...args); +export const stageActionPayload = ( + ...args: Parameters +): ReturnType => current().stageActionPayload(...args); +export const modelActionPayload = ( + ...args: Parameters +): ReturnType => current().modelActionPayload(...args); +export const hypothesisActionPayload = ( + ...args: Parameters +): ReturnType => current().hypothesisActionPayload(...args); diff --git a/apps/web/src/api/researchStatusRead.test.ts b/apps/web/src/api/researchStatusRead.test.ts index 35cd5892..12eb6b19 100644 --- a/apps/web/src/api/researchStatusRead.test.ts +++ b/apps/web/src/api/researchStatusRead.test.ts @@ -15,9 +15,17 @@ vi.mock("./rpcClient", () => ({ const { readResearchStatus } = await import("./researchStatusRead"); const key = ["research", "p", "t"]; -const status = (patch: Record) => ({ operation_state: "SUCCEEDED", usage: { total_tokens: 1 }, ...patch }); +const status = (patch: Record) => ({ + operation_state: "SUCCEEDED", + usage: { total_tokens: 1 }, + ...patch, +}); let client: QueryClient; -beforeEach(() => { calls.length = 0; replies.length = 0; client = new QueryClient(); }); +beforeEach(() => { + calls.length = 0; + replies.length = 0; + client = new QueryClient(); +}); it("reads in full when nothing is running", async () => { replies.push(status({ current_result: { operation_id: "op" } })); @@ -27,34 +35,58 @@ it("reads in full when nothing is running", async () => { }); it("requests typed expected-result views only from a server that advertises support", async () => { - client.setQueryData(["workspace-ready"], {value: {response_language_scope: "REQUEST", response_languages: ["ko", "en"]}}); + client.setQueryData(["workspace-ready"], { + value: { response_language_scope: "REQUEST", response_languages: ["ko", "en"] }, + }); replies.push(status({ current_result: { operation_id: "op" } })); await readResearchStatus(client, key, "p", "t"); - expect(calls[0].input).toEqual({project_id: "p", thread_id: "t", expected_result_contract: "v4"}); + expect(calls[0].input).toEqual({ + project_id: "p", + thread_id: "t", + expected_result_contract: "v4", + }); }); it("prefers v5 from cached workspace readiness instead of pinning a supported server to v4", async () => { - client.setQueryData(["workspace-ready"], {value: {response_language_scope: "REQUEST", expected_result_contracts: ["v4", "v5"]}}); + client.setQueryData(["workspace-ready"], { + value: { response_language_scope: "REQUEST", expected_result_contracts: ["v4", "v5"] }, + }); replies.push(status({ current_result: { operation_id: "op" } })); await readResearchStatus(client, key, "p", "t"); expect(calls[0].input.expected_result_contract).toBe("v5"); }); it("polls the light progress view while running and keeps the earlier full fields", async () => { - client.setQueryData(key, { operation_id: "", state: "SUCCEEDED", value: status({ operation_state: "RUNNING", coverage_matrix: { rows: [1] }, usage: { total_tokens: 1 } }) }); + client.setQueryData(key, { + operation_id: "", + state: "SUCCEEDED", + value: status({ + operation_state: "RUNNING", + coverage_matrix: { rows: [1] }, + usage: { total_tokens: 1 }, + }), + }); replies.push(status({ operation_state: "RUNNING", usage: { total_tokens: 99 } })); const result = await readResearchStatus(client, key, "p", "t"); - expect(calls).toEqual([{ method: "thread/read", input: { project_id: "p", thread_id: "t", view: "PROGRESS" } }]); + expect(calls).toEqual([ + { method: "thread/read", input: { project_id: "p", thread_id: "t", view: "PROGRESS" } }, + ]); expect(result.value.operation_state).toBe("RUNNING"); expect((result.value.usage as { total_tokens: number }).total_tokens).toBe(99); expect((result.value as Record).coverage_matrix).toEqual({ rows: [1] }); }); it("reads in full once, right after the light read says the run ended", async () => { - client.setQueryData(key, { operation_id: "", state: "SUCCEEDED", value: status({ operation_state: "RUNNING" }) }); - replies.push(status({ operation_state: "SUCCEEDED" }), status({ operation_state: "SUCCEEDED", current_result: { operation_id: "op" } })); + client.setQueryData(key, { + operation_id: "", + state: "SUCCEEDED", + value: status({ operation_state: "RUNNING" }), + }); + replies.push( + status({ operation_state: "SUCCEEDED" }), + status({ operation_state: "SUCCEEDED", current_result: { operation_id: "op" } }), + ); const result = await readResearchStatus(client, key, "p", "t"); - expect(calls.map(call => call.input.view)).toEqual(["PROGRESS", undefined]); + expect(calls.map((call) => call.input.view)).toEqual(["PROGRESS", undefined]); expect((result.value as ResearchStatus).current_result).toBeTruthy(); }); - diff --git a/apps/web/src/api/researchStatusRead.ts b/apps/web/src/api/researchStatusRead.ts index 87f05adf..534b9541 100644 --- a/apps/web/src/api/researchStatusRead.ts +++ b/apps/web/src/api/researchStatusRead.ts @@ -10,7 +10,11 @@ import { expectedResultContract } from "./expectedResultContract"; * the first read that no longer says running is a full one, so the result appears exactly once. */ export async function readResearchStatus( - client: QueryClient, queryKey: readonly unknown[], projectId: string, threadId: string, signal?: AbortSignal, + client: QueryClient, + queryKey: readonly unknown[], + projectId: string, + threadId: string, + signal?: AbortSignal, ): Promise> { const ready = client.getQueryData>(["workspace-ready"]); const selected = expectedResultContract(ready?.value); @@ -19,15 +23,29 @@ export async function readResearchStatus( // Same key contract as useReadAccessProtection; the query's real success releases it. const guardKey = ["read-access-blocked", queryHash]; const owner = client.getQueryCache().get(queryHash); - const requiresFullRead = () => client.getQueryData(guardKey) === true - || isReadAccessDenied(client.getQueryState(queryKey)?.error); + const requiresFullRead = () => + client.getQueryData(guardKey) === true || + isReadAccessDenied(client.getQueryState(queryKey)?.error); try { - const prior = requiresFullRead() ? undefined : client.getQueryData>(queryKey); + const prior = requiresFullRead() + ? undefined + : client.getQueryData>(queryKey); if (prior?.value.operation_state === "RUNNING") { - const light = await rpc("thread/read", { project_id: projectId, thread_id: threadId, ...contract, view: "PROGRESS" }, crypto.randomUUID(), signal); - if (light.value.operation_state === "RUNNING" && !requiresFullRead()) return { ...light, value: { ...prior.value, ...light.value } }; + const light = await rpc( + "thread/read", + { project_id: projectId, thread_id: threadId, ...contract, view: "PROGRESS" }, + crypto.randomUUID(), + signal, + ); + if (light.value.operation_state === "RUNNING" && !requiresFullRead()) + return { ...light, value: { ...prior.value, ...light.value } }; } - return await rpc("thread/read", { project_id: projectId, thread_id: threadId, ...contract }, crypto.randomUUID(), signal); + return await rpc( + "thread/read", + { project_id: projectId, thread_id: threadId, ...contract }, + crypto.randomUUID(), + signal, + ); } catch (error) { if (isReadAccessDenied(error) && client.getQueryCache().get(queryHash) === owner) { client.setQueryData(guardKey, true); @@ -35,4 +53,3 @@ export async function readResearchStatus( throw error; } } - diff --git a/apps/web/src/api/responseLanguageDefault.ts b/apps/web/src/api/responseLanguageDefault.ts index a806dac9..ea296669 100644 --- a/apps/web/src/api/responseLanguageDefault.ts +++ b/apps/web/src/api/responseLanguageDefault.ts @@ -16,15 +16,21 @@ const listeners = new Set<() => void>(); const unsaved = new Map(); function identity(scope: BrowserScope): string | null { - return scope.mode === "LOCAL" ? scope.workspaceId : scope.browserScopeId ?? null; + return scope.mode === "LOCAL" ? scope.workspaceId : (scope.browserScopeId ?? null); +} +function memoryKey(scope: BrowserScope): string { + return `${scope.mode}:${identity(scope)}`; } -function memoryKey(scope: BrowserScope): string { return `${scope.mode}:${identity(scope)}`; } function storageKey(scope: BrowserScope): string | null { const id = identity(scope); return id ? `thoth:response-language-default:v1:${JSON.stringify([id])}` : null; } function store(scope: BrowserScope): Storage | null { - try { return scope.mode === "LOCAL" ? localStorage : sessionStorage; } catch { return null; } + try { + return scope.mode === "LOCAL" ? localStorage : sessionStorage; + } catch { + return null; + } } export function readResponseLanguageDefault(scope: BrowserScope): ResponseLanguageDefault { @@ -35,19 +41,30 @@ export function readResponseLanguageDefault(scope: BrowserScope): ResponseLangua try { const value = store(scope)?.getItem(name); return isResponseLanguage(value) ? value : null; - } catch { return null; } + } catch { + return null; + } } /** Returns false when the browser could not keep the choice; it then lasts until the tab closes. */ -export function writeResponseLanguageDefault(scope: BrowserScope, value: ResponseLanguageDefault): boolean { +export function writeResponseLanguageDefault( + scope: BrowserScope, + value: ResponseLanguageDefault, +): boolean { let saved = false; const name = storageKey(scope); try { const target = store(scope); - if (name && target) { target.setItem(name, value ?? "auto"); saved = true; } - } catch { saved = false; } - if (saved) unsaved.delete(memoryKey(scope)); else unsaved.set(memoryKey(scope), value); - listeners.forEach(listener => listener()); + if (name && target) { + target.setItem(name, value ?? "auto"); + saved = true; + } + } catch { + saved = false; + } + if (saved) unsaved.delete(memoryKey(scope)); + else unsaved.set(memoryKey(scope), value); + listeners.forEach((listener) => listener()); return saved; } @@ -55,9 +72,16 @@ const subscribe = (listener: () => void) => { listeners.add(listener); const onStorage = () => listener(); if (typeof window !== "undefined") window.addEventListener("storage", onStorage); - return () => { listeners.delete(listener); if (typeof window !== "undefined") window.removeEventListener("storage", onStorage); }; + return () => { + listeners.delete(listener); + if (typeof window !== "undefined") window.removeEventListener("storage", onStorage); + }; }; export function useResponseLanguageDefault(scope: BrowserScope): ResponseLanguageDefault { - return useSyncExternalStore(subscribe, () => readResponseLanguageDefault(scope), () => null); + return useSyncExternalStore( + subscribe, + () => readResponseLanguageDefault(scope), + () => null, + ); } diff --git a/apps/web/src/api/responseLanguages.test.ts b/apps/web/src/api/responseLanguages.test.ts index be0567d3..c3ab1162 100644 --- a/apps/web/src/api/responseLanguages.test.ts +++ b/apps/web/src/api/responseLanguages.test.ts @@ -1,28 +1,65 @@ // @vitest-environment jsdom import { afterEach, expect, it } from "vitest"; import { LANGUAGE_CHOICES } from "../i18n/languageChoices"; -import { readResponseLanguageDefault, writeResponseLanguageDefault } from "./responseLanguageDefault"; +import { + readResponseLanguageDefault, + writeResponseLanguageDefault, +} from "./responseLanguageDefault"; import { submissionSignature, type PendingSubmission } from "./localWorkspacePersistence"; -import { isResponseLanguage, knownResponseLanguages, RESPONSE_LANGUAGE_CHOICES, responseLanguageLabel } from "./responseLanguages"; +import { + isResponseLanguage, + knownResponseLanguages, + RESPONSE_LANGUAGE_CHOICES, + responseLanguageLabel, +} from "./responseLanguages"; import { clearSubmission, readSubmission, writeSubmission } from "./submissionPersistence"; import { resultLanguage } from "../i18n/ResponseLanguageContext"; -afterEach(() => { localStorage.clear(); sessionStorage.clear(); }); +afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); +}); it("lists the answer languages in one place, apart from the screen languages", () => { - expect(RESPONSE_LANGUAGE_CHOICES.map(choice => choice.code)).toEqual(["ko", "en", "zh-Hans", "zh-Hant", "ja"]); + expect(RESPONSE_LANGUAGE_CHOICES.map((choice) => choice.code)).toEqual([ + "ko", + "en", + "zh-Hans", + "zh-Hant", + "ja", + ]); expect(responseLanguageLabel("ja")).toBe("日本語"); expect(responseLanguageLabel("zh-Hans")).toBe("中文(简体)"); expect(responseLanguageLabel("zh-Hant")).toBe("中文(繁體)"); - expect(LANGUAGE_CHOICES.map(choice => choice.code)).toEqual(["ko", "en", "zh-Hans", "zh-Hant", "ja"]); - expect(RESPONSE_LANGUAGE_CHOICES.map(choice => choice.label)).toEqual(LANGUAGE_CHOICES.map(choice => choice.label)); + expect(LANGUAGE_CHOICES.map((choice) => choice.code)).toEqual([ + "ko", + "en", + "zh-Hans", + "zh-Hant", + "ja", + ]); + expect(RESPONSE_LANGUAGE_CHOICES.map((choice) => choice.label)).toEqual( + LANGUAGE_CHOICES.map((choice) => choice.label), + ); }); it("accepts only listed languages, and keeps from a server's list only the ones this app can offer", () => { expect(["ko", "en", "zh-Hans", "zh-Hant", "ja"].every(isResponseLanguage)).toBe(true); - expect(["fr", "zh", "zh-TW", "zh-hant", "ja-JP", "", null, 1].some(isResponseLanguage)).toBe(false); - expect(knownResponseLanguages(["ko", "fr", "zh-Hans", "zh-Hant", "ja", 3])).toEqual(["ko", "zh-Hans", "zh-Hant", "ja"]); - expect(knownResponseLanguages(["ko", "en", "zh-Hans", "ja"])).toEqual(["ko", "en", "zh-Hans", "ja"]); + expect(["fr", "zh", "zh-TW", "zh-hant", "ja-JP", "", null, 1].some(isResponseLanguage)).toBe( + false, + ); + expect(knownResponseLanguages(["ko", "fr", "zh-Hans", "zh-Hant", "ja", 3])).toEqual([ + "ko", + "zh-Hans", + "zh-Hant", + "ja", + ]); + expect(knownResponseLanguages(["ko", "en", "zh-Hans", "ja"])).toEqual([ + "ko", + "en", + "zh-Hans", + "ja", + ]); expect(knownResponseLanguages(undefined)).toEqual([]); expect(resultLanguage({ response_language: "zh-Hans" })).toBe("zh-Hans"); expect(resultLanguage({ response_language: "fr" })).toBeNull(); @@ -34,10 +71,18 @@ it("keeps a Chinese default and a Chinese pending request, and refuses an unlist const scope = { mode: "LOCAL" as const, workspaceId: "workspace:zh-hans" }; writeResponseLanguageDefault(scope, "zh-Hans"); expect(readResponseLanguageDefault(scope)).toBe("zh-Hans"); - const pending: PendingSubmission = { version: 2, responseLanguage: "zh-Hans", workspaceId: scope.workspaceId, - projectId: "p", threadId: "t", problem: "中文问题", selection: null, + const pending: PendingSubmission = { + version: 2, + responseLanguage: "zh-Hans", + workspaceId: scope.workspaceId, + projectId: "p", + threadId: "t", + problem: "中文问题", + selection: null, signature: submissionSignature("p", "t", "中文问题", null, null, "zh-Hans"), - key: "11111111-1111-1111-1111-111111111111", createdAt: 1 }; + key: "11111111-1111-1111-1111-111111111111", + createdAt: 1, + }; expect(writeSubmission(scope, pending)).toBe("SAVED"); expect(readSubmission(scope, "p", "t")).toEqual({ kind: "PENDING", value: pending }); expect(clearSubmission(scope, "p", "t")).toBe(true); diff --git a/apps/web/src/api/responseLanguages.ts b/apps/web/src/api/responseLanguages.ts index 2c317058..b7973282 100644 --- a/apps/web/src/api/responseLanguages.ts +++ b/apps/web/src/api/responseLanguages.ts @@ -14,12 +14,12 @@ export const RESPONSE_LANGUAGE_CHOICES = [ export type ResponseLanguage = (typeof RESPONSE_LANGUAGE_CHOICES)[number]["code"]; export function isResponseLanguage(value: unknown): value is ResponseLanguage { - return RESPONSE_LANGUAGE_CHOICES.some(choice => choice.code === value); + return RESPONSE_LANGUAGE_CHOICES.some((choice) => choice.code === value); } /** Languages are named in their own writing, the same on every screen language. */ export function responseLanguageLabel(code: ResponseLanguage): string { - return RESPONSE_LANGUAGE_CHOICES.find(choice => choice.code === code)?.label ?? code; + return RESPONSE_LANGUAGE_CHOICES.find((choice) => choice.code === code)?.label ?? code; } /** The languages of a server's list that this app knows how to offer; a language only the server knows is left out. */ diff --git a/apps/web/src/api/restore.test.ts b/apps/web/src/api/restore.test.ts index 2800212b..9aa75f62 100644 --- a/apps/web/src/api/restore.test.ts +++ b/apps/web/src/api/restore.test.ts @@ -2,21 +2,43 @@ import { afterEach, expect, it, vi } from "vitest"; import { captureRestoreAttempt, readRestorePreview, sendRestoreAttempt } from "./restore"; import type { RestorePreview, RestoreSelection } from "./historyModels"; -const a = "a".repeat(64), b = "b".repeat(64); -const selection: RestoreSelection = { projectId: "p", entityType: "HYPOTHESIS", entityId: "h", targetDigest: a, expectedHead: b }; -const preview: RestorePreview = { selection, basisDigest: a, principalScope: b, availability: "AVAILABLE", applyReady: true, - profile: "hypothesis.v1", reasons: [], comparison: { changes: [], coverage: "COMPLETE", reasons: [] }, impacts: [], sourceChanges: [], technical: {} }; -const envelope = (state: string, value: unknown, operation_id = "op") => new Response(JSON.stringify({ jsonrpc: "2.0", id: "t", result: { state, value, operation_id } })); +const a = "a".repeat(64), + b = "b".repeat(64); +const selection: RestoreSelection = { + projectId: "p", + entityType: "HYPOTHESIS", + entityId: "h", + targetDigest: a, + expectedHead: b, +}; +const preview: RestorePreview = { + selection, + basisDigest: a, + principalScope: b, + availability: "AVAILABLE", + applyReady: true, + profile: "hypothesis.v1", + reasons: [], + comparison: { changes: [], coverage: "COMPLETE", reasons: [] }, + impacts: [], + sourceChanges: [], + technical: {}, +}; +const envelope = (state: string, value: unknown, operation_id = "op") => + new Response(JSON.stringify({ jsonrpc: "2.0", id: "t", result: { state, value, operation_id } })); afterEach(() => vi.unstubAllGlobals()); it("keeps the same key and payload after transport loss, and RUNNING is not APPLIED", async () => { const attempt = captureRestoreAttempt(preview); - const fetcher = vi.fn().mockRejectedValueOnce(new TypeError("network lost")).mockResolvedValueOnce(envelope("RUNNING", {})); + const fetcher = vi + .fn() + .mockRejectedValueOnce(new TypeError("network lost")) + .mockResolvedValueOnce(envelope("RUNNING", {})); vi.stubGlobal("fetch", fetcher); await expect(sendRestoreAttempt(attempt)).rejects.toThrow("network lost"); const second = await sendRestoreAttempt(attempt); expect(second.state).toBe("RUNNING"); - const inputs = fetcher.mock.calls.map(call => JSON.parse(call[1].body as string)); + const inputs = fetcher.mock.calls.map((call) => JSON.parse(call[1].body as string)); expect(inputs[0].params).toEqual(inputs[1].params); expect(inputs[0].method).toBe("revision/restore/apply"); }); @@ -27,12 +49,32 @@ it("does not create an apply attempt while the server readiness gate is closed", }); it("rejects a preview for another entity even when the request succeeded", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(envelope("SUCCEEDED", { - contract_version: 2, selection: { project_id: "p", entity_type: "HYPOTHESIS", entity_id: "other", target_revision_digest: a, expected_current_head: b }, - profile_id: "hypothesis.v1", availability: "AVAILABLE", reason_codes: [], actor_scope_digest: b, basis_digest: a, - capability: { restore: "RESTORE_SUPPORTED", apply_ready: true, reason_codes: [] }, - impact: { stale_refs: [], invalidated_refs: [], recalculate_refs: [] }, diff: [], summary_groups: [], source_drift: [], - reanalysis: "NOT_REQUESTED", external_effects: "PRESERVED", - }))); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + envelope("SUCCEEDED", { + contract_version: 2, + selection: { + project_id: "p", + entity_type: "HYPOTHESIS", + entity_id: "other", + target_revision_digest: a, + expected_current_head: b, + }, + profile_id: "hypothesis.v1", + availability: "AVAILABLE", + reason_codes: [], + actor_scope_digest: b, + basis_digest: a, + capability: { restore: "RESTORE_SUPPORTED", apply_ready: true, reason_codes: [] }, + impact: { stale_refs: [], invalidated_refs: [], recalculate_refs: [] }, + diff: [], + summary_groups: [], + source_drift: [], + reanalysis: "NOT_REQUESTED", + external_effects: "PRESERVED", + }), + ), + ); await expect(readRestorePreview(selection)).rejects.toThrow(/연결/); }); diff --git a/apps/web/src/api/restore.ts b/apps/web/src/api/restore.ts index a2ad75bb..a7685645 100644 --- a/apps/web/src/api/restore.ts +++ b/apps/web/src/api/restore.ts @@ -2,83 +2,225 @@ import { UiError } from "../i18n/UiError"; import { t } from "../i18n/locale"; import { z } from "zod"; import { rpc, RpcError } from "./rpcClient"; -import { capabilitySchema, changeGroupSchema, diffEntrySchema, digestSchema } from "./historySchemas"; +import { + capabilitySchema, + changeGroupSchema, + diffEntrySchema, + digestSchema, +} from "./historySchemas"; import { mapComparison } from "./historyComparison"; -import { restoreKey, type RestorePreview, type RestoreResult, type RestoreSelection } from "./historyModels"; +import { + restoreKey, + type RestorePreview, + type RestoreResult, + type RestoreSelection, +} from "./historyModels"; -export const restoreSelectionSchema = z.object({ project_id: z.string(), entity_type: z.string(), entity_id: z.string(), - target_revision_digest: digestSchema, expected_current_head: digestSchema }); -const impactSchema = z.object({ stale_refs: z.array(z.string()), invalidated_refs: z.array(z.string()), recalculate_refs: z.array(z.string()) }); -const previewSchema = z.object({ contract_version: z.literal(2), selection: restoreSelectionSchema, - profile_id: z.string().nullable(), availability: z.enum(["AVAILABLE", "BLOCKED", "NO_CHANGE"]), - reason_codes: z.array(z.string()), capability: capabilitySchema, actor_scope_digest: digestSchema, basis_digest: digestSchema.nullable(), - impact: impactSchema, diff: z.array(diffEntrySchema), summary_groups: z.array(changeGroupSchema), source_drift: z.array(z.string()), - reanalysis: z.literal("NOT_REQUESTED"), external_effects: z.literal("PRESERVED") }); -const applyResultSchema = z.object({ contract_version: z.literal(2), status: z.enum(["APPLIED", "NO_CHANGE"]), - selection: restoreSelectionSchema, new_revision_digest: digestSchema.nullable(), new_revision_id: z.string().nullable(), - receipt_id: z.string().nullable(), receipt_digest: digestSchema.nullable(), impact: impactSchema, - reanalysis: z.literal("NOT_REQUESTED"), external_effects: z.literal("PRESERVED") }); +export const restoreSelectionSchema = z.object({ + project_id: z.string(), + entity_type: z.string(), + entity_id: z.string(), + target_revision_digest: digestSchema, + expected_current_head: digestSchema, +}); +const impactSchema = z.object({ + stale_refs: z.array(z.string()), + invalidated_refs: z.array(z.string()), + recalculate_refs: z.array(z.string()), +}); +const previewSchema = z.object({ + contract_version: z.literal(2), + selection: restoreSelectionSchema, + profile_id: z.string().nullable(), + availability: z.enum(["AVAILABLE", "BLOCKED", "NO_CHANGE"]), + reason_codes: z.array(z.string()), + capability: capabilitySchema, + actor_scope_digest: digestSchema, + basis_digest: digestSchema.nullable(), + impact: impactSchema, + diff: z.array(diffEntrySchema), + summary_groups: z.array(changeGroupSchema), + source_drift: z.array(z.string()), + reanalysis: z.literal("NOT_REQUESTED"), + external_effects: z.literal("PRESERVED"), +}); +const applyResultSchema = z.object({ + contract_version: z.literal(2), + status: z.enum(["APPLIED", "NO_CHANGE"]), + selection: restoreSelectionSchema, + new_revision_digest: digestSchema.nullable(), + new_revision_id: z.string().nullable(), + receipt_id: z.string().nullable(), + receipt_digest: digestSchema.nullable(), + impact: impactSchema, + reanalysis: z.literal("NOT_REQUESTED"), + external_effects: z.literal("PRESERVED"), +}); export const restoreAttemptSchema = z.object({ - key: z.string().min(1), principalScope: digestSchema, - input: z.object({ project_id: z.string(), selection: restoreSelectionSchema, contract_version: z.literal(2), - preview_basis_digest: digestSchema, reason: z.string().min(1).max(2000) }), + key: z.string().min(1), + principalScope: digestSchema, + input: z.object({ + project_id: z.string(), + selection: restoreSelectionSchema, + contract_version: z.literal(2), + preview_basis_digest: digestSchema, + reason: z.string().min(1).max(2000), + }), }); export type RestoreAttempt = z.infer; -export type RestoreOutcome = { state: "RUNNING"; operationId: string } | { state: "COMPLETE"; result: RestoreResult }; +export type RestoreOutcome = + { state: "RUNNING"; operationId: string } | { state: "COMPLETE"; result: RestoreResult }; export function wireRestore(selection: RestoreSelection) { - return { project_id: selection.projectId, entity_type: selection.entityType, entity_id: selection.entityId, - target_revision_digest: selection.targetDigest, expected_current_head: selection.expectedHead }; + return { + project_id: selection.projectId, + entity_type: selection.entityType, + entity_id: selection.entityId, + target_revision_digest: selection.targetDigest, + expected_current_head: selection.expectedHead, + }; } function selectionOf(value: z.infer): RestoreSelection { - return { projectId: value.project_id, entityType: value.entity_type, entityId: value.entity_id, - targetDigest: value.target_revision_digest, expectedHead: value.expected_current_head }; + return { + projectId: value.project_id, + entityType: value.entity_type, + entityId: value.entity_id, + targetDigest: value.target_revision_digest, + expectedHead: value.expected_current_head, + }; } function requireSelection(actual: RestoreSelection, expected: RestoreSelection) { if (restoreKey(actual) !== restoreKey(expected)) throw new UiError("api.restoreTarget"); } -export async function readRestorePreview(selection: RestoreSelection, signal?: AbortSignal): Promise { - const response = await rpc("revision/restore/preview", { project_id: selection.projectId, selection: wireRestore(selection), contract_version: 2 }, crypto.randomUUID(), signal); +export async function readRestorePreview( + selection: RestoreSelection, + signal?: AbortSignal, +): Promise { + const response = await rpc( + "revision/restore/preview", + { project_id: selection.projectId, selection: wireRestore(selection), contract_version: 2 }, + crypto.randomUUID(), + signal, + ); if (response.state !== "SUCCEEDED") throw new UiError("api.restorePreviewIncomplete"); const value = previewSchema.parse(response.value); requireSelection(selectionOf(value.selection), selection); - const states = { get stale_refs() { return t("history.review"); }, get invalidated_refs() { return t("history.invalid"); }, get recalculate_refs() { return t("api.restoreReviewItems"); } }; - return { selection, basisDigest: value.basis_digest ?? "", principalScope: value.actor_scope_digest, - availability: value.availability, applyReady: value.capability.restore === "RESTORE_SUPPORTED" && value.capability.apply_ready, - profile: value.profile_id ?? "", reasons: [...new Set([...value.reason_codes, ...value.capability.reason_codes])], + const states = { + get stale_refs() { + return t("history.review"); + }, + get invalidated_refs() { + return t("history.invalid"); + }, + get recalculate_refs() { + return t("api.restoreReviewItems"); + }, + }; + return { + selection, + basisDigest: value.basis_digest ?? "", + principalScope: value.actor_scope_digest, + availability: value.availability, + applyReady: value.capability.restore === "RESTORE_SUPPORTED" && value.capability.apply_ready, + profile: value.profile_id ?? "", + reasons: [...new Set([...value.reason_codes, ...value.capability.reason_codes])], comparison: mapComparison(value.diff, value.summary_groups), - impacts: Object.entries(value.impact).flatMap(([key, refs]) => refs.map(label => ({ label, state: states[key as keyof typeof states] }))), - sourceChanges: value.source_drift, technical: value }; + impacts: Object.entries(value.impact).flatMap(([key, refs]) => + refs.map((label) => ({ label, state: states[key as keyof typeof states] })), + ), + sourceChanges: value.source_drift, + technical: value, + }; } export function captureRestoreAttempt(preview: RestorePreview): RestoreAttempt { - if (!preview.applyReady || preview.availability !== "AVAILABLE" || !preview.basisDigest || !preview.principalScope) { + if ( + !preview.applyReady || + preview.availability !== "AVAILABLE" || + !preview.basisDigest || + !preview.principalScope + ) { throw new UiError("api.restoreUnavailable"); } - return restoreAttemptSchema.parse({ key: crypto.randomUUID(), principalScope: preview.principalScope, - input: { project_id: preview.selection.projectId, selection: wireRestore(preview.selection), contract_version: 2, - preview_basis_digest: preview.basisDigest, reason: "연구 이력에서 확인한 과거 내용을 새 버전으로 적용" } }); + return restoreAttemptSchema.parse({ + key: crypto.randomUUID(), + principalScope: preview.principalScope, + input: { + project_id: preview.selection.projectId, + selection: wireRestore(preview.selection), + contract_version: 2, + preview_basis_digest: preview.basisDigest, + reason: "연구 이력에서 확인한 과거 내용을 새 버전으로 적용", + }, + }); } function resultOf(value: unknown, attempt: RestoreAttempt, operationId: string): RestoreResult { const parsed = applyResultSchema.parse(value); const selection = selectionOf(parsed.selection); requireSelection(selection, selectionOf(attempt.input.selection)); - if (parsed.status === "APPLIED" && (!parsed.new_revision_digest || !parsed.new_revision_id || !parsed.receipt_id || !parsed.receipt_digest)) { + if ( + parsed.status === "APPLIED" && + (!parsed.new_revision_digest || + !parsed.new_revision_id || + !parsed.receipt_id || + !parsed.receipt_digest) + ) { throw new UiError("api.restoreRecordUnknown"); } - return { disposition: parsed.status, selection, newDigest: parsed.new_revision_digest, operationId, - currentness: { state: parsed.status === "APPLIED" ? "REVIEW_REQUIRED" : "UNKNOWN_BASIS", reasons: [] }, technical: parsed }; + return { + disposition: parsed.status, + selection, + newDigest: parsed.new_revision_digest, + operationId, + currentness: { + state: parsed.status === "APPLIED" ? "REVIEW_REQUIRED" : "UNKNOWN_BASIS", + reasons: [], + }, + technical: parsed, + }; } -export async function sendRestoreAttempt(attempt: RestoreAttempt, signal?: AbortSignal): Promise { +export async function sendRestoreAttempt( + attempt: RestoreAttempt, + signal?: AbortSignal, +): Promise { const response = await rpc("revision/restore/apply", attempt.input, attempt.key, signal); - if (response.state === "RUNNING" && response.operation_id) return { state: "RUNNING", operationId: response.operation_id }; + if (response.state === "RUNNING" && response.operation_id) + return { state: "RUNNING", operationId: response.operation_id }; if (response.state !== "SUCCEEDED") throw new UiError("api.restoreUnconfirmed"); return { state: "COMPLETE", result: resultOf(response.value, attempt, response.operation_id) }; } -export async function readRestoreOperation(attempt: RestoreAttempt, operationId: string, signal?: AbortSignal): Promise { - const response = await rpc("operation/result/read", { project_id: attempt.input.project_id, operation_id: operationId }, crypto.randomUUID(), signal); - const value = z.object({ operation_id: z.string(), state: z.string(), result: z.unknown(), error: z.object({ code: z.number().optional(), message: z.string().optional(), data: z.record(z.unknown()).optional() }).nullable().optional() }).parse(response.value); +export async function readRestoreOperation( + attempt: RestoreAttempt, + operationId: string, + signal?: AbortSignal, +): Promise { + const response = await rpc( + "operation/result/read", + { project_id: attempt.input.project_id, operation_id: operationId }, + crypto.randomUUID(), + signal, + ); + const value = z + .object({ + operation_id: z.string(), + state: z.string(), + result: z.unknown(), + error: z + .object({ + code: z.number().optional(), + message: z.string().optional(), + data: z.record(z.unknown()).optional(), + }) + .nullable() + .optional(), + }) + .parse(response.value); if (value.operation_id !== operationId) throw new UiError("api.restoreBinding"); if (value.state === "RUNNING") return { state: "RUNNING", operationId }; - if (value.state === "FAILED" || value.state === "CANCELLED") throw new RpcError(value.error?.message ?? t("api.restoreNotApplied"), value.error?.code ?? -32000, value.error?.data ?? {}); + if (value.state === "FAILED" || value.state === "CANCELLED") + throw new RpcError( + value.error?.message ?? t("api.restoreNotApplied"), + value.error?.code ?? -32000, + value.error?.data ?? {}, + ); if (value.state !== "SUCCEEDED") throw new UiError("api.restoreStateUnknown"); return { state: "COMPLETE", result: resultOf(value.result, attempt, operationId) }; } diff --git a/apps/web/src/api/restorePending.test.ts b/apps/web/src/api/restorePending.test.ts index 15d96c64..36c9333d 100644 --- a/apps/web/src/api/restorePending.test.ts +++ b/apps/web/src/api/restorePending.test.ts @@ -2,12 +2,34 @@ import { afterEach, expect, it } from "vitest"; import { loadPendingRestore, savePendingRestore } from "./restorePending"; import type { RestoreAttempt } from "./restore"; -const a = "a".repeat(64), b = "b".repeat(64), c = "c".repeat(64); +const a = "a".repeat(64), + b = "b".repeat(64), + c = "c".repeat(64); const scope = { projectId: "p", threadId: "t" }; -const selection = { projectId: "p", entityType: "HYPOTHESIS", entityId: "h", targetDigest: a, expectedHead: b }; -const attempt: RestoreAttempt = { key: "one-click", principalScope: c, - input: { project_id: "p", selection: { project_id: "p", entity_type: "HYPOTHESIS", entity_id: "h", target_revision_digest: a, expected_current_head: b }, - contract_version: 2, preview_basis_digest: a, reason: "사용자 복원" } }; +const selection = { + projectId: "p", + entityType: "HYPOTHESIS", + entityId: "h", + targetDigest: a, + expectedHead: b, +}; +const attempt: RestoreAttempt = { + key: "one-click", + principalScope: c, + input: { + project_id: "p", + selection: { + project_id: "p", + entity_type: "HYPOTHESIS", + entity_id: "h", + target_revision_digest: a, + expected_current_head: b, + }, + contract_version: 2, + preview_basis_digest: a, + reason: "사용자 복원", + }, +}; afterEach(() => sessionStorage.clear()); it("retains the original request when only the current head changes", () => { savePendingRestore(scope, selection, attempt, "operation-1"); diff --git a/apps/web/src/api/restorePending.ts b/apps/web/src/api/restorePending.ts index 8f7b8d5a..b55a63db 100644 --- a/apps/web/src/api/restorePending.ts +++ b/apps/web/src/api/restorePending.ts @@ -2,27 +2,71 @@ import { z } from "zod"; import type { HistoryScope, RestoreSelection } from "./historyModels"; import { restoreAttemptSchema, type RestoreAttempt } from "./restore"; -const pendingSchema = z.object({ version: z.literal(1), attempt: restoreAttemptSchema, operationId: z.string().nullable() }); +const pendingSchema = z.object({ + version: z.literal(1), + attempt: restoreAttemptSchema, + operationId: z.string().nullable(), +}); export type PendingRestore = z.infer; // Head changes must not hide an in-flight attempt. Actor/session/scope changes must. function storageKey(scope: HistoryScope, selection: RestoreSelection, actorScope: string): string { - return `thoth:restore-pending:v1:${JSON.stringify([scope.projectId, scope.threadId, scope.requestDigest, - selection.entityType, selection.entityId, selection.targetDigest, actorScope])}`; + return `thoth:restore-pending:v1:${JSON.stringify([ + scope.projectId, + scope.threadId, + scope.requestDigest, + selection.entityType, + selection.entityId, + selection.targetDigest, + actorScope, + ])}`; } -export function loadPendingRestore(scope: HistoryScope, selection: RestoreSelection, actorScope: string): PendingRestore | null { +export function loadPendingRestore( + scope: HistoryScope, + selection: RestoreSelection, + actorScope: string, +): PendingRestore | null { try { - const value = pendingSchema.parse(JSON.parse(sessionStorage.getItem(storageKey(scope, selection, actorScope)) ?? "null")); + const value = pendingSchema.parse( + JSON.parse(sessionStorage.getItem(storageKey(scope, selection, actorScope)) ?? "null"), + ); const input = value.attempt.input; - if (value.attempt.principalScope !== actorScope || input.project_id !== scope.projectId || input.selection.project_id !== scope.projectId - || input.selection.entity_id !== selection.entityId || input.selection.entity_type !== selection.entityType - || input.selection.target_revision_digest !== selection.targetDigest) return null; + if ( + value.attempt.principalScope !== actorScope || + input.project_id !== scope.projectId || + input.selection.project_id !== scope.projectId || + input.selection.entity_id !== selection.entityId || + input.selection.entity_type !== selection.entityType || + input.selection.target_revision_digest !== selection.targetDigest + ) + return null; return value; - } catch { return null; } + } catch { + return null; + } } -export function savePendingRestore(scope: HistoryScope, selection: RestoreSelection, attempt: RestoreAttempt, operationId: string | null) { - try { sessionStorage.setItem(storageKey(scope, selection, attempt.principalScope), JSON.stringify({ version: 1, attempt, operationId })); } - catch { /* In-memory attempt remains valid; no new key is issued on a transport failure. */ } +export function savePendingRestore( + scope: HistoryScope, + selection: RestoreSelection, + attempt: RestoreAttempt, + operationId: string | null, +) { + try { + sessionStorage.setItem( + storageKey(scope, selection, attempt.principalScope), + JSON.stringify({ version: 1, attempt, operationId }), + ); + } catch { + /* In-memory attempt remains valid; no new key is issued on a transport failure. */ + } } -export function clearPendingRestore(scope: HistoryScope, selection: RestoreSelection, actorScope: string) { - try { sessionStorage.removeItem(storageKey(scope, selection, actorScope)); } catch { /* Optional convenience storage. */ } +export function clearPendingRestore( + scope: HistoryScope, + selection: RestoreSelection, + actorScope: string, +) { + try { + sessionStorage.removeItem(storageKey(scope, selection, actorScope)); + } catch { + /* Optional convenience storage. */ + } } diff --git a/apps/web/src/api/resultEvidence.test.ts b/apps/web/src/api/resultEvidence.test.ts index 9cbe68df..8ba93026 100644 --- a/apps/web/src/api/resultEvidence.test.ts +++ b/apps/web/src/api/resultEvidence.test.ts @@ -3,10 +3,18 @@ import { readResultEvidence } from "./resultEvidence"; import { rpc, RpcError } from "./rpcClient"; import { evidenceResponse, selection, manifest, partial } from "./resultEvidenceTestFixture"; -vi.mock("./rpcClient", async original => ({ ...await original(), rpc: vi.fn() })); +vi.mock("./rpcClient", async (original) => ({ + ...(await original()), + rpc: vi.fn(), +})); afterEach(() => vi.resetAllMocks()); function serve() { - vi.mocked(rpc).mockImplementation(async (_method, args) => evidenceResponse(Number(args.selected_evidence_offset ?? 0), args.include_selected_evidence === true)); + vi.mocked(rpc).mockImplementation(async (_method, args) => + evidenceResponse( + Number(args.selected_evidence_offset ?? 0), + args.include_selected_evidence === true, + ), + ); } it("reads 149 immutable selected refs in 50/50/49 pages without full-project reads", async () => { serve(); @@ -14,27 +22,50 @@ it("reads 149 immutable selected refs in 50/50/49 pages without full-project rea let offset: number | null = 0; while (offset !== null) { const page = await readResultEvidence(selection, partial.result, { offset }); - pages.push(page); offset = page.next_offset; + pages.push(page); + offset = page.next_offset; } - expect(pages.map(page => page.items.length)).toEqual([50, 50, 49]); - expect(pages.flatMap(page => page.items.map(item => item.span_id))).toEqual(manifest.source_refs); - expect(pages.flatMap(page => page.items).filter(item => item.span).length).toBe(25); - expect(vi.mocked(rpc).mock.calls.map(([method]) => method)).toEqual(Array(3).fill("thread/result/read")); - for (const [, args] of vi.mocked(rpc).mock.calls) expect(args.result_revision_digest).toBe(selection.resultDigest); + expect(pages.map((page) => page.items.length)).toEqual([50, 50, 49]); + expect(pages.flatMap((page) => page.items.map((item) => item.span_id))).toEqual( + manifest.source_refs, + ); + expect(pages.flatMap((page) => page.items).filter((item) => item.span).length).toBe(25); + expect(vi.mocked(rpc).mock.calls.map(([method]) => method)).toEqual( + Array(3).fill("thread/result/read"), + ); + for (const [, args] of vi.mocked(rpc).mock.calls) + expect(args.result_revision_digest).toBe(selection.resultDigest); }); it("resolves an old turn once through metadata and pins its immutable result before expansion", async () => { serve(); - const page = await readResultEvidence({ ...selection, resultDigest: undefined }, partial.result, { offset: 0 }); + const page = await readResultEvidence({ ...selection, resultDigest: undefined }, partial.result, { + offset: 0, + }); expect(page.selected_count).toBe(149); const calls = vi.mocked(rpc).mock.calls; expect(calls).toHaveLength(2); expect(calls[0][1].include_selected_evidence).toBeUndefined(); expect(calls[1][1].result_revision_digest).toBe(manifest.result_revision_digest); }); -it.each(["project", "thread", "request", "operation", "result", "manifest", "changed-answer", "order", "count", "source-version", "text-hash", "bytes", "unavailable-bytes"])("rejects %s mismatches", async kind => { +it.each([ + "project", + "thread", + "request", + "operation", + "result", + "manifest", + "changed-answer", + "order", + "count", + "source-version", + "text-hash", + "bytes", + "unavailable-bytes", +])("rejects %s mismatches", async (kind) => { const response = evidenceResponse(); - const value = response.value, page = value.selected_evidence!; - const item = page.items.find(item => item.span)!; + const value = response.value, + page = value.selected_evidence!; + const item = page.items.find((item) => item.span)!; if (kind === "project") value.project_id = "foreign"; if (kind === "thread") value.thread_id = "foreign"; if (kind === "request") value.request_revision_digest = "b".repeat(64); @@ -53,61 +84,123 @@ it.each(["project", "thread", "request", "operation", "result", "manifest", "cha }); it("keeps unreadable and unknown-basis references instead of counting them as absent", async () => { const response = evidenceResponse(); - response.value.selected_evidence!.items.forEach(item => { item.availability = "UNKNOWN_BASIS"; item.span = null; }); + response.value.selected_evidence!.items.forEach((item) => { + item.availability = "UNKNOWN_BASIS"; + item.span = null; + }); vi.mocked(rpc).mockResolvedValue(response); const page = await readResultEvidence(selection, partial.result, { offset: 0 }); - expect(page.selected_count).toBe(149); expect(page.items).toHaveLength(50); - expect(page.items.every(item => item.span === null)).toBe(true); + expect(page.selected_count).toBe(149); + expect(page.items).toHaveLength(50); + expect(page.items.every((item) => item.span === null)).toBe(true); }); it("rejects access denial without substituting another source read", async () => { vi.mocked(rpc).mockRejectedValue(new RpcError("denied", -32040, {})); - await expect(readResultEvidence(selection, partial.result, { offset: 0 })).rejects.toThrow("denied"); + await expect(readResultEvidence(selection, partial.result, { offset: 0 })).rejects.toThrow( + "denied", + ); expect(rpc).toHaveBeenCalledTimes(1); }); it("supports full results using the same exact manifest contract", async () => { - const result = { ...partial.result, selected_evidence_refs: manifest.source_refs, portfolio: { hypotheses: [] }, action_plan: { alternatives: [] } }; + const result = { + ...partial.result, + selected_evidence_refs: manifest.source_refs, + portfolio: { hypotheses: [] }, + action_plan: { alternatives: [] }, + }; vi.mocked(rpc).mockResolvedValue(evidenceResponse(0, true, result)); expect((await readResultEvidence(selection, result, { offset: 0 })).selected_count).toBe(149); }); it("represents a confirmed empty manifest separately from missing metadata", async () => { const response = evidenceResponse(); response.value.manifest.source_refs = []; - response.value.selected_evidence = { ...response.value.selected_evidence!, selected_count: 0, items: [], next_offset: null }; + response.value.selected_evidence = { + ...response.value.selected_evidence!, + selected_count: 0, + items: [], + next_offset: null, + }; vi.mocked(rpc).mockResolvedValue(response); - expect((await readResultEvidence(selection, partial.result, { offset: 0 })).selected_count).toBe(0); + expect((await readResultEvidence(selection, partial.result, { offset: 0 })).selected_count).toBe( + 0, + ); vi.mocked(rpc).mockResolvedValue({ ...response, value: { ...response.value, manifest: null } }); - await expect(readResultEvidence(selection, partial.result, { offset: 0 })).rejects.toThrow("저장 버전"); + await expect(readResultEvidence(selection, partial.result, { offset: 0 })).rejects.toThrow( + "저장 버전", + ); }); it("binds the historical manifest body without requiring operation-only envelope fields", async () => { - const { contract_version: _contract, request_epoch: _epoch, thread_id: _thread, ...body } = partial.result; - void _contract; void _epoch; void _thread; + const { + contract_version: _contract, + request_epoch: _epoch, + thread_id: _thread, + ...body + } = partial.result; + void _contract; + void _epoch; + void _thread; vi.mocked(rpc).mockResolvedValue(evidenceResponse(0, true, body)); expect((await readResultEvidence(selection, body, { offset: 0 })).selected_count).toBe(149); - expect((await readResultEvidence(selection, partial.result, { offset: 0 })).selected_count).toBe(149); - await expect(readResultEvidence(selection, { ...partial.result, thread_id: "foreign" }, { offset: 0 })).rejects.toThrow(); - await expect(readResultEvidence(selection, { ...partial.result, request_epoch: 999 }, { offset: 0 })).rejects.toThrow(); + expect((await readResultEvidence(selection, partial.result, { offset: 0 })).selected_count).toBe( + 149, + ); + await expect( + readResultEvidence(selection, { ...partial.result, thread_id: "foreign" }, { offset: 0 }), + ).rejects.toThrow(); + await expect( + readResultEvidence(selection, { ...partial.result, request_epoch: 999 }, { offset: 0 }), + ).rejects.toThrow(); }); it("reads the evidence when the screen shows a summary view that left some keys out", async () => { - const full = { ...partial.result, coverage: { rows: 3 }, requirements: ["r1"], source_packet: { spans: [1, 2] } }; - vi.mocked(rpc).mockImplementation(async (_method, args) => evidenceResponse(Number(args.selected_evidence_offset ?? 0), args.include_selected_evidence === true, full)); + const full = { + ...partial.result, + coverage: { rows: 3 }, + requirements: ["r1"], + source_packet: { spans: [1, 2] }, + }; + vi.mocked(rpc).mockImplementation(async (_method, args) => + evidenceResponse( + Number(args.selected_evidence_offset ?? 0), + args.include_selected_evidence === true, + full, + ), + ); // the displayed result is the stored one without the heavy keys, wrapper keys kept - expect((await readResultEvidence(selection, partial.result, { offset: 0 })).selected_count).toBe(149); + expect((await readResultEvidence(selection, partial.result, { offset: 0 })).selected_count).toBe( + 149, + ); const { contract_version: _contract, ...withoutWrapperKey } = partial.result; void _contract; - expect((await readResultEvidence(selection, withoutWrapperKey, { offset: 0 })).selected_count).toBe(149); + expect( + (await readResultEvidence(selection, withoutWrapperKey, { offset: 0 })).selected_count, + ).toBe(149); expect((await readResultEvidence(selection, full, { offset: 0 })).selected_count).toBe(149); }); it("still rejects a displayed value that differs, or a key the stored result does not have", async () => { const full = { ...partial.result, coverage: { rows: 3 } }; - vi.mocked(rpc).mockImplementation(async (_method, args) => evidenceResponse(Number(args.selected_evidence_offset ?? 0), args.include_selected_evidence === true, full)); - await expect(readResultEvidence(selection, { ...partial.result, answer: "another answer" }, { offset: 0 })).rejects.toThrow("연결을 확인하지 못했습니다"); - await expect(readResultEvidence(selection, { ...partial.result, coverage: { rows: 4 } }, { offset: 0 })).rejects.toThrow("연결을 확인하지 못했습니다"); - await expect(readResultEvidence(selection, { ...partial.result, invented_key: 1 }, { offset: 0 })).rejects.toThrow("연결을 확인하지 못했습니다"); + vi.mocked(rpc).mockImplementation(async (_method, args) => + evidenceResponse( + Number(args.selected_evidence_offset ?? 0), + args.include_selected_evidence === true, + full, + ), + ); + await expect( + readResultEvidence(selection, { ...partial.result, answer: "another answer" }, { offset: 0 }), + ).rejects.toThrow("연결을 확인하지 못했습니다"); + await expect( + readResultEvidence(selection, { ...partial.result, coverage: { rows: 4 } }, { offset: 0 }), + ).rejects.toThrow("연결을 확인하지 못했습니다"); + await expect( + readResultEvidence(selection, { ...partial.result, invented_key: 1 }, { offset: 0 }), + ).rejects.toThrow("연결을 확인하지 못했습니다"); }); it("still needs the server's own result and the manifest to be the same whole", async () => { const response = evidenceResponse(0, true, { ...partial.result, coverage: { rows: 3 } }); response.value.manifest.result = { ...partial.result }; vi.mocked(rpc).mockResolvedValue(response); - await expect(readResultEvidence(selection, partial.result, { offset: 0 })).rejects.toThrow("연결을 확인하지 못했습니다"); + await expect(readResultEvidence(selection, partial.result, { offset: 0 })).rejects.toThrow( + "연결을 확인하지 못했습니다", + ); }); diff --git a/apps/web/src/api/resultEvidence.ts b/apps/web/src/api/resultEvidence.ts index 71644eb0..6fc28e9a 100644 --- a/apps/web/src/api/resultEvidence.ts +++ b/apps/web/src/api/resultEvidence.ts @@ -6,56 +6,132 @@ import type { HistorySelection } from "./historyModels"; export type ResultSelection = Extract; const spanSchema = z.object({ - span_id: z.string(), project_id: z.string(), artifact_id: z.string(), source_version_id: z.string(), - exact_text: z.string(), text_sha256: digestSchema, authority_state: z.string(), cutoff_state: z.string(), - verification_state: z.string(), support_state: z.string(), - locator: z.object({ page: z.number().nullable().optional(), line: z.number().nullable().optional(), - paragraph: z.number().nullable().optional(), sheet: z.string().nullable().optional(), - cell_range: z.string().nullable().optional(), xml_path: z.string().nullable().optional(), uri: z.string().nullable().optional() }).passthrough(), + span_id: z.string(), + project_id: z.string(), + artifact_id: z.string(), + source_version_id: z.string(), + exact_text: z.string(), + text_sha256: digestSchema, + authority_state: z.string(), + cutoff_state: z.string(), + verification_state: z.string(), + support_state: z.string(), + locator: z + .object({ + page: z.number().nullable().optional(), + line: z.number().nullable().optional(), + paragraph: z.number().nullable().optional(), + sheet: z.string().nullable().optional(), + cell_range: z.string().nullable().optional(), + xml_path: z.string().nullable().optional(), + uri: z.string().nullable().optional(), + }) + .passthrough(), }); const pageSchema = z.object({ - schema_version: z.literal("1.0.0"), result_revision_digest: digestSchema, actor_scope_digest: digestSchema, - selected_count: z.number().int().nonnegative(), offset: z.number().int().nonnegative(), limit: z.number().int().min(1).max(100), + schema_version: z.literal("1.0.0"), + result_revision_digest: digestSchema, + actor_scope_digest: digestSchema, + selected_count: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + limit: z.number().int().min(1).max(100), next_offset: z.number().int().nonnegative().nullable(), - items: z.array(z.object({ span_id: z.string(), availability: z.enum(["AVAILABLE", "UNAVAILABLE", "BASIS_MISMATCH", "UNKNOWN_BASIS"]), - reason_codes: z.array(z.string()), span: spanSchema.nullable() })), + items: z.array( + z.object({ + span_id: z.string(), + availability: z.enum(["AVAILABLE", "UNAVAILABLE", "BASIS_MISMATCH", "UNKNOWN_BASIS"]), + reason_codes: z.array(z.string()), + span: spanSchema.nullable(), + }), + ), +}); +const responseSchema = historicalResultSchema.extend({ + selected_evidence: pageSchema.nullable().optional(), +}); +const manifestSchema = z.object({ + operation_id: z.string(), + request_ref: z.object({ + project_id: z.string(), + entity_type: z.string(), + entity_id: z.string(), + revision_digest: digestSchema, + }), + source_refs: z.array(z.string()), + source_basis: z.record(z.string()).optional(), + result: z.record(z.unknown()), }); -const responseSchema = historicalResultSchema.extend({ selected_evidence: pageSchema.nullable().optional() }); -const manifestSchema = z.object({ operation_id: z.string(), request_ref: z.object({ project_id: z.string(), entity_type: z.string(), entity_id: z.string(), revision_digest: digestSchema }), - source_refs: z.array(z.string()), source_basis: z.record(z.string()).optional(), result: z.record(z.unknown()) }); export type ResultEvidencePage = z.infer; export type EvidencePagePosition = { offset: number; resultDigest?: string }; // JSON object key ordering is not part of the stored answer's identity. export function resultContentKey(value: unknown): string { if (Array.isArray(value)) return `[${value.map(resultContentKey).join(",")}]`; - if (value !== null && typeof value === "object") return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${resultContentKey((value as Record)[key])}`).join(",")}}`; + if (value !== null && typeof value === "object") + return `{${Object.keys(value) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${resultContentKey((value as Record)[key])}`, + ) + .join(",")}}`; return JSON.stringify(value) ?? "null"; } /** The screen may show a summary view of the stored result: every key it shows must be in the stored result with the * same value, and a key it leaves out is taken as left out by the summary. A key the stored result does not have is not. */ function isShownPartOf(shown: Record, stored: Record): boolean { - return Object.entries(shown).every(([key, value]) => Object.hasOwn(stored, key) && resultContentKey(value) === resultContentKey(stored[key])); + return Object.entries(shown).every( + ([key, value]) => + Object.hasOwn(stored, key) && resultContentKey(value) === resultContentKey(stored[key]), + ); +} +function mismatch(): never { + throw new UiError("evidence.binding"); } -function mismatch(): never { throw new UiError("evidence.binding"); } function without(value: Record, keys: readonly string[]): Record { - return keys.length === 0 ? value : Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); + return keys.length === 0 + ? value + : Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); } -function answerContent(value: Record, thread: string, epoch: unknown, terminal: unknown) { +function answerContent( + value: Record, + thread: string, + epoch: unknown, + terminal: unknown, +) { // research_threads publication adds these four fields only to operation.result. // Validate that wrapper before comparing it with the immutable manifest.result. - if (("thread_id" in value && value.thread_id !== thread) || ("request_epoch" in value && value.request_epoch !== epoch) || - ("contract_version" in value && value.contract_version !== 2) || ("terminal_reason" in value && value.terminal_reason !== terminal)) mismatch(); - return Object.fromEntries(Object.entries(value).filter(([key]) => !["thread_id", "request_epoch", "contract_version", "terminal_reason"].includes(key))); + if ( + ("thread_id" in value && value.thread_id !== thread) || + ("request_epoch" in value && value.request_epoch !== epoch) || + ("contract_version" in value && value.contract_version !== 2) || + ("terminal_reason" in value && value.terminal_reason !== terminal) + ) + mismatch(); + return Object.fromEntries( + Object.entries(value).filter( + ([key]) => + !["thread_id", "request_epoch", "contract_version", "terminal_reason"].includes(key), + ), + ); } type Response = z.infer; type Manifest = z.infer; /** The answer on the screen, the result the server returned and the immutable manifest must be the same stored answer. */ -function bindAnswer(selection: ResultSelection, displayed: Record, value: Response, manifest: Manifest, result: Record) { +function bindAnswer( + selection: ResultSelection, + displayed: Record, + value: Response, + manifest: Manifest, + result: Record, +) { const { scope } = selection; - const [thread, epoch, terminal] = [scope.threadId, value.request.request_epoch, value.manifest?.terminal_reason]; + const [thread, epoch, terminal] = [ + scope.threadId, + value.request.request_epoch, + value.manifest?.terminal_reason, + ]; const displayedContent = answerContent(displayed, thread!, epoch, terminal); const resultBody = answerContent(result, thread!, epoch, terminal); const manifestBody = answerContent(manifest.result, thread!, epoch, terminal); @@ -64,58 +140,146 @@ function bindAnswer(selection: ResultSelection, displayed: Record Object.hasOwn(resultBody, key))) mismatch(); + const shownBody = value.shown_result + ? answerContent(value.shown_result, thread!, epoch, terminal) + : manifestBody; + if (value.shown_result && !Object.keys(shownBody).every((key) => Object.hasOwn(resultBody, key))) + mismatch(); // The latest round is shown from the summary view. A saved earlier round is shown from the stored result itself // (operation/result/read returns it whole). What the screen shows must be, all of it, a part of one of these forms // of the same stored answer; keys taken from two forms that disagree are refused. - const shownIsStored = [shownBody, resultBody, manifestBody].some(body => isShownPartOf(displayedContent, body)); - if (manifest.operation_id !== selection.operationId || manifest.request_ref.project_id !== scope.projectId || - manifest.request_ref.entity_type !== "THREAD" || manifest.request_ref.entity_id !== `request:${thread}` || manifest.request_ref.revision_digest !== scope.requestDigest || - resultContentKey(without(resultBody, projected)) !== resultContentKey(without(manifestBody, projected)) || !shownIsStored) mismatch(); + const shownIsStored = [shownBody, resultBody, manifestBody].some((body) => + isShownPartOf(displayedContent, body), + ); + if ( + manifest.operation_id !== selection.operationId || + manifest.request_ref.project_id !== scope.projectId || + manifest.request_ref.entity_type !== "THREAD" || + manifest.request_ref.entity_id !== `request:${thread}` || + manifest.request_ref.revision_digest !== scope.requestDigest || + resultContentKey(without(resultBody, projected)) !== + resultContentKey(without(manifestBody, projected)) || + !shownIsStored + ) + mismatch(); } -async function read(selection: ResultSelection, displayed: Record, digest: string | undefined, offset: number, expand: boolean, signal?: AbortSignal) { +async function read( + selection: ResultSelection, + displayed: Record, + digest: string | undefined, + offset: number, + expand: boolean, + signal?: AbortSignal, +) { const { scope } = selection; - if (!selection.operationId || !scope.projectId || !scope.threadId || !scope.requestDigest) mismatch(); - const response = await rpc("thread/result/read", { project_id: scope.projectId, thread_id: scope.threadId, - request_revision_digest: scope.requestDigest, ...(digest ? { result_revision_digest: digest } : {}), contract_version: 2, - ...(expand ? { include_selected_evidence: true, selected_evidence_offset: offset, selected_evidence_limit: 50 } : {}), - }, crypto.randomUUID(), signal); + if (!selection.operationId || !scope.projectId || !scope.threadId || !scope.requestDigest) + mismatch(); + const response = await rpc( + "thread/result/read", + { + project_id: scope.projectId, + thread_id: scope.threadId, + request_revision_digest: scope.requestDigest, + ...(digest ? { result_revision_digest: digest } : {}), + contract_version: 2, + ...(expand + ? { + include_selected_evidence: true, + selected_evidence_offset: offset, + selected_evidence_limit: 50, + } + : {}), + }, + crypto.randomUUID(), + signal, + ); if (response.state !== "SUCCEEDED") throw new UiError("evidence.queryIncomplete"); const value = responseSchema.parse(response.value); - if (value.project_id !== scope.projectId || value.thread_id !== scope.threadId || value.request_revision_digest !== scope.requestDigest || - value.operation_id !== selection.operationId || (digest && value.result_revision_digest !== digest)) mismatch(); + if ( + value.project_id !== scope.projectId || + value.thread_id !== scope.threadId || + value.request_revision_digest !== scope.requestDigest || + value.operation_id !== selection.operationId || + (digest && value.result_revision_digest !== digest) + ) + mismatch(); if (value.availability === "UNAVAILABLE") throw new UiError("evidence.accessDenied"); - if (!value.manifest || !value.result_revision_digest) throw new UiError("evidence.versionUnknown"); - if (value.request.project_id !== scope.projectId || value.request.thread_id !== scope.threadId || value.request.operation_id !== selection.operationId || !value.result) mismatch(); + if (!value.manifest || !value.result_revision_digest) + throw new UiError("evidence.versionUnknown"); + if ( + value.request.project_id !== scope.projectId || + value.request.thread_id !== scope.threadId || + value.request.operation_id !== selection.operationId || + !value.result + ) + mismatch(); const manifest = manifestSchema.parse(value.manifest); bindAnswer(selection, displayed, value, manifest, value.result); return { value, manifest }; } -export async function readResultEvidence(selection: ResultSelection, displayed: Record, position: EvidencePagePosition, signal?: AbortSignal): Promise { +export async function readResultEvidence( + selection: ResultSelection, + displayed: Record, + position: EvidencePagePosition, + signal?: AbortSignal, +): Promise { let digest = position.resultDigest ?? selection.resultDigest; if (selection.resultDigest && digest !== selection.resultDigest) mismatch(); - if (!digest) digest = (await read(selection, displayed, undefined, 0, false, signal)).value.result_revision_digest!; - const { value, manifest } = await read(selection, displayed, digest, position.offset, true, signal); + if (!digest) + digest = (await read(selection, displayed, undefined, 0, false, signal)).value + .result_revision_digest!; + const { value, manifest } = await read( + selection, + displayed, + digest, + position.offset, + true, + signal, + ); const page = value.selected_evidence; if (!page) throw new UiError("evidence.unsupported"); const expected = manifest.source_refs.slice(position.offset, position.offset + 50); - const next = position.offset + expected.length < manifest.source_refs.length ? position.offset + expected.length : null; - if (page.result_revision_digest !== digest || page.offset !== position.offset || page.limit !== 50 || - page.selected_count !== manifest.source_refs.length || page.next_offset !== next || page.items.length !== expected.length || - new Set(manifest.source_refs).size !== manifest.source_refs.length) mismatch(); - await Promise.all(page.items.map(async (item, index) => { - if (item.span_id !== expected[index]) mismatch(); - if (item.availability !== "AVAILABLE") { if (item.span !== null) mismatch(); return; } - const span = item.span; - if (!span || span.project_id !== selection.scope.projectId || span.span_id !== item.span_id || - manifest.source_basis?.[item.span_id] !== `${span.source_version_id}:${span.text_sha256}`) mismatch(); - const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(span.exact_text)))) - .map(byte => byte.toString(16).padStart(2, "0")).join(""); - if (hash !== span.text_sha256.toLowerCase()) mismatch(); - })); + const next = + position.offset + expected.length < manifest.source_refs.length + ? position.offset + expected.length + : null; + if ( + page.result_revision_digest !== digest || + page.offset !== position.offset || + page.limit !== 50 || + page.selected_count !== manifest.source_refs.length || + page.next_offset !== next || + page.items.length !== expected.length || + new Set(manifest.source_refs).size !== manifest.source_refs.length + ) + mismatch(); + await Promise.all( + page.items.map(async (item, index) => { + if (item.span_id !== expected[index]) mismatch(); + if (item.availability !== "AVAILABLE") { + if (item.span !== null) mismatch(); + return; + } + const span = item.span; + if ( + !span || + span.project_id !== selection.scope.projectId || + span.span_id !== item.span_id || + manifest.source_basis?.[item.span_id] !== `${span.source_version_id}:${span.text_sha256}` + ) + mismatch(); + const hash = Array.from( + new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(span.exact_text)), + ), + ) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + if (hash !== span.text_sha256.toLowerCase()) mismatch(); + }), + ); if (signal?.aborted) throw signal.reason; return page; } diff --git a/apps/web/src/api/resultEvidenceIdentity.test.ts b/apps/web/src/api/resultEvidenceIdentity.test.ts index 265ab6e8..c6fe238a 100644 --- a/apps/web/src/api/resultEvidenceIdentity.test.ts +++ b/apps/web/src/api/resultEvidenceIdentity.test.ts @@ -3,24 +3,64 @@ import { currentResultDigest } from "./resultEvidenceIdentity"; import fixture from "./fixtures/qa04-evidence-manifest.json"; import type { ResearchStatus } from "./research"; -const scope = { projectId: fixture.project_id, threadId: fixture.thread_id, requestDigest: fixture.request_revision_digest }; +const scope = { + projectId: fixture.project_id, + threadId: fixture.thread_id, + requestDigest: fixture.request_revision_digest, +}; function status(): ResearchStatus { - return { project_id: fixture.project_id, thread_id: fixture.thread_id, cycle_id: "fixture-cycle", problem: "fixture", lifecycle: "OPEN", execution_state: "IDLE", current_object_ids: [], working_head_digest: "fixture-head", - current_result: { operation_id: fixture.operation_id, request_ref: fixture.request_ref, result: {}, phase: "PARTIAL", completion: "TERMINAL", state: "CURRENT", terminal_reason: null, basis_digest: "fixture-basis", gaps: [], next_steps: [], source_refs: fixture.source_refs, record_refs: [] }, - attempt: { operation_id: fixture.operation_id, request_ref: fixture.request_ref, phase: "PARTIAL", status: "FAILED", - checkpoint_ref: { project_id: fixture.project_id, entity_type: "DECISION_OBJECT", entity_id: `result:${fixture.thread_id}`, revision_digest: fixture.result_revision_digest } }, + return { + project_id: fixture.project_id, + thread_id: fixture.thread_id, + cycle_id: "fixture-cycle", + problem: "fixture", + lifecycle: "OPEN", + execution_state: "IDLE", + current_object_ids: [], + working_head_digest: "fixture-head", + current_result: { + operation_id: fixture.operation_id, + request_ref: fixture.request_ref, + result: {}, + phase: "PARTIAL", + completion: "TERMINAL", + state: "CURRENT", + terminal_reason: null, + basis_digest: "fixture-basis", + gaps: [], + next_steps: [], + source_refs: fixture.source_refs, + record_refs: [], + }, + attempt: { + operation_id: fixture.operation_id, + request_ref: fixture.request_ref, + phase: "PARTIAL", + status: "FAILED", + checkpoint_ref: { + project_id: fixture.project_id, + entity_type: "DECISION_OBJECT", + entity_id: `result:${fixture.thread_id}`, + revision_digest: fixture.result_revision_digest, + }, + }, }; } it("pins the synthetic checkpoint's immutable result identity", () => { - expect(currentResultDigest(status(), scope, fixture.operation_id)).toBe(fixture.result_revision_digest); -}); -it.each(["project", "thread", "operation", "request", "checkpoint", "missing"])("never borrows an unrelated %s binding", kind => { - const value = status(); - if (kind === "project") value.project_id = "other"; - if (kind === "thread") value.thread_id = "other"; - if (kind === "operation") value.attempt!.operation_id = "other"; - if (kind === "request") value.attempt!.request_ref = { revision_digest: "f".repeat(64) }; - if (kind === "checkpoint") value.attempt!.checkpoint_ref!.entity_id = "result:other"; - if (kind === "missing") value.attempt!.checkpoint_ref = null; - expect(currentResultDigest(value, scope, fixture.operation_id)).toBeUndefined(); + expect(currentResultDigest(status(), scope, fixture.operation_id)).toBe( + fixture.result_revision_digest, + ); }); +it.each(["project", "thread", "operation", "request", "checkpoint", "missing"])( + "never borrows an unrelated %s binding", + (kind) => { + const value = status(); + if (kind === "project") value.project_id = "other"; + if (kind === "thread") value.thread_id = "other"; + if (kind === "operation") value.attempt!.operation_id = "other"; + if (kind === "request") value.attempt!.request_ref = { revision_digest: "f".repeat(64) }; + if (kind === "checkpoint") value.attempt!.checkpoint_ref!.entity_id = "result:other"; + if (kind === "missing") value.attempt!.checkpoint_ref = null; + expect(currentResultDigest(value, scope, fixture.operation_id)).toBeUndefined(); + }, +); diff --git a/apps/web/src/api/resultEvidenceIdentity.ts b/apps/web/src/api/resultEvidenceIdentity.ts index e0756740..250900f3 100644 --- a/apps/web/src/api/resultEvidenceIdentity.ts +++ b/apps/web/src/api/resultEvidenceIdentity.ts @@ -2,16 +2,30 @@ import type { ResearchStatus } from "./research"; import type { HistoryScope } from "./historyModels"; /** Pin a displayed checkpoint only when every identity comes from the same read. */ -export function currentResultDigest(status: ResearchStatus | undefined, scope: HistoryScope, operationId: string): string | undefined { +export function currentResultDigest( + status: ResearchStatus | undefined, + scope: HistoryScope, + operationId: string, +): string | undefined { const manifest = status?.current_result ?? status?.previous_result; const attempt = status?.attempt; const checkpoint = attempt?.checkpoint_ref; - if (!status || !manifest || !attempt || !checkpoint || - status.project_id !== scope.projectId || status.thread_id !== scope.threadId || - manifest.operation_id !== operationId || attempt.operation_id !== operationId || - manifest.request_ref?.revision_digest !== scope.requestDigest || - attempt.request_ref?.revision_digest !== scope.requestDigest || - checkpoint.project_id !== scope.projectId || checkpoint.entity_type !== "DECISION_OBJECT" || - checkpoint.entity_id !== `result:${scope.threadId}` || !/^[a-f0-9]{64}$/i.test(checkpoint.revision_digest)) return undefined; + if ( + !status || + !manifest || + !attempt || + !checkpoint || + status.project_id !== scope.projectId || + status.thread_id !== scope.threadId || + manifest.operation_id !== operationId || + attempt.operation_id !== operationId || + manifest.request_ref?.revision_digest !== scope.requestDigest || + attempt.request_ref?.revision_digest !== scope.requestDigest || + checkpoint.project_id !== scope.projectId || + checkpoint.entity_type !== "DECISION_OBJECT" || + checkpoint.entity_id !== `result:${scope.threadId}` || + !/^[a-f0-9]{64}$/i.test(checkpoint.revision_digest) + ) + return undefined; return checkpoint.revision_digest; } diff --git a/apps/web/src/api/resultEvidenceRuntime.test.ts b/apps/web/src/api/resultEvidenceRuntime.test.ts index efd43341..72f39bbd 100644 --- a/apps/web/src/api/resultEvidenceRuntime.test.ts +++ b/apps/web/src/api/resultEvidenceRuntime.test.ts @@ -6,70 +6,120 @@ import fixture from "./fixtures/result-evidence-runtime.json"; /* Responses a real runtime made for a controlled (fake) model run, read as the web client reads them * (expected_result_contract v5). Regenerate with: python -m tests.integration.result_evidence_runtime * tests/integration/test_result_shown_view.py checks the file against the server's own functions. */ -vi.mock("./rpcClient", async original => ({ ...await original(), rpc: vi.fn() })); +vi.mock("./rpcClient", async (original) => ({ + ...(await original()), + rpc: vi.fn(), +})); afterEach(() => vi.resetAllMocks()); type Wire = Record; // eslint-disable-line @typescript-eslint/no-explicit-any -type Case = { selection: ResultSelection["scope"] & { operationId: string; resultDigest: string }; displayed: Wire; read: Wire; evidence_page: Wire }; -const cases = fixture.cases as unknown as Record<"stored_current" | "stored_before_grounded_rows", Case>; -const selectionOf = (c: Case): ResultSelection => ({ kind: "result", scope: { projectId: c.selection.projectId, threadId: c.selection.threadId!, requestDigest: c.selection.requestDigest! }, - operationId: c.selection.operationId, resultDigest: c.selection.resultDigest }); +type Case = { + selection: ResultSelection["scope"] & { operationId: string; resultDigest: string }; + displayed: Wire; + read: Wire; + evidence_page: Wire; +}; +const cases = fixture.cases as unknown as Record< + "stored_current" | "stored_before_grounded_rows", + Case +>; +const selectionOf = (c: Case): ResultSelection => ({ + kind: "result", + scope: { + projectId: c.selection.projectId, + threadId: c.selection.threadId!, + requestDigest: c.selection.requestDigest!, + }, + operationId: c.selection.operationId, + resultDigest: c.selection.resultDigest, +}); /** Serves the stored responses; "edit" changes what the server says (a copy per call). */ function serve(c: Case, edit?: (value: Wire) => void) { vi.mocked(rpc).mockImplementation(async (_method, args) => { - const value: Wire = structuredClone(args.include_selected_evidence === true ? { ...c.read, selected_evidence: c.evidence_page } : c.read); + const value: Wire = structuredClone( + args.include_selected_evidence === true + ? { ...c.read, selected_evidence: c.evidence_page } + : c.read, + ); edit?.(value); return { operation_id: "", state: "SUCCEEDED", value }; }); } const BINDING = "연결을 확인하지 못했습니다"; -const show = (c: Case, displayed: Wire = c.displayed) => readResultEvidence(selectionOf(c), displayed, { offset: 0 }); +const show = (c: Case, displayed: Wire = c.displayed) => + readResultEvidence(selectionOf(c), displayed, { offset: 0 }); -describe.each(["stored_current", "stored_before_grounded_rows"] as const)("the answer a real runtime stored, %s", name => { - const c = cases[name]; - it("opens the evidence for the answer the screen shows", async () => { - serve(c); - const page = await show(c); - expect(page.selected_count).toBeGreaterThan(0); - expect(page.items.filter(item => item.availability === "AVAILABLE").length).toBe(page.items.length); - }); - it("still rejects a different answer, a changed summary, a key the answer does not have, or a shown value left out of the result", async () => { - serve(c); - await expect(show(c, { ...c.displayed, answer: "another answer" })).rejects.toThrow(BINDING); - await expect(show(c, { ...c.displayed, absence_assessments: { ...c.displayed.absence_assessments, not_checked_count: 99 } })).rejects.toThrow(BINDING); - await expect(show(c, { ...c.displayed, invented_key: 1 })).rejects.toThrow(BINDING); - serve(c, value => { value.shown_result = { ...value.shown_result, invented_key: 1 }; }); - await expect(show(c, { ...c.displayed, invented_key: 1 })).rejects.toThrow(BINDING); - }); - it("still needs the result and the manifest to agree on every key the server did not declare changed", async () => { - serve(c, value => { value.result = { ...value.result, answer: "another answer" }; value.shown_result = { ...value.shown_result, answer: "another answer" }; }); - await expect(show(c, { ...c.displayed, answer: "another answer" })).rejects.toThrow(BINDING); - }); - // An earlier round of a conversation is shown from operation/result/read, which returns the stored result whole, - // not the summary view the latest round is shown from. - it("opens the evidence for an earlier round, which the screen shows from the stored result itself", async () => { - serve(c); - const whole = c.read.result as Wire; - // it is not the summary view: at least one of the two summarized records is there in full - expect(["absence_assessments", "checked_observations"].some(key => JSON.stringify(whole[key]) !== JSON.stringify(c.displayed[key]))).toBe(true); - expect((await show(c, whole)).selected_count).toBeGreaterThan(0); - expect((await show(c, c.read.manifest.result as Wire)).selected_count).toBeGreaterThan(0); - }); - it("still rejects a whole result with another answer or an invented key, and one put together from two forms", async () => { - serve(c); - const whole = c.read.result as Wire; - await expect(show(c, { ...whole, answer: "another answer" })).rejects.toThrow(BINDING); - await expect(show(c, { ...whole, invented_key: 1 })).rejects.toThrow(BINDING); - // the summary of one record inside the whole result: each key is right in one form, the whole is right in none - const mixedKey = (["absence_assessments", "checked_observations"] as const).find(key => JSON.stringify(whole[key]) !== JSON.stringify(c.displayed[key])); - expect(mixedKey).toBeDefined(); - await expect(show(c, { ...whole, [mixedKey!]: c.displayed[mixedKey!] })).rejects.toThrow(BINDING); - }); -}); +describe.each(["stored_current", "stored_before_grounded_rows"] as const)( + "the answer a real runtime stored, %s", + (name) => { + const c = cases[name]; + it("opens the evidence for the answer the screen shows", async () => { + serve(c); + const page = await show(c); + expect(page.selected_count).toBeGreaterThan(0); + expect(page.items.filter((item) => item.availability === "AVAILABLE").length).toBe( + page.items.length, + ); + }); + it("still rejects a different answer, a changed summary, a key the answer does not have, or a shown value left out of the result", async () => { + serve(c); + await expect(show(c, { ...c.displayed, answer: "another answer" })).rejects.toThrow(BINDING); + await expect( + show(c, { + ...c.displayed, + absence_assessments: { ...c.displayed.absence_assessments, not_checked_count: 99 }, + }), + ).rejects.toThrow(BINDING); + await expect(show(c, { ...c.displayed, invented_key: 1 })).rejects.toThrow(BINDING); + serve(c, (value) => { + value.shown_result = { ...value.shown_result, invented_key: 1 }; + }); + await expect(show(c, { ...c.displayed, invented_key: 1 })).rejects.toThrow(BINDING); + }); + it("still needs the result and the manifest to agree on every key the server did not declare changed", async () => { + serve(c, (value) => { + value.result = { ...value.result, answer: "another answer" }; + value.shown_result = { ...value.shown_result, answer: "another answer" }; + }); + await expect(show(c, { ...c.displayed, answer: "another answer" })).rejects.toThrow(BINDING); + }); + // An earlier round of a conversation is shown from operation/result/read, which returns the stored result whole, + // not the summary view the latest round is shown from. + it("opens the evidence for an earlier round, which the screen shows from the stored result itself", async () => { + serve(c); + const whole = c.read.result as Wire; + // it is not the summary view: at least one of the two summarized records is there in full + expect( + ["absence_assessments", "checked_observations"].some( + (key) => JSON.stringify(whole[key]) !== JSON.stringify(c.displayed[key]), + ), + ).toBe(true); + expect((await show(c, whole)).selected_count).toBeGreaterThan(0); + expect((await show(c, c.read.manifest.result as Wire)).selected_count).toBeGreaterThan(0); + }); + it("still rejects a whole result with another answer or an invented key, and one put together from two forms", async () => { + serve(c); + const whole = c.read.result as Wire; + await expect(show(c, { ...whole, answer: "another answer" })).rejects.toThrow(BINDING); + await expect(show(c, { ...whole, invented_key: 1 })).rejects.toThrow(BINDING); + // the summary of one record inside the whole result: each key is right in one form, the whole is right in none + const mixedKey = (["absence_assessments", "checked_observations"] as const).find( + (key) => JSON.stringify(whole[key]) !== JSON.stringify(c.displayed[key]), + ); + expect(mixedKey).toBeDefined(); + await expect(show(c, { ...whole, [mixedKey!]: c.displayed[mixedKey!] })).rejects.toThrow( + BINDING, + ); + }); + }, +); describe("a server that does not send shown_result or projected_keys (older)", () => { - const old = (value: Wire) => { delete value.shown_result; delete value.projected_keys; }; + const old = (value: Wire) => { + delete value.shown_result; + delete value.projected_keys; + }; it("is read as before: the screen must hold keys of the stored result with the stored values", async () => { const c = cases.stored_current; serve(c, old); diff --git a/apps/web/src/api/resultEvidenceServerContract.test.ts b/apps/web/src/api/resultEvidenceServerContract.test.ts index 7a2be9f1..dfded717 100644 --- a/apps/web/src/api/resultEvidenceServerContract.test.ts +++ b/apps/web/src/api/resultEvidenceServerContract.test.ts @@ -7,17 +7,45 @@ import { evidenceResponse, manifest } from "./resultEvidenceTestFixture"; it("matches the actual selected-evidence Python DTOs without starting an engine", () => { const repository = fileURLToPath(new URL("../../../../", import.meta.url)); - const python = [join(repository, ".venv", "Scripts", "python.exe"), join(repository, ".venv", "bin", "python")].find(existsSync); - if (!python) throw new Error("The repository Python environment is required for wire validation."); - const cases = [0, 50, 100].map(offset => ({ input: { project_id: manifest.project_id, thread_id: manifest.thread_id, - request_revision_digest: manifest.request_revision_digest, result_revision_digest: manifest.result_revision_digest, - include_selected_evidence: true, selected_evidence_offset: offset, selected_evidence_limit: 50, contract_version: 2 }, - value: evidenceResponse(offset).value })); - const source = ["import json,sys", "from thoth.domain.research_history import HistoricalResultInput, HistoricalResultView", - "items=json.load(sys.stdin)", "counts=[]", "for item in items:", - " HistoricalResultInput.model_validate(item['input'])", " value=HistoricalResultView.model_validate(item['value'])", - " counts.append(len(value.selected_evidence.items))", "print(json.dumps(counts))"].join("\n"); - const validated = JSON.parse(execFileSync(python, ["-c", source], { shell: false, encoding: "utf8", input: JSON.stringify(cases), - cwd: repository, timeout: 20000, env: { ...process.env, PYTHONPATH: join(repository, "src"), PYTHONDONTWRITEBYTECODE: "1" } })); + const python = [ + join(repository, ".venv", "Scripts", "python.exe"), + join(repository, ".venv", "bin", "python"), + ].find(existsSync); + if (!python) + throw new Error("The repository Python environment is required for wire validation."); + const cases = [0, 50, 100].map((offset) => ({ + input: { + project_id: manifest.project_id, + thread_id: manifest.thread_id, + request_revision_digest: manifest.request_revision_digest, + result_revision_digest: manifest.result_revision_digest, + include_selected_evidence: true, + selected_evidence_offset: offset, + selected_evidence_limit: 50, + contract_version: 2, + }, + value: evidenceResponse(offset).value, + })); + const source = [ + "import json,sys", + "from thoth.domain.research_history import HistoricalResultInput, HistoricalResultView", + "items=json.load(sys.stdin)", + "counts=[]", + "for item in items:", + " HistoricalResultInput.model_validate(item['input'])", + " value=HistoricalResultView.model_validate(item['value'])", + " counts.append(len(value.selected_evidence.items))", + "print(json.dumps(counts))", + ].join("\n"); + const validated = JSON.parse( + execFileSync(python, ["-c", source], { + shell: false, + encoding: "utf8", + input: JSON.stringify(cases), + cwd: repository, + timeout: 20000, + env: { ...process.env, PYTHONPATH: join(repository, "src"), PYTHONDONTWRITEBYTECODE: "1" }, + }), + ); expect(validated).toEqual([50, 50, 49]); }); diff --git a/apps/web/src/api/resultEvidenceTestFixture.ts b/apps/web/src/api/resultEvidenceTestFixture.ts index d40c9c36..88a0ab84 100644 --- a/apps/web/src/api/resultEvidenceTestFixture.ts +++ b/apps/web/src/api/resultEvidenceTestFixture.ts @@ -4,24 +4,76 @@ import citations from "./fixtures/qa04-cited-spans.json"; import type { ResultSelection } from "./resultEvidence"; export { manifest, partial, citations }; -export const selection: ResultSelection = { kind: "result", scope: partial.scope, operationId: partial.operationId, resultDigest: manifest.result_revision_digest }; +export const selection: ResultSelection = { + kind: "result", + scope: partial.scope, + operationId: partial.operationId, + resultDigest: manifest.result_revision_digest, +}; // Fictional greenhouse text, identities, hashes and locators are synthetic; status badges below are test-only. -export function evidenceResponse(offset = 0, expand = true, result: Record = partial.result) { - return { operation_id: "", state: "SUCCEEDED", value: { - contract_version: 2, project_id: manifest.project_id, thread_id: manifest.thread_id, - request_revision_digest: manifest.request_revision_digest, operation_id: manifest.operation_id, - result_revision_digest: manifest.result_revision_digest, request: { project_id: manifest.project_id, thread_id: manifest.thread_id, - operation_id: manifest.operation_id, request_epoch: partial.result.request_epoch }, manifest: { ...manifest, result }, result, - error: null, availability: "AVAILABLE", basis_currentness: { state: "CURRENT", reasons: [], execution_eligible: false }, - coverage: { visibility: "AUTHORIZED_SUBSET", association: "EXACT", scan: "COMPLETE_PAGE", reasons: [] }, - selected_evidence: expand ? { schema_version: "1.0.0", result_revision_digest: manifest.result_revision_digest, - actor_scope_digest: "a".repeat(64), selected_count: manifest.source_refs.length, offset, limit: 50, - next_offset: offset + 50 < manifest.source_refs.length ? offset + 50 : null, - items: manifest.source_refs.slice(offset, offset + 50).map(span_id => { - const span = citations.find(item => item.span_id === span_id); - return { span_id, availability: span ? "AVAILABLE" : "UNAVAILABLE", reason_codes: span ? [] : ["TEST_FIXTURE_NOT_INCLUDED"], - span: span ? { ...span, project_id: manifest.project_id, authority_state: "UNCLASSIFIED", cutoff_state: "ELIGIBLE", verification_state: "NOT_CHECKED", support_state: "EXTRACTED", extraction_method: "test-fixture", schema_version: "1.0.0" } : null }; - }), - } : null, - } }; +export function evidenceResponse( + offset = 0, + expand = true, + result: Record = partial.result, +) { + return { + operation_id: "", + state: "SUCCEEDED", + value: { + contract_version: 2, + project_id: manifest.project_id, + thread_id: manifest.thread_id, + request_revision_digest: manifest.request_revision_digest, + operation_id: manifest.operation_id, + result_revision_digest: manifest.result_revision_digest, + request: { + project_id: manifest.project_id, + thread_id: manifest.thread_id, + operation_id: manifest.operation_id, + request_epoch: partial.result.request_epoch, + }, + manifest: { ...manifest, result }, + result, + error: null, + availability: "AVAILABLE", + basis_currentness: { state: "CURRENT", reasons: [], execution_eligible: false }, + coverage: { + visibility: "AUTHORIZED_SUBSET", + association: "EXACT", + scan: "COMPLETE_PAGE", + reasons: [], + }, + selected_evidence: expand + ? { + schema_version: "1.0.0", + result_revision_digest: manifest.result_revision_digest, + actor_scope_digest: "a".repeat(64), + selected_count: manifest.source_refs.length, + offset, + limit: 50, + next_offset: offset + 50 < manifest.source_refs.length ? offset + 50 : null, + items: manifest.source_refs.slice(offset, offset + 50).map((span_id) => { + const span = citations.find((item) => item.span_id === span_id); + return { + span_id, + availability: span ? "AVAILABLE" : "UNAVAILABLE", + reason_codes: span ? [] : ["TEST_FIXTURE_NOT_INCLUDED"], + span: span + ? { + ...span, + project_id: manifest.project_id, + authority_state: "UNCLASSIFIED", + cutoff_state: "ELIGIBLE", + verification_state: "NOT_CHECKED", + support_state: "EXTRACTED", + extraction_method: "test-fixture", + schema_version: "1.0.0", + } + : null, + }; + }), + } + : null, + }, + }; } diff --git a/apps/web/src/api/rpcClient.test.ts b/apps/web/src/api/rpcClient.test.ts index 5feb80e4..8f371dbc 100644 --- a/apps/web/src/api/rpcClient.test.ts +++ b/apps/web/src/api/rpcClient.test.ts @@ -8,45 +8,72 @@ afterEach(() => { }); const reason = "REFERENCE_INQUIRY_NOT_FOUND"; -const notice = `연구의 중간 기록은 저장됐습니다. 기준(reference) 단계만 실패했습니다 (사유: ${reason}). ` - + "결과는 기록에서 볼 수 있습니다. 같은 질문을 새로 보내면 연구가 다시 실행될 수 있으니 먼저 저장된 결과를 확인하세요. / " - + `Intermediate research records were saved. Only the reference step failed (reason: ${reason}). ` - + "You can read the result in the history. Sending the same question again as a new request may run the research again, so check the saved result first."; +const notice = + `연구의 중간 기록은 저장됐습니다. 기준(reference) 단계만 실패했습니다 (사유: ${reason}). ` + + "결과는 기록에서 볼 수 있습니다. 같은 질문을 새로 보내면 연구가 다시 실행될 수 있으니 먼저 저장된 결과를 확인하세요. / " + + `Intermediate research records were saved. Only the reference step failed (reason: ${reason}). ` + + "You can read the result in the history. Sending the same question again as a new request may run the research again, so check the saved result first."; it("preserves the bilingual reference notice and saved-record metadata from an RPC error", async () => { const data = { reason_code: reason, research_saved: true }; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ - jsonrpc: "2.0", id: "notice", error: { code: -32030, message: notice, data }, - }))); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + jsonrpc: "2.0", + id: "notice", + error: { code: -32030, message: notice, data }, + }), + ), + ); await expect(rpc("thread/input", { project_id: "p" }, "reference-notice")).rejects.toMatchObject({ - name: "RpcError", code: -32030, message: `${notice} [-32030]`, details: data, + name: "RpcError", + code: -32030, + message: `${notice} [-32030]`, + details: data, }); }); it("says an unexpected server failure in the screen language, with the operation id the server log is found by", async () => { setLocale("ko"); - vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => Response.json({ - jsonrpc: "2.0", - id: "internal", - error: { code: -32603, message: "internal command failure", data: { operation_id: "operation:42" } }, - }))); + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async () => + Response.json({ + jsonrpc: "2.0", + id: "internal", + error: { + code: -32603, + message: "internal command failure", + data: { operation_id: "operation:42" }, + }, + }), + ), + ); await expect(rpc("thread/input", { project_id: "p" }, "internal-failure")).rejects.toMatchObject({ name: "RpcError", code: -32603, message: "서버가 이 요청을 처리하지 못했습니다. [-32603] (operation:42)", }); setLocale("en"); - await expect(rpc("thread/input", { project_id: "p" }, "internal-failure-en")).rejects.toMatchObject({ + await expect( + rpc("thread/input", { project_id: "p" }, "internal-failure-en"), + ).rejects.toMatchObject({ message: "The server could not complete this request. [-32603] (operation:42)", }); }); it("keeps a specific message the server wrote for an internal error", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ - jsonrpc: "2.0", - id: "specific", - error: { code: -32603, message: "index is being rebuilt" }, - }))); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + jsonrpc: "2.0", + id: "specific", + error: { code: -32603, message: "index is being rebuilt" }, + }), + ), + ); await expect(rpc("thread/input", { project_id: "p" }, "specific")).rejects.toMatchObject({ message: "index is being rebuilt [-32603]", }); @@ -54,17 +81,22 @@ it("keeps a specific message the server wrote for an internal error", async () = it("adds no operation id to other errors, or when the server gave none", async () => { setLocale("ko"); - const fetchMock = vi.fn() - .mockResolvedValueOnce(Response.json({ - jsonrpc: "2.0", - id: "refused", - error: { code: -32030, message: "refused", data: { operation_id: "operation:43" } }, - })) - .mockResolvedValueOnce(Response.json({ - jsonrpc: "2.0", - id: "bare", - error: { code: -32603, message: "internal command failure" }, - })); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + jsonrpc: "2.0", + id: "refused", + error: { code: -32030, message: "refused", data: { operation_id: "operation:43" } }, + }), + ) + .mockResolvedValueOnce( + Response.json({ + jsonrpc: "2.0", + id: "bare", + error: { code: -32603, message: "internal command failure" }, + }), + ); vi.stubGlobal("fetch", fetchMock); await expect(rpc("thread/input", { project_id: "p" }, "refused")).rejects.toMatchObject({ message: "refused [-32030]", diff --git a/apps/web/src/api/rpcClient.ts b/apps/web/src/api/rpcClient.ts index 5fed93a7..87bd42b6 100644 --- a/apps/web/src/api/rpcClient.ts +++ b/apps/web/src/api/rpcClient.ts @@ -7,22 +7,54 @@ export type RpcResult = { state: string; value: T; }; -export const readQueries = new Set(["thread/read", "thread/list", "thread/activity/list", "model/settings/read", - "model/localAsset/read", "project/retrieval/experiment/read", - "model/credential/list", "model/credential/login/status", "workspace/setup/read", "workspace/ready", - "project/read", "project/list", "project/source/list", "evidence/list", "operation/read", - "operation/result/read", "operation/checkpoint/read", - "revision/timeline/read", "revision/timeline/item/read", "thread/result/read", - "thread/result/compare/read", "project/review/list", - "revision/diff/read", "revision/restore/preview", "revision/read", "revision/content/read", - "revision/head/read", "revision/history/read", "trace/read", "trace/export", "trace/importPreview", "trace/history", "hypothesis/link/list", "hypothesis/test/result/list", "hypothesis/same/list", "trace/closure/list", "trace/lesson/list"]); +export const readQueries = new Set([ + "thread/read", + "thread/list", + "thread/activity/list", + "model/settings/read", + "model/localAsset/read", + "project/retrieval/experiment/read", + "model/credential/list", + "model/credential/login/status", + "workspace/setup/read", + "workspace/ready", + "project/read", + "project/list", + "project/source/list", + "evidence/list", + "operation/read", + "operation/result/read", + "operation/checkpoint/read", + "revision/timeline/read", + "revision/timeline/item/read", + "thread/result/read", + "thread/result/compare/read", + "project/review/list", + "revision/diff/read", + "revision/restore/preview", + "revision/read", + "revision/content/read", + "revision/head/read", + "revision/history/read", + "trace/read", + "trace/export", + "trace/importPreview", + "trace/history", + "hypothesis/link/list", + "hypothesis/test/result/list", + "hypothesis/same/list", + "trace/closure/list", + "trace/lesson/list", +]); const INTERNAL_ERROR = -32603; /** What the server answers for any unexpected failure. The screen says it in the person's language. */ const INTERNAL_FAILURE_TEXT = "internal command failure"; function shownMessage(message: string, code: number): string { - return code === INTERNAL_ERROR && message === INTERNAL_FAILURE_TEXT ? t("rpc.internalFailure") : message; + return code === INTERNAL_ERROR && message === INTERNAL_FAILURE_TEXT + ? t("rpc.internalFailure") + : message; } /** The server logs an unexpected failure under its operation id; showing the id lets a report name it. */ @@ -33,23 +65,40 @@ function internalFailureRef(code: number, details: Record | und } export class RpcError extends Error { - constructor(message: string, readonly code: number, readonly details: Record) { + constructor( + message: string, + readonly code: number, + readonly details: Record, + ) { super(`${shownMessage(message, code)} [${code}]${internalFailureRef(code, details)}`); this.name = "RpcError"; } } export class RpcTransportError extends Error { - constructor(message: string, readonly kind: "NETWORK" | "TIMEOUT" | "HTTP", readonly status: number | null = null) { + constructor( + message: string, + readonly kind: "NETWORK" | "TIMEOUT" | "HTTP", + readonly status: number | null = null, + ) { super(message); this.name = "RpcTransportError"; } - get retryable() { return this.kind !== "HTTP" || this.status === 429 || (this.status !== null && this.status >= 500); } + get retryable() { + return ( + this.kind !== "HTTP" || this.status === 429 || (this.status !== null && this.status >= 500) + ); + } } -async function transportRead(read: () => Promise, caller: AbortSignal | undefined, bounded: AbortSignal): Promise { - try { return await read(); } - catch (error) { +async function transportRead( + read: () => Promise, + caller: AbortSignal | undefined, + bounded: AbortSignal, +): Promise { + try { + return await read(); + } catch (error) { if (caller?.aborted) throw error; if (bounded.aborted || (error instanceof DOMException && error.name === "TimeoutError")) { throw new RpcTransportError("RPC timed out", "TIMEOUT"); @@ -61,7 +110,10 @@ async function transportRead(read: () => Promise, caller: AbortSignal | un function timeoutSignal(signal: AbortSignal | undefined, timeoutMs: number) { const controller = new AbortController(); - const timeout = globalThis.setTimeout(() => controller.abort(new DOMException("RPC timed out", "TimeoutError")), timeoutMs); + const timeout = globalThis.setTimeout( + () => controller.abort(new DOMException("RPC timed out", "TimeoutError")), + timeoutMs, + ); const abort = () => controller.abort(signal?.reason); const dispose = () => { globalThis.clearTimeout(timeout); @@ -85,25 +137,38 @@ export async function rpc( ): Promise> { const bounded = timeoutSignal(signal, options.timeoutMs ?? 15000); if (method === "workspace/ready") resultContract = undefined; - const readInput = resultContract && typedResultMethods.has(method) && !("expected_result_contract" in input) - ? { ...input, expected_result_contract: resultContract } : input; + const readInput = + resultContract && typedResultMethods.has(method) && !("expected_result_contract" in input) + ? { ...input, expected_result_contract: resultContract } + : input; try { - const response = await transportRead(() => fetch(readQueries.has(method) ? "/rpc/query" : "/rpc", { - method: "POST", - signal: bounded.signal, - credentials: "same-origin", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: crypto.randomUUID(), - method, - params: { _meta: { idempotencyKey }, input: readInput }, - }), - }), signal, bounded.signal); + const response = await transportRead( + () => + fetch(readQueries.has(method) ? "/rpc/query" : "/rpc", { + method: "POST", + signal: bounded.signal, + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: crypto.randomUUID(), + method, + params: { _meta: { idempotencyKey }, input: readInput }, + }), + }), + signal, + bounded.signal, + ); if (!response.ok) { - throw new RpcTransportError(`RPC transport failed (${response.status})`, "HTTP", response.status); + throw new RpcTransportError( + `RPC transport failed (${response.status})`, + "HTTP", + response.status, + ); } - const envelope = jsonRpcResponseSchema.parse(await transportRead(() => response.json(), signal, bounded.signal)); + const envelope = jsonRpcResponseSchema.parse( + await transportRead(() => response.json(), signal, bounded.signal), + ); if (envelope.error) { throw new RpcError(envelope.error.message, envelope.error.code, envelope.error.data); } diff --git a/apps/web/src/api/rpcTransport.test.ts b/apps/web/src/api/rpcTransport.test.ts index 3bdcba60..5ee9c494 100644 --- a/apps/web/src/api/rpcTransport.test.ts +++ b/apps/web/src/api/rpcTransport.test.ts @@ -12,9 +12,19 @@ it.each([ await expect(read()).rejects.toMatchObject({ name: "RpcTransportError", kind, retryable: true }); }); -it.each([[429, true], [504, true], [403, false], [401, false]] as const)("classifies HTTP %i without parsing its message", async (status, retryable) => { +it.each([ + [429, true], + [504, true], + [403, false], + [401, false], +] as const)("classifies HTTP %i without parsing its message", async (status, retryable) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status }))); - await expect(read()).rejects.toMatchObject({ name: "RpcTransportError", kind: "HTTP", status, retryable }); + await expect(read()).rejects.toMatchObject({ + name: "RpcTransportError", + kind: "HTTP", + status, + retryable, + }); }); it("does not turn response corruption into a retryable transport failure", async () => { @@ -23,9 +33,20 @@ it("does not turn response corruption into a retryable transport failure", async }); it("retains an application access denial as RpcError", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ jsonrpc: "2.0", id: "read", error: { - code: -32040, message: "denied", data: { reason_code: "RESOURCE_SCOPE_ACCESS_DENIED" }, - }}))); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + jsonrpc: "2.0", + id: "read", + error: { + code: -32040, + message: "denied", + data: { reason_code: "RESOURCE_SCOPE_ACCESS_DENIED" }, + }, + }), + ), + ); await expect(read()).rejects.toBeInstanceOf(RpcError); }); @@ -37,9 +58,18 @@ it("does not reclassify the caller's cancellation as a network failure", async ( await expect(read(controller.signal)).rejects.toBe(cancellation); }); -it.each(["thread/result/compare/read", "project/review/list"] as const)("uses the read endpoint for %s", async (method) => { - const fetch = vi.fn().mockResolvedValue(Response.json({ jsonrpc: "2.0", id: "read", result: { operation_id: "op", state: "SUCCEEDED", value: {} } })); - vi.stubGlobal("fetch", fetch); - await rpc(method, { project_id: "project:p" }, "followup-read-route"); - expect(fetch).toHaveBeenCalledWith("/rpc/query", expect.objectContaining({ method: "POST" })); -}); +it.each(["thread/result/compare/read", "project/review/list"] as const)( + "uses the read endpoint for %s", + async (method) => { + const fetch = vi.fn().mockResolvedValue( + Response.json({ + jsonrpc: "2.0", + id: "read", + result: { operation_id: "op", state: "SUCCEEDED", value: {} }, + }), + ); + vi.stubGlobal("fetch", fetch); + await rpc(method, { project_id: "project:p" }, "followup-read-route"); + expect(fetch).toHaveBeenCalledWith("/rpc/query", expect.objectContaining({ method: "POST" })); + }, +); diff --git a/apps/web/src/api/sourceBindings.test.ts b/apps/web/src/api/sourceBindings.test.ts index a451243c..49b43918 100644 --- a/apps/web/src/api/sourceBindings.test.ts +++ b/apps/web/src/api/sourceBindings.test.ts @@ -1,15 +1,28 @@ import { expect, it } from "vitest"; import type { ConnectedArtifact } from "../types"; -import { activeBinding, connectedArtifacts, detachedArtifactIds, detachedArtifacts } from "./sourceBindings"; +import { + activeBinding, + connectedArtifacts, + detachedArtifactIds, + detachedArtifacts, +} from "./sourceBindings"; import { threadName } from "./threadName"; -const artifact = (id: string) => ({ artifact_id: id, source_uri: "file:///" + id + ".pdf" }) as ConnectedArtifact; -const binding = (id: string, state: string) => ({ binding_id: "binding:" + id, artifact_id: id, state }); +const artifact = (id: string) => + ({ artifact_id: id, source_uri: "file:///" + id + ".pdf" }) as ConnectedArtifact; +const binding = (id: string, state: string) => ({ + binding_id: "binding:" + id, + artifact_id: id, + state, +}); it("counts a source as connected only while its binding is active", () => { - const sources = { artifacts: [artifact("a"), artifact("b"), artifact("c")], bindings: [binding("a", "ACTIVE"), binding("b", "DETACHED"), binding("c", "REVOKED")] }; - expect(connectedArtifacts(sources).map(item => item.artifact_id)).toEqual(["a"]); - expect(detachedArtifacts(sources).map(item => item.artifact_id)).toEqual(["b", "c"]); + const sources = { + artifacts: [artifact("a"), artifact("b"), artifact("c")], + bindings: [binding("a", "ACTIVE"), binding("b", "DETACHED"), binding("c", "REVOKED")], + }; + expect(connectedArtifacts(sources).map((item) => item.artifact_id)).toEqual(["a"]); + expect(detachedArtifacts(sources).map((item) => item.artifact_id)).toEqual(["b", "c"]); expect([...detachedArtifactIds(sources)]).toEqual(["b", "c"]); expect(activeBinding(sources, "a")?.binding_id).toBe("binding:a"); expect(activeBinding(sources, "b")).toBeUndefined(); @@ -17,7 +30,9 @@ it("counts a source as connected only while its binding is active", () => { it("keeps a source connected when the server listed no binding for it", () => { // A workspace read before bindings were listed, or an answer from an older server. - expect(connectedArtifacts({ artifacts: [artifact("a")] }).map(item => item.artifact_id)).toEqual(["a"]); + expect( + connectedArtifacts({ artifacts: [artifact("a")] }).map((item) => item.artifact_id), + ).toEqual(["a"]); expect(connectedArtifacts({ artifacts: [artifact("a")], bindings: [] })).toHaveLength(1); expect(detachedArtifacts({ artifacts: [artifact("a")] })).toEqual([]); expect(activeBinding({ artifacts: [artifact("a")] }, "a")).toBeUndefined(); @@ -33,9 +48,17 @@ it("names a conversation by the name a person gave it, else by its first questio it("does not list a file as disconnected once the same file is connected again", () => { // Connecting a file again makes a new source row; the old row keeps its disconnected binding. - const file = (id: string) => ({ ...artifact(id), source_uri: "file:///weather.txt", byte_sha256: "ab".repeat(32) }) as ConnectedArtifact; - const sources = { artifacts: [file("old"), file("new"), artifact("other")], bindings: [binding("old", "DETACHED"), binding("new", "ACTIVE"), binding("other", "DETACHED")] }; - expect(connectedArtifacts(sources).map(item => item.artifact_id)).toEqual(["new"]); - expect(detachedArtifacts(sources).map(item => item.artifact_id)).toEqual(["other"]); + const file = (id: string) => + ({ + ...artifact(id), + source_uri: "file:///weather.txt", + byte_sha256: "ab".repeat(32), + }) as ConnectedArtifact; + const sources = { + artifacts: [file("old"), file("new"), artifact("other")], + bindings: [binding("old", "DETACHED"), binding("new", "ACTIVE"), binding("other", "DETACHED")], + }; + expect(connectedArtifacts(sources).map((item) => item.artifact_id)).toEqual(["new"]); + expect(detachedArtifacts(sources).map((item) => item.artifact_id)).toEqual(["other"]); expect([...detachedArtifactIds(sources)]).toEqual(["other"]); }); diff --git a/apps/web/src/api/sourceBindings.ts b/apps/web/src/api/sourceBindings.ts index a12e885b..b3cc82d7 100644 --- a/apps/web/src/api/sourceBindings.ts +++ b/apps/web/src/api/sourceBindings.ts @@ -6,7 +6,7 @@ export type SourceBinding = { binding_id: string; artifact_id: string; state: st type SourcesRead = { artifacts: ConnectedArtifact[]; bindings?: SourceBinding[] } | undefined; function bindingOf(sources: SourcesRead, artifactId: string): SourceBinding | undefined { - return sources?.bindings?.find(binding => binding.artifact_id === artifactId); + return sources?.bindings?.find((binding) => binding.artifact_id === artifactId); } /** @@ -20,12 +20,17 @@ function isConnected(sources: SourcesRead, artifactId: string): boolean { /** The sources a new question can use. */ export function connectedArtifacts(sources: SourcesRead): ConnectedArtifact[] { - return (sources?.artifacts ?? []).filter(artifact => isConnected(sources, artifact.artifact_id)); + return (sources?.artifacts ?? []).filter((artifact) => + isConnected(sources, artifact.artifact_id), + ); } /** The same file under another id: connecting a file again makes a new source row with the same bytes. */ function connectedAgain(sources: SourcesRead, artifact: ConnectedArtifact): boolean { - return connectedArtifacts(sources).some(other => other.byte_sha256 === artifact.byte_sha256 && other.source_uri === artifact.source_uri); + return connectedArtifacts(sources).some( + (other) => + other.byte_sha256 === artifact.byte_sha256 && other.source_uri === artifact.source_uri, + ); } /** @@ -33,11 +38,13 @@ function connectedAgain(sources: SourcesRead, artifact: ConnectedArtifact): bool * A file that was connected again afterwards is not listed: it is in use. */ export function detachedArtifacts(sources: SourcesRead): ConnectedArtifact[] { - return (sources?.artifacts ?? []).filter(artifact => !isConnected(sources, artifact.artifact_id) && !connectedAgain(sources, artifact)); + return (sources?.artifacts ?? []).filter( + (artifact) => !isConnected(sources, artifact.artifact_id) && !connectedAgain(sources, artifact), + ); } export function detachedArtifactIds(sources: SourcesRead): ReadonlySet { - return new Set(detachedArtifacts(sources).map(artifact => artifact.artifact_id)); + return new Set(detachedArtifacts(sources).map((artifact) => artifact.artifact_id)); } /** The binding to name when disconnecting a source; undefined when the source has none or is already off. */ diff --git a/apps/web/src/api/sourcePolicy.ts b/apps/web/src/api/sourcePolicy.ts index 644c7f55..a5aa63ec 100644 --- a/apps/web/src/api/sourcePolicy.ts +++ b/apps/web/src/api/sourcePolicy.ts @@ -2,20 +2,94 @@ import { t } from "../i18n/locale"; import { z } from "zod"; // Mirrors domain/enums.py and domain/resource_scope.py; source parity is tested. -export const authorityStates = ["UNCLASSIFIED","INFORMAL","OFFICIAL","APPROVED","SUPERSEDED","NOT_ADMISSIBLE"] as const; -export const cutoffStates = ["ELIGIBLE","AFTER_CUTOFF","UNKNOWN_TIME","PROHIBITED_CONTEXT"] as const; +export const authorityStates = [ + "UNCLASSIFIED", + "INFORMAL", + "OFFICIAL", + "APPROVED", + "SUPERSEDED", + "NOT_ADMISSIBLE", +] as const; +export const cutoffStates = [ + "ELIGIBLE", + "AFTER_CUTOFF", + "UNKNOWN_TIME", + "PROHIBITED_CONTEXT", +] as const; export const authorityOptions = [ - {value:"UNCLASSIFIED",get label() { return t("sources.unclassified"); }},{value:"INFORMAL",get label() { return t("sources.informal"); }}, - {value:"OFFICIAL",get label() { return t("sources.official"); }},{value:"APPROVED",get label() { return t("sources.approved"); }}, - {value:"SUPERSEDED",get label() { return t("sources.superseded"); }},{value:"NOT_ADMISSIBLE",get label() { return t("sources.inadmissible"); }}, + { + value: "UNCLASSIFIED", + get label() { + return t("sources.unclassified"); + }, + }, + { + value: "INFORMAL", + get label() { + return t("sources.informal"); + }, + }, + { + value: "OFFICIAL", + get label() { + return t("sources.official"); + }, + }, + { + value: "APPROVED", + get label() { + return t("sources.approved"); + }, + }, + { + value: "SUPERSEDED", + get label() { + return t("sources.superseded"); + }, + }, + { + value: "NOT_ADMISSIBLE", + get label() { + return t("sources.inadmissible"); + }, + }, ]; export const cutoffOptions = [ - {value:"ELIGIBLE",get label() { return t("sources.beforeCutoff"); }},{value:"UNKNOWN_TIME",get label() { return t("sources.timeUnknown"); }}, - {value:"AFTER_CUTOFF",get label() { return t("sources.afterCutoff"); }},{value:"PROHIBITED_CONTEXT",get label() { return t("sources.prohibited"); }}, + { + value: "ELIGIBLE", + get label() { + return t("sources.beforeCutoff"); + }, + }, + { + value: "UNKNOWN_TIME", + get label() { + return t("sources.timeUnknown"); + }, + }, + { + value: "AFTER_CUTOFF", + get label() { + return t("sources.afterCutoff"); + }, + }, + { + value: "PROHIBITED_CONTEXT", + get label() { + return t("sources.prohibited"); + }, + }, ]; export const sourceConnectSchema = z.object({ - project_id:z.string().min(1),relative_path:z.string().min(1),media_type:z.string().min(1), - authority:z.enum(authorityStates),cutoff_state:z.enum(cutoffStates),security_class:z.literal("INTERNAL"), - resource_scope:z.object({owner_kind:z.literal("PROJECT"),visibility:z.enum(["PROJECT_SHARED","EXPLICIT_GRANT"])}), - version_label:z.string(), + project_id: z.string().min(1), + relative_path: z.string().min(1), + media_type: z.string().min(1), + authority: z.enum(authorityStates), + cutoff_state: z.enum(cutoffStates), + security_class: z.literal("INTERNAL"), + resource_scope: z.object({ + owner_kind: z.literal("PROJECT"), + visibility: z.enum(["PROJECT_SHARED", "EXPLICIT_GRANT"]), + }), + version_label: z.string(), }); diff --git a/apps/web/src/api/sourceTime.ts b/apps/web/src/api/sourceTime.ts index 6b09a022..7efa45f2 100644 --- a/apps/web/src/api/sourceTime.ts +++ b/apps/web/src/api/sourceTime.ts @@ -11,7 +11,7 @@ export const sourceTimeAssessmentSchema = z.object({ byte_sha256: z.string(), cutoff_at: z.string(), cutoff_state: z.enum(cutoffStates), - mode: z.enum(["AUTO","USER_CONFIRMATION","ADVANCED_CORRECTION"]), + mode: z.enum(["AUTO", "USER_CONFIRMATION", "ADVANCED_CORRECTION"]), basis_observation_ids: z.array(z.string()), reason_code: z.string(), revision: z.number(), @@ -48,16 +48,45 @@ export function projectCutoffText(cutoffAt?: string | null) { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; } -export async function confirmSourceTime(input: z.infer & {assertion:"ON_OR_BEFORE_CUTOFF"|"AFTER_CUTOFF"}) { - return rpc<{source_time: SourceTimeAssessment}>("project/source/time/confirm", input, crypto.randomUUID()); +export async function confirmSourceTime( + input: z.infer & { + assertion: "ON_OR_BEFORE_CUTOFF" | "AFTER_CUTOFF"; + }, +) { + return rpc<{ source_time: SourceTimeAssessment }>( + "project/source/time/confirm", + input, + crypto.randomUUID(), + ); } -export async function correctSourceTime(input: z.infer & {assertion?: "ON_OR_BEFORE_CUTOFF"|"AFTER_CUTOFF"; revert_unknown?: boolean; correction_reason: string}) { - return rpc<{source_time: SourceTimeAssessment}>("project/source/time/correct", input, crypto.randomUUID()); +export async function correctSourceTime( + input: z.infer & { + assertion?: "ON_OR_BEFORE_CUTOFF" | "AFTER_CUTOFF"; + revert_unknown?: boolean; + correction_reason: string; + }, +) { + return rpc<{ source_time: SourceTimeAssessment }>( + "project/source/time/correct", + input, + crypto.randomUUID(), + ); } /** Refresh query-backed views only; do not auto-refetch legacy journaling RPCs. */ export async function refreshSourceTimeViews(client: QueryClient, projectId: string) { - await Promise.all(["sources", "evidence", "result-evidence", "research", "conversation", "research-history", "history-detail", "history-comparison", "restore-preview"] - .map(prefix => client.invalidateQueries({ queryKey: [prefix, projectId] }))); + await Promise.all( + [ + "sources", + "evidence", + "result-evidence", + "research", + "conversation", + "research-history", + "history-detail", + "history-comparison", + "restore-preview", + ].map((prefix) => client.invalidateQueries({ queryKey: [prefix, projectId] })), + ); } diff --git a/apps/web/src/api/submissionPersistence.test.ts b/apps/web/src/api/submissionPersistence.test.ts index 3568517a..7a987e50 100644 --- a/apps/web/src/api/submissionPersistence.test.ts +++ b/apps/web/src/api/submissionPersistence.test.ts @@ -1,21 +1,35 @@ // @vitest-environment jsdom import { afterEach, expect, it } from "vitest"; import { readDraft, writeDraft } from "./conversation"; -import { readResponseLanguageDefault, writeResponseLanguageDefault } from "./responseLanguageDefault"; +import { + readResponseLanguageDefault, + writeResponseLanguageDefault, +} from "./responseLanguageDefault"; import { submissionSignature, type PendingSubmission } from "./localWorkspacePersistence"; import { clearSubmission, readSubmission, writeSubmission } from "./submissionPersistence"; -afterEach(() => { localStorage.clear(); sessionStorage.clear(); }); +afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); +}); it("isolates hosted pending payloads and drafts by the server's opaque session scope", () => { - const a = {mode: "HOSTED" as const, browserScopeId: "browser:aaaaaaaa"}; - const b = {mode: "HOSTED" as const, browserScopeId: "browser:bbbbbbbb"}; - const record: PendingSubmission = {version: 2, responseLanguage: "en", workspaceId: a.browserScopeId, - projectId: "p", threadId: "t", problem: "same thread", selection: null, + const a = { mode: "HOSTED" as const, browserScopeId: "browser:aaaaaaaa" }; + const b = { mode: "HOSTED" as const, browserScopeId: "browser:bbbbbbbb" }; + const record: PendingSubmission = { + version: 2, + responseLanguage: "en", + workspaceId: a.browserScopeId, + projectId: "p", + threadId: "t", + problem: "same thread", + selection: null, signature: submissionSignature("p", "t", "same thread", null, null, "en"), - key: "11111111-1111-1111-1111-111111111111", createdAt: 1}; + key: "11111111-1111-1111-1111-111111111111", + createdAt: 1, + }; expect(writeSubmission(a, record)).toBe("SAVED"); - expect(readSubmission(a, "p", "t")).toEqual({kind: "PENDING", value: record}); - expect(readSubmission(b, "p", "t")).toEqual({kind: "NONE"}); + expect(readSubmission(a, "p", "t")).toEqual({ kind: "PENDING", value: record }); + expect(readSubmission(b, "p", "t")).toEqual({ kind: "NONE" }); expect(writeSubmission(b, record)).toBe("UNAVAILABLE"); writeDraft("p", "t", "original draft", a); writeResponseLanguageDefault(a, "en"); @@ -25,5 +39,5 @@ it("isolates hosted pending payloads and drafts by the server's opaque session s expect(readResponseLanguageDefault(b)).toBeNull(); expect(localStorage.length).toBe(0); expect(clearSubmission(b, "p", "t")).toBe(true); - expect(readSubmission(a, "p", "t")).toEqual({kind: "PENDING", value: record}); + expect(readSubmission(a, "p", "t")).toEqual({ kind: "PENDING", value: record }); }); diff --git a/apps/web/src/api/submissionPersistence.ts b/apps/web/src/api/submissionPersistence.ts index b033486e..25c5a5ba 100644 --- a/apps/web/src/api/submissionPersistence.ts +++ b/apps/web/src/api/submissionPersistence.ts @@ -1,19 +1,35 @@ -import { clearLocalPending, readLocalPending, validWorkspaceId, writeLocalPending, - type BrowserScope, type DraftWriteResult, type PendingRead, type PendingSubmission } from "./localWorkspacePersistence"; +import { + clearLocalPending, + readLocalPending, + validWorkspaceId, + writeLocalPending, + type BrowserScope, + type DraftWriteResult, + type PendingRead, + type PendingSubmission, +} from "./localWorkspacePersistence"; export function submissionScopeId(scope: BrowserScope): string | null { - return scope.mode === "LOCAL" ? scope.workspaceId : scope.browserScopeId ?? null; + return scope.mode === "LOCAL" ? scope.workspaceId : (scope.browserScopeId ?? null); } export function hasSubmissionStore(scope: BrowserScope): boolean { return scope.mode === "LOCAL" || validWorkspaceId(scope.browserScopeId); } -function hostedStore(): Storage | null { try { return sessionStorage; } catch { return null; } } +function hostedStore(): Storage | null { + try { + return sessionStorage; + } catch { + return null; + } +} export function readSubmission(scope: BrowserScope, project: string, thread: string): PendingRead { if (scope.mode === "LOCAL") return readLocalPending(scope.workspaceId, project, thread); - if (!hasSubmissionStore(scope)) return {kind: "NONE"}; + if (!hasSubmissionStore(scope)) return { kind: "NONE" }; const storage = hostedStore(); - return storage ? readLocalPending(scope.browserScopeId ?? null, project, thread, storage) : {kind: "INVALID"}; + return storage + ? readLocalPending(scope.browserScopeId ?? null, project, thread, storage) + : { kind: "INVALID" }; } export function writeSubmission(scope: BrowserScope, value: PendingSubmission): DraftWriteResult { if (value.workspaceId !== submissionScopeId(scope)) return "UNAVAILABLE"; @@ -24,5 +40,7 @@ export function writeSubmission(scope: BrowserScope, value: PendingSubmission): export function clearSubmission(scope: BrowserScope, project: string, thread: string): boolean { if (scope.mode === "LOCAL") return clearLocalPending(scope.workspaceId, project, thread); const storage = hostedStore(); - return storage ? clearLocalPending(scope.browserScopeId ?? null, project, thread, storage) : false; + return storage + ? clearLocalPending(scope.browserScopeId ?? null, project, thread, storage) + : false; } diff --git a/apps/web/src/api/threadName.ts b/apps/web/src/api/threadName.ts index 61a64a76..81d79fd6 100644 --- a/apps/web/src/api/threadName.ts +++ b/apps/web/src/api/threadName.ts @@ -1,4 +1,6 @@ /** The name a conversation is listed under: the one a person gave it, else its first question. */ -export function threadName(thread: { display_name?: string | null; problem?: string | null } | undefined): string { +export function threadName( + thread: { display_name?: string | null; problem?: string | null } | undefined, +): string { return thread?.display_name?.trim() || thread?.problem || ""; } diff --git a/apps/web/src/api/timelineExample.ts b/apps/web/src/api/timelineExample.ts index ff04585f..ee9a4db3 100644 --- a/apps/web/src/api/timelineExample.ts +++ b/apps/web/src/api/timelineExample.ts @@ -40,10 +40,34 @@ export const timelineExampleStatus: ResearchStatus = { }, }, completed_stages: [ - { role: "RESEARCH_PLANNER", state: "COMPLETED", elapsed_ms: 91700, context_bytes: 12000, dispatch_ids: ["d1"] }, - { role: "EVIDENCE_RERANKER", state: "COMPLETED", elapsed_ms: 59100, context_bytes: 8400, dispatch_ids: ["d2"] }, - { role: "SEMANTIC_REVIEWER", state: "COMPLETED", elapsed_ms: 68000, context_bytes: 15600, dispatch_ids: ["d3"] }, - { role: "HYPOTHESIS_GENERATOR", state: "COMPLETED", elapsed_ms: 131000, context_bytes: 22100, dispatch_ids: ["d4"] }, + { + role: "RESEARCH_PLANNER", + state: "COMPLETED", + elapsed_ms: 91700, + context_bytes: 12000, + dispatch_ids: ["d1"], + }, + { + role: "EVIDENCE_RERANKER", + state: "COMPLETED", + elapsed_ms: 59100, + context_bytes: 8400, + dispatch_ids: ["d2"], + }, + { + role: "SEMANTIC_REVIEWER", + state: "COMPLETED", + elapsed_ms: 68000, + context_bytes: 15600, + dispatch_ids: ["d3"], + }, + { + role: "HYPOTHESIS_GENERATOR", + state: "COMPLETED", + elapsed_ms: 131000, + context_bytes: 22100, + dispatch_ids: ["d4"], + }, ], activity_events: [ { seq: 1, kind: "note", key: "RESEARCH_PLANNER" }, @@ -118,7 +142,11 @@ export const timelineExampleStatus: ResearchStatus = { why_ko: "원문 확인 범위와 추측 금지 조건을 이후 단계에 그대로 적용하기 위해서입니다.", state: "succeeded", research_relation: "none", - result: { summary_ko: "질문 조건 4개와 인용 제한을 정리했습니다.", duration_ms: 91700, counts: { constraints: 4 } }, + result: { + summary_ko: "질문 조건 4개와 인용 제한을 정리했습니다.", + duration_ms: 91700, + counts: { constraints: 4 }, + }, redaction: { applied: false, classes: [], source_content_included: false }, }, { @@ -155,8 +183,17 @@ export const timelineExampleStatus: ResearchStatus = { raw_command_available: false, command_detail: "unavailable", }, - result: { summary_ko: "지정한 쪽의 구조와 표 위치를 확인했습니다.", duration_ms: 840, counts: { spans: 1 } }, - redaction: { applied: true, classes: ["absolute_path", "source_content"], public_note_ko: "로컬 경로와 원문은 화면에서 제외했습니다.", source_content_included: false }, + result: { + summary_ko: "지정한 쪽의 구조와 표 위치를 확인했습니다.", + duration_ms: 840, + counts: { spans: 1 }, + }, + redaction: { + applied: true, + classes: ["absolute_path", "source_content"], + public_note_ko: "로컬 경로와 원문은 화면에서 제외했습니다.", + source_content_included: false, + }, }, { schema_version: "thoth.user_activity_event.v1", @@ -173,7 +210,11 @@ export const timelineExampleStatus: ResearchStatus = { why_ko: "질문의 네 조건과 직접 연결되는 원문만 다음 판단에 넘기기 위해서입니다.", state: "succeeded", research_relation: "selected_candidate", - result: { summary_ko: "후보 38개 중 관련성이 높은 4개를 골랐습니다.", duration_ms: 59100, counts: { reviewed: 38, selected: 4 } }, + result: { + summary_ko: "후보 38개 중 관련성이 높은 4개를 골랐습니다.", + duration_ms: 59100, + counts: { reviewed: 38, selected: 4 }, + }, redaction: { applied: false, classes: [], source_content_included: false }, }, { @@ -191,10 +232,32 @@ export const timelineExampleStatus: ResearchStatus = { why_ko: "서로 다른 설명 4개 중 원문과 맞지 않는 설명을 걸러내기 위해서입니다.", state: "running", research_relation: "none", - target: { kind: "model", title: "판단 검토 모델", display_ref: "모델 호출 5", currentness: null, access_state: "allowed" }, - tool: { display_name: "판단 검토", family: "MODEL", operation: "CALL_MODEL", sanitized_args: { evidence_candidates: 4, hypotheses: 4 }, raw_command_available: false, command_detail: "unavailable" }, - result: { summary_ko: "가설 4개 중 2개를 검토했습니다.", duration_ms: 47000, counts: { reviewed: 2, total: 4 } }, - redaction: { applied: true, classes: ["prompt", "provider_payload"], public_note_ko: "프롬프트와 제공자 응답 원문은 표시하지 않았습니다.", source_content_included: false }, + target: { + kind: "model", + title: "판단 검토 모델", + display_ref: "모델 호출 5", + currentness: null, + access_state: "allowed", + }, + tool: { + display_name: "판단 검토", + family: "MODEL", + operation: "CALL_MODEL", + sanitized_args: { evidence_candidates: 4, hypotheses: 4 }, + raw_command_available: false, + command_detail: "unavailable", + }, + result: { + summary_ko: "가설 4개 중 2개를 검토했습니다.", + duration_ms: 47000, + counts: { reviewed: 2, total: 4 }, + }, + redaction: { + applied: true, + classes: ["prompt", "provider_payload"], + public_note_ko: "프롬프트와 제공자 응답 원문은 표시하지 않았습니다.", + source_content_included: false, + }, }, ], request: { @@ -211,7 +274,9 @@ export const timelineExampleStatus: ResearchStatus = { unreported_calls: 1, cumulative_token_limit_enforced: false, }, - model_dispatches: [{ state: "RESERVED", transport_observation: { elapsed_ms: 47000, received_bytes: 0 } }], + model_dispatches: [ + { state: "RESERVED", transport_observation: { elapsed_ms: 47000, received_bytes: 0 } }, + ], }; export function isTimelineExampleRequested(): boolean { diff --git a/apps/web/src/api/trace.ts b/apps/web/src/api/trace.ts index cc3a1bd3..158e1eb4 100644 --- a/apps/web/src/api/trace.ts +++ b/apps/web/src/api/trace.ts @@ -1,30 +1,86 @@ import { rpc } from "./rpcClient"; -export type TraceItem = { item_id: string; item_key: string; kind: string; title: string; fields: Record; source_span_refs: string[] }; +export type TraceItem = { + item_id: string; + item_key: string; + kind: string; + title: string; + fields: Record; + source_span_refs: string[]; +}; export type TraceLink = { link_id: string; from_id: string; to_id: string; relation: string }; export type TraceRule = { - rule_id: string; rule_revision: number; criterion_id: string; measure: string; comparator: string; threshold: string; - unit: string; condition: string; rounding: { digits: number; mode: string }; required: boolean; + rule_id: string; + rule_revision: number; + criterion_id: string; + measure: string; + comparator: string; + threshold: string; + unit: string; + condition: string; + rounding: { digits: number; mode: string }; + required: boolean; }; export type TraceResult = { - result_id: string; result_revision: number; criterion_id: string; condition: string; value: string | null; raw_value: string | null; - unit: string; numerator: number | null; denominator: number | null; observed_at: string; source_span_refs: string[]; + result_id: string; + result_revision: number; + criterion_id: string; + condition: string; + value: string | null; + raw_value: string | null; + unit: string; + numerator: number | null; + denominator: number | null; + observed_at: string; + source_span_refs: string[]; }; export type ChangedDependency = { - kind: string; ref_id: string; field: string; before: string | null; after: string | null; - before_revision: number | null; after_revision: number | null; criterion_ids: string[]; requirement_ids: string[]; + kind: string; + ref_id: string; + field: string; + before: string | null; + after: string | null; + before_revision: number | null; + after_revision: number | null; + criterion_ids: string[]; + requirement_ids: string[]; +}; +export type Confirmation = { + verdict_revision_digest: string; + actor_id: string; + confirmed_at: string; + rationale: string; }; -export type Confirmation = { verdict_revision_digest: string; actor_id: string; confirmed_at: string; rationale: string }; export type ResultRef = { result_id: string; result_revision: number }; export type VerdictRevision = { - subject_kind: "CRITERION" | "REQUIREMENT"; subject_id: string; state: string; + subject_kind: "CRITERION" | "REQUIREMENT"; + subject_id: string; + state: string; cause: { trigger: string; changed: ChangedDependency[] }; - basis: { input_digest: string; source_span_refs: string[]; evidence_text_preserved: boolean; child_verdict_digests: string[] }; - selection: { policy: { name: string; version: number }; candidates: ResultRef[]; chosen: ResultRef[]; excluded: (ResultRef & { reason: string })[] } | null; - conditions: { required: boolean; rule_id: string | null; condition: string | null; unit: string | null; required_criteria: string[] }; + basis: { + input_digest: string; + source_span_refs: string[]; + evidence_text_preserved: boolean; + child_verdict_digests: string[]; + }; + selection: { + policy: { name: string; version: number }; + candidates: ResultRef[]; + chosen: ResultRef[]; + excluded: (ResultRef & { reason: string })[]; + } | null; + conditions: { + required: boolean; + rule_id: string | null; + condition: string | null; + unit: string | null; + required_criteria: string[]; + }; actor: { actor_id: string; computed_at: string }; reasons: { computed: string[]; human: string[] }; - parent_digest: string | null; verdict_digest: string; revision_digest: string; + parent_digest: string | null; + verdict_digest: string; + revision_digest: string; }; export type Verdict = VerdictRevision & { currentness: { state: "CURRENT" | "STALE_BASIS"; changed_dependencies: ChangedDependency[] }; @@ -33,37 +89,114 @@ export type Verdict = VerdictRevision & { history_total: number; }; export type TraceView = { - record_digest: string | null; set_digest: string; items: TraceItem[]; links: TraceLink[]; rules: TraceRule[]; results: TraceResult[]; - verdicts: Verdict[]; confirmations: Confirmation[]; pending_changes: ChangedDependency[]; + record_digest: string | null; + set_digest: string; + items: TraceItem[]; + links: TraceLink[]; + rules: TraceRule[]; + results: TraceResult[]; + verdicts: Verdict[]; + confirmations: Confirmation[]; + pending_changes: ChangedDependency[]; }; export type ImportIssue = { code: string; row: number | null; ref: string | null; detail: string }; export type PlannedChange = { kind: string; id: string; row: number; fields: string[] }; export type ImportMode = "CREATE" | "UPDATE"; export type ImportPreview = { - preview_id: string; input_sha256: string; mode: ImportMode; current_set_digest: string | null; applicable: boolean; - changes: { added: PlannedChange[]; updated: PlannedChange[]; deleted: PlannedChange[]; unchanged: number }; - conflicts: ImportIssue[]; losses: string[]; spreadsheet_suspects: ImportIssue[]; + preview_id: string; + input_sha256: string; + mode: ImportMode; + current_set_digest: string | null; + applicable: boolean; + changes: { + added: PlannedChange[]; + updated: PlannedChange[]; + deleted: PlannedChange[]; + unchanged: number; + }; + conflicts: ImportIssue[]; + losses: string[]; + spreadsheet_suspects: ImportIssue[]; +}; +export type VerdictChange = { + subject_kind: string; + subject_id: string; + before: string | null; + after: string; +}; +export type ImportApplied = { + applied: boolean; + reason?: string; + record_digest: string | null; + verdict_changes: VerdictChange[]; + trace?: TraceView; +}; +export type TraceExport = { + format: string; + filename: string; + csv_text: string; + sha256: string; + set_digest: string; + loss_manifest: string[]; }; -export type VerdictChange = { subject_kind: string; subject_id: string; before: string | null; after: string }; -export type ImportApplied = { applied: boolean; reason?: string; record_digest: string | null; verdict_changes: VerdictChange[]; trace?: TraceView }; -export type TraceExport = { format: string; filename: string; csv_text: string; sha256: string; set_digest: string; loss_manifest: string[] }; -export type HistoryPage = { subject_kind: string; subject_id: string; history_total: number; revisions: VerdictRevision[]; next_before_digest: string | null }; +export type HistoryPage = { + subject_kind: string; + subject_id: string; + history_total: number; + revisions: VerdictRevision[]; + next_before_digest: string | null; +}; export const HISTORY_PAGE = 20; export const traceKey = (projectId: string) => ["verification-trace", projectId] as const; -const call = async (method: string, input: Record, signal?: AbortSignal) => +const call = async (method: string, input: Record, signal?: AbortSignal) => (await rpc(method, input, crypto.randomUUID(), signal)).value; -export const readTrace = (projectId: string, signal?: AbortSignal) => call("trace/read", { project_id: projectId }, signal); -export const exportTrace = (projectId: string) => call("trace/export", { project_id: projectId }); +export const readTrace = (projectId: string, signal?: AbortSignal) => + call("trace/read", { project_id: projectId }, signal); +export const exportTrace = (projectId: string) => + call("trace/export", { project_id: projectId }); export const previewTraceImport = (projectId: string, mode: ImportMode, csvText: string) => call("trace/importPreview", { project_id: projectId, mode, csv_text: csvText }); -export const applyTraceImport = (projectId: string, mode: ImportMode, csvText: string, preview: ImportPreview) => - call("trace/importApply", { project_id: projectId, mode, csv_text: csvText, preview_id: preview.preview_id, input_sha256: preview.input_sha256 }); -export const confirmTraceVerdict = (projectId: string, revisionDigest: string, rationale: string, expectedDigest: string | null) => - call("trace/confirm", { project_id: projectId, verdict_revision_digest: revisionDigest, rationale, expected_digest: expectedDigest }); -export const readTraceHistory = (projectId: string, kind: "CRITERION" | "REQUIREMENT", subjectId: string, beforeDigest: string, limit = HISTORY_PAGE) => - call("trace/history", { project_id: projectId, subject_kind: kind, subject_id: subjectId, before_digest: beforeDigest, limit }); +export const applyTraceImport = ( + projectId: string, + mode: ImportMode, + csvText: string, + preview: ImportPreview, +) => + call("trace/importApply", { + project_id: projectId, + mode, + csv_text: csvText, + preview_id: preview.preview_id, + input_sha256: preview.input_sha256, + }); +export const confirmTraceVerdict = ( + projectId: string, + revisionDigest: string, + rationale: string, + expectedDigest: string | null, +) => + call("trace/confirm", { + project_id: projectId, + verdict_revision_digest: revisionDigest, + rationale, + expected_digest: expectedDigest, + }); +export const readTraceHistory = ( + projectId: string, + kind: "CRITERION" | "REQUIREMENT", + subjectId: string, + beforeDigest: string, + limit = HISTORY_PAGE, +) => + call("trace/history", { + project_id: projectId, + subject_kind: kind, + subject_id: subjectId, + before_digest: beforeDigest, + limit, + }); diff --git a/apps/web/src/api/uploadTypes.ts b/apps/web/src/api/uploadTypes.ts index 90ce30db..835e596d 100644 --- a/apps/web/src/api/uploadTypes.ts +++ b/apps/web/src/api/uploadTypes.ts @@ -5,7 +5,20 @@ import { message } from "../i18n/formatMessages"; * a copy so the file dialog can filter and a wrong file is refused before it is sent; a server test * compares the two lists. */ -export const UPLOAD_EXTENSIONS = [".hwpx", ".pdf", ".docx", ".xlsx", ".csv", ".json", ".md", ".txt", ".yaml", ".yml", ".html", ".htm"] as const; +export const UPLOAD_EXTENSIONS = [ + ".hwpx", + ".pdf", + ".docx", + ".xlsx", + ".csv", + ".json", + ".md", + ".txt", + ".yaml", + ".yml", + ".html", + ".htm", +] as const; export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024; /** The value of the file input's accept attribute. */ export const UPLOAD_ACCEPT = UPLOAD_EXTENSIONS.join(","); @@ -18,19 +31,26 @@ const REFUSALS = ["UPLOAD_UNSUPPORTED_TYPE", "UPLOAD_EMPTY", "UPLOAD_TOO_LARGE"] export type UploadRefusalCode = (typeof REFUSALS)[number]; export function isUploadRefusalCode(value: string | null): value is UploadRefusalCode { - return REFUSALS.some(code => code === value); + return REFUSALS.some((code) => code === value); } /** A file that cannot be uploaded: which file and why, said in the screen language at the time it is shown. */ export class UploadRefused extends Error { - constructor(readonly code: UploadRefusalCode, readonly filename: string) { + constructor( + readonly code: UploadRefusalCode, + readonly filename: string, + ) { super(code); - Object.defineProperty(this, "message", { configurable: true, get: () => uploadRefusalText(code, filename) }); + Object.defineProperty(this, "message", { + configurable: true, + get: () => uploadRefusalText(code, filename), + }); } } export function uploadRefusalText(code: UploadRefusalCode, filename: string): string { - if (code === "UPLOAD_UNSUPPORTED_TYPE") return message("uploadUnsupported", filename, UPLOAD_FORMATS); + if (code === "UPLOAD_UNSUPPORTED_TYPE") + return message("uploadUnsupported", filename, UPLOAD_FORMATS); if (code === "UPLOAD_EMPTY") return message("uploadEmpty", filename); return message("uploadTooLarge", filename); } @@ -39,7 +59,8 @@ export function uploadRefusalText(code: UploadRefusalCode, filename: string): st export function refusalBeforeUpload(file: Pick): UploadRefused | null { const dot = file.name.lastIndexOf("."); const extension = dot === -1 ? "" : file.name.slice(dot).toLowerCase(); - if (!UPLOAD_EXTENSIONS.some(item => item === extension)) return new UploadRefused("UPLOAD_UNSUPPORTED_TYPE", file.name); + if (!UPLOAD_EXTENSIONS.some((item) => item === extension)) + return new UploadRefused("UPLOAD_UNSUPPORTED_TYPE", file.name); if (file.size === 0) return new UploadRefused("UPLOAD_EMPTY", file.name); if (file.size > MAX_UPLOAD_BYTES) return new UploadRefused("UPLOAD_TOO_LARGE", file.name); return null; diff --git a/apps/web/src/components/AbsenceChecks.test.tsx b/apps/web/src/components/AbsenceChecks.test.tsx index 54f58a4c..2c450e63 100644 --- a/apps/web/src/components/AbsenceChecks.test.tsx +++ b/apps/web/src/components/AbsenceChecks.test.tsx @@ -4,13 +4,41 @@ import { setLocale } from "../i18n/locale"; import { AbsenceChecks } from "./AbsenceChecks"; import { ResearchResultCard } from "./ResearchResultCard"; -const payload = { schema_version: "1.0.0", checker_version: "literal-absence:1.0.0", items: [ - { target: "Polars 1.44.1", a_outcome: "FOUND", b_outcome: "FOUND", effective: "FOUND_IN_MODEL_INPUT", warning_code: "FOUND_IN_MODEL_INPUT" }, - { target: "Polars 1.44.2", a_outcome: "NOT_FOUND_WITHIN_SCOPE", b_outcome: "FOUND", effective: "FOUND_OUTSIDE_MODEL_INPUT", warning_code: "FOUND_OUTSIDE_MODEL_INPUT", injection_state: "NOT_DELIVERED" }, - { target: "반복 분포", a_outcome: "UNSUPPORTED", b_outcome: "UNSUPPORTED", effective: "UNSUPPORTED" }, - { target: "Other", a_outcome: "NOT_FOUND_WITHIN_SCOPE", b_outcome: "NOT_FOUND_WITHIN_SCOPE", effective: "NOT_FOUND_WITHIN_SCOPES" }, -] }; -const render = (value: unknown) => renderToStaticMarkup(); +const payload = { + schema_version: "1.0.0", + checker_version: "literal-absence:1.0.0", + items: [ + { + target: "Polars 1.44.1", + a_outcome: "FOUND", + b_outcome: "FOUND", + effective: "FOUND_IN_MODEL_INPUT", + warning_code: "FOUND_IN_MODEL_INPUT", + }, + { + target: "Polars 1.44.2", + a_outcome: "NOT_FOUND_WITHIN_SCOPE", + b_outcome: "FOUND", + effective: "FOUND_OUTSIDE_MODEL_INPUT", + warning_code: "FOUND_OUTSIDE_MODEL_INPUT", + injection_state: "NOT_DELIVERED", + }, + { + target: "반복 분포", + a_outcome: "UNSUPPORTED", + b_outcome: "UNSUPPORTED", + effective: "UNSUPPORTED", + }, + { + target: "Other", + a_outcome: "NOT_FOUND_WITHIN_SCOPE", + b_outcome: "NOT_FOUND_WITHIN_SCOPE", + effective: "NOT_FOUND_WITHIN_SCOPES", + }, + ], +}; +const render = (value: unknown) => + renderToStaticMarkup(); afterEach(() => setLocale("ko")); describe("declared absence checks", () => { it("leaves old and unsupported versions without invented check records", () => { @@ -33,7 +61,13 @@ describe("declared absence checks", () => { it("keeps English wording, exact targets and the original answer beside the hold", () => { setLocale("en"); const answer = "The original answer remains unchanged."; - const html = renderToStaticMarkup( {}}/>); + const html = renderToStaticMarkup( + {}} + />, + ); expect(html).toContain(answer); expect(html).toContain("The model claimed absence of a string present in its input"); expect(html).toContain("Found in allowed material outside the model input"); @@ -41,10 +75,25 @@ describe("declared absence checks", () => { expect(html).not.toContain("AUDITED_COMPLETE"); }); it("renders full records and does not call an unchecked input absent", () => { - const html = render({ ...payload, active_stage_ref: { revision_id: "new" }, items: [ - { claim: { target: "Old target" }, origin_stage_ref: { revision_id: "old" }, effective: "FOUND_IN_MODEL_INPUT" }, - { claim: { target: "Visible target" }, origin_stage_ref: { revision_id: "new" }, a_result: { outcome: "NOT_CHECKED" }, b_result: { outcome: "FOUND" }, effective: "FOUND_A_SCOPE_UNCHECKED", warning_code: "FOUND_A_SCOPE_UNCHECKED" }, - ] }); + const html = render({ + ...payload, + active_stage_ref: { revision_id: "new" }, + items: [ + { + claim: { target: "Old target" }, + origin_stage_ref: { revision_id: "old" }, + effective: "FOUND_IN_MODEL_INPUT", + }, + { + claim: { target: "Visible target" }, + origin_stage_ref: { revision_id: "new" }, + a_result: { outcome: "NOT_CHECKED" }, + b_result: { outcome: "FOUND" }, + effective: "FOUND_A_SCOPE_UNCHECKED", + warning_code: "FOUND_A_SCOPE_UNCHECKED", + }, + ], + }); expect(html).toContain("AI 입력 범위는 미확인"); expect(html).toContain("Visible target"); expect(html).not.toContain("Old target"); diff --git a/apps/web/src/components/AbsenceChecks.tsx b/apps/web/src/components/AbsenceChecks.tsx index 246a9dfd..c9111d7e 100644 --- a/apps/web/src/components/AbsenceChecks.tsx +++ b/apps/web/src/components/AbsenceChecks.tsx @@ -4,24 +4,69 @@ import { t } from "../i18n/locale"; /** A declared string check is not evidence that something is absent from the whole document. */ export function AbsenceChecks({ result }: { result: Record }) { const payload = objectValue(result.absence_assessments); - const supportedChecker = payload.checker_version === "literal-absence:1.0.0" || payload.checker_version === "literal-absence:1.1.0"; + const supportedChecker = + payload.checker_version === "literal-absence:1.0.0" || + payload.checker_version === "literal-absence:1.1.0"; if (payload.schema_version !== "1.0.0" || !supportedChecker) return null; const active = textValue(objectValue(payload.active_stage_ref).revision_id); - const items = objectList(payload.items).filter(item => !active || textValue(objectValue(item.origin_stage_ref).revision_id) === active); - const outcome = (value: unknown) => value === "FOUND" ? t("absence.found") : value === "NOT_FOUND_WITHIN_SCOPE" ? t("absence.notFound") : value === "UNSUPPORTED" ? t("absence.unsupported") : t("absence.notChecked"); - const description = (value: unknown) => value === "FOUND_IN_MODEL_INPUT" ? t("absence.inInput") : value === "FOUND_OUTSIDE_MODEL_INPUT" ? t("absence.outsideInput") : value === "FOUND_A_SCOPE_UNCHECKED" ? t("absence.inputUnchecked") : value === "NOT_FOUND_WITHIN_SCOPES" ? t("absence.notFound") : value === "UNSUPPORTED" ? t("absence.unsupported") : t("absence.notChecked"); - return
-

{t("absence.title")}

-

{t("absence.limitation")}

- {items.length === 0 ?

{t("absence.empty")}

:
    {items.map((item, index) => { - const claim = objectValue(item.claim); - const target = textValue(item.target) || textValue(claim.target); - const warning = typeof item.warning_code === "string" && item.warning_code.startsWith("FOUND_"); - return
  • -

    {description(item.effective)}{" · "}{target}

    - {t("absence.inputScope")}: {outcome(item.a_outcome ?? objectValue(item.a_result).outcome)}{" · "}{t("absence.allowedScope")}: {outcome(item.b_outcome ?? objectValue(item.b_result).outcome)} - {item.injection_state === "NOT_DELIVERED" &&

    {t("absence.notDelivered")}

    } -
  • ; - })}
} -
; + const items = objectList(payload.items).filter( + (item) => !active || textValue(objectValue(item.origin_stage_ref).revision_id) === active, + ); + const outcome = (value: unknown) => + value === "FOUND" + ? t("absence.found") + : value === "NOT_FOUND_WITHIN_SCOPE" + ? t("absence.notFound") + : value === "UNSUPPORTED" + ? t("absence.unsupported") + : t("absence.notChecked"); + const description = (value: unknown) => + value === "FOUND_IN_MODEL_INPUT" + ? t("absence.inInput") + : value === "FOUND_OUTSIDE_MODEL_INPUT" + ? t("absence.outsideInput") + : value === "FOUND_A_SCOPE_UNCHECKED" + ? t("absence.inputUnchecked") + : value === "NOT_FOUND_WITHIN_SCOPES" + ? t("absence.notFound") + : value === "UNSUPPORTED" + ? t("absence.unsupported") + : t("absence.notChecked"); + return ( +
+

{t("absence.title")}

+

{t("absence.limitation")}

+ {items.length === 0 ? ( +

{t("absence.empty")}

+ ) : ( +
    + {items.map((item, index) => { + const claim = objectValue(item.claim); + const target = textValue(item.target) || textValue(claim.target); + const warning = + typeof item.warning_code === "string" && item.warning_code.startsWith("FOUND_"); + return ( +
  • +

    + {description(item.effective)} + {" · "} + {target} +

    + + {t("absence.inputScope")}:{" "} + {outcome(item.a_outcome ?? objectValue(item.a_result).outcome)} + {" · "} + {t("absence.allowedScope")}:{" "} + {outcome(item.b_outcome ?? objectValue(item.b_result).outcome)} + + {item.injection_state === "NOT_DELIVERED" && ( +

    {t("absence.notDelivered")}

    + )} +
  • + ); + })} +
+ )} +
+ ); } diff --git a/apps/web/src/components/ActionCompare.test.tsx b/apps/web/src/components/ActionCompare.test.tsx index 477ff5da..8ef7930c 100644 --- a/apps/web/src/components/ActionCompare.test.tsx +++ b/apps/web/src/components/ActionCompare.test.tsx @@ -7,10 +7,11 @@ import { actionFamilyLabel } from "./statusLabels"; const render = (patch: (result: Record) => void = () => {}) => { const result = structuredClone(excerpt) as Record; patch(result); - return renderToStaticMarkup(); + return renderToStaticMarkup(); }; // Internal ids may sit in title attributes and technical details only. -const visible = (html: string) => html.replace(//g, "").replace(/ title="[^"]*"/g, ""); +const visible = (html: string) => + html.replace(//g, "").replace(/ title="[^"]*"/g, ""); const analysis = excerpt.action_plan.decision_analysis; it("leads with the model's recommendation, its uncertainty and what would change it", () => { @@ -34,21 +35,41 @@ it("lists mandatory criteria and the action-by-criterion assessments with source it("shows risk, execution authority, reversibility and unknown cost and time in plain Korean", () => { const html = render(); - for (const text of ["승인 없이 실행 가능", "사전 허용 범위", "격리 실행만", "되돌릴 수 있음"]) expect(html).toContain(text); + for (const text of ["승인 없이 실행 가능", "사전 허용 범위", "격리 실행만", "되돌릴 수 있음"]) + expect(html).toContain(text); expect(html).toContain("비용·시간: 미확인"); expect(visible(html)).not.toMatch(/AUTO_R0|PREAUTHORIZED|SANDBOX_ONLY|FULL|READ_ONLY_ANALYSIS/); expect(actionFamilyLabel("SANDBOX_REPLAY")).toBe("격리 환경 재현"); expect(actionFamilyLabel("SOMETHING_ELSE")).toBeNull(); }); -const estimate = (dimension: string, band: string, type: string, basis: string, at = "2026-09-30T00:00:00Z") => - ({ dimension, band, estimator_type: type, estimator_ref: type === "AI" ? "ACTION_PLANNER" : "human:local-user", basis_text: basis, assumptions: [], created_at: at }); -const withEstimates = (list: unknown[]) => render(result => { - ((result.action_plan as { alternatives: Record[] }).alternatives[0]).effort_estimates = list; +const estimate = ( + dimension: string, + band: string, + type: string, + basis: string, + at = "2026-09-30T00:00:00Z", +) => ({ + dimension, + band, + estimator_type: type, + estimator_ref: type === "AI" ? "ACTION_PLANNER" : "human:local-user", + basis_text: basis, + assumptions: [], + created_at: at, }); +const withEstimates = (list: unknown[]) => + render((result) => { + ( + result.action_plan as { alternatives: Record[] } + ).alternatives[0].effort_estimates = list; + }); it("replaces the unknown cost and time with the band, who estimated it and why", () => { - const html = withEstimates([estimate("TIME", "MEDIUM", "AI", "담당자 회신이 필요"), estimate("COST_EFFORT", "LOW", "AI", "기존 자료 재대조")]); + const html = withEstimates([ + estimate("TIME", "MEDIUM", "AI", "담당자 회신이 필요"), + estimate("COST_EFFORT", "LOW", "AI", "기존 자료 재대조"), + ]); expect(html).toContain("시간: 보통 · AI 추정 · 담당자 회신이 필요"); expect(html).toContain("비용·품: 낮음 · AI 추정 · 기존 자료 재대조"); expect(visible(html)).not.toMatch(/MEDIUM|COST_EFFORT|ACTION_PLANNER/); @@ -61,14 +82,20 @@ it("puts an unknown band in the needs-confirmation wording, never as a cheap opt }); it("shows the AI and the person side by side when they differ, and keeps the AI value", () => { - const html = withEstimates([estimate("TIME", "MEDIUM", "AI", "담당자 회신"), estimate("TIME", "HIGH", "HUMAN", "현장 시험이 필요", "2026-09-30T01:00:00Z")]); + const html = withEstimates([ + estimate("TIME", "MEDIUM", "AI", "담당자 회신"), + estimate("TIME", "HIGH", "HUMAN", "현장 시험이 필요", "2026-09-30T01:00:00Z"), + ]); expect(html).toContain("AI 추정: 보통"); expect(html).toContain("사람 추정: 김"); expect(html).toContain("현장 시험이 필요"); }); it("shows one value when the AI and the person agree, and never a number for a band", () => { - const html = withEstimates([estimate("TIME", "HIGH", "AI", "시험 일정"), estimate("TIME", "HIGH", "HUMAN", "동의", "2026-09-30T01:00:00Z")]); + const html = withEstimates([ + estimate("TIME", "HIGH", "AI", "시험 일정"), + estimate("TIME", "HIGH", "HUMAN", "동의", "2026-09-30T01:00:00Z"), + ]); expect(html).toContain("시간: 김 · AI·사람 추정 일치"); expect(html).not.toMatch(/시간: [0-9]/); }); @@ -78,7 +105,7 @@ it("never shows a total score or rank", () => { }); it("asks the user's preference question when the model recorded one", () => { - const html = render(result => { + const html = render((result) => { const plan = result.action_plan as { decision_analysis: Record }; plan.decision_analysis.preference_question = "비용과 속도 중 무엇이 더 중요합니까?"; }); @@ -87,8 +114,14 @@ it("asks the user's preference question when the model recorded one", () => { }); it("still lists the actions when no decision analysis was recorded", () => { - const html = render(result => { delete (result.action_plan as Record).decision_analysis; }); + const html = render((result) => { + delete (result.action_plan as Record).decision_analysis; + }); expect(html).not.toContain("필수 기준"); expect(html).toContain("비용·시간: 미확인"); - expect(render(result => { (result as Record).action_plan = { alternatives: [] }; })).toContain("아직 제안된 행동이 없습니다"); + expect( + render((result) => { + (result as Record).action_plan = { alternatives: [] }; + }), + ).toContain("아직 제안된 행동이 없습니다"); }); diff --git a/apps/web/src/components/ActionCompare.tsx b/apps/web/src/components/ActionCompare.tsx index dccbd66c..99e442d6 100644 --- a/apps/web/src/components/ActionCompare.tsx +++ b/apps/web/src/components/ActionCompare.tsx @@ -13,13 +13,27 @@ import { standingActionNote } from "./judgmentRecordText"; import type { DiscriminationItem } from "../api/judgmentRecords"; import { orderedActions } from "./testOrderInput"; import { StatusBadge } from "./StatusBadge"; -import { actionFamilyLabel, actionStateLabel, reversibilityLabel, statusLabel } from "./statusLabels"; +import { + actionFamilyLabel, + actionStateLabel, + reversibilityLabel, + statusLabel, +} from "./statusLabels"; -const legacyEffort = (action: Record) => action.estimated_cost == null && action.estimated_seconds == null ? undefined - : formatMessage("actionRecorded", String(action.estimated_cost ?? t("actions.costUnknown")), String(action.estimated_seconds ?? t("actions.timeUnknown"))); +const legacyEffort = (action: Record) => + action.estimated_cost == null && action.estimated_seconds == null + ? undefined + : formatMessage( + "actionRecorded", + String(action.estimated_cost ?? t("actions.costUnknown")), + String(action.estimated_seconds ?? t("actions.timeUnknown")), + ); const staleNote = (action: Record, links?: HypothesisLink[]) => - staleActionNote([...stringValues(action.hypothesis_ids), ...stringValues(action.hypothesis_refs)], links); + staleActionNote( + [...stringValues(action.hypothesis_ids), ...stringValues(action.hypothesis_refs)], + links, + ); /** Next actions: the model's recommendation, the criteria it used, and how each action fares. No score, no rank. */ /** @@ -28,16 +42,26 @@ const staleNote = (action: Record, links?: HypothesisLink[]) => */ function stateTag(action: Record): string | null { const label = actionStateLabel(textValue(action.state)) ?? null; - return label !== null && label === statusLabel(textValue(action.execution_authority)) ? null : label; + return label !== null && label === statusLabel(textValue(action.execution_authority)) + ? null + : label; } /** The recorded values behind the tags (R0, AUTO_R0, …), for the folded technical details. */ function recordedCodes(action: Record): string[] { - return [action.risk_tier, action.execution_authority, action.state].map(textValue).filter(Boolean); + return [action.risk_tier, action.execution_authority, action.state] + .map(textValue) + .filter(Boolean); } -export function ActionCompare({ result, projectId, links, discrimination }: { - result: Record; projectId?: string; +export function ActionCompare({ + result, + projectId, + links, + discrimination, +}: { + result: Record; + projectId?: string; /** Hypotheses that came from a trace row, read against today's verdicts; an action on a changed one is marked. */ links?: HypothesisLink[]; /** What people recorded for the tests of these hypotheses; an action resting on one the results went against gets a note (nothing is held back). */ @@ -48,51 +72,190 @@ export function ActionCompare({ result, projectId, links, discrimination }: { const analysis = objectValue(plan.decision_analysis); const criteria = objectList(analysis.criteria); const evaluations = objectList(analysis.evaluations); - const extra = [...actions.flatMap(action => stringValues(action.source_refs)), ...evaluations.flatMap(item => stringValues(item.evidence_refs))]; + const extra = [ + ...actions.flatMap((action) => stringValues(action.source_refs)), + ...evaluations.flatMap((item) => stringValues(item.evidence_refs)), + ]; const numbers = numberSources(typeof result.answer === "string" ? result.answer : "", extra); - const cite = (refs: string[]) => refs.map(ref => [{numbers.get(ref)}]); + const cite = (refs: string[]) => + refs.map((ref) => ( + + [{numbers.get(ref)}] + + )); if (actions.length === 0) return

{t("actions.empty")}

; const againstNote = (action: Record) => - standingActionNote([...stringValues(action.hypothesis_ids), ...stringValues(action.hypothesis_refs)], discrimination); - const approvalNote = actions.filter(action => action.execution_authority === "HUMAN_REQUIRED_R3").map(againstNote).find(Boolean) ?? null; - const columnLabel = (action: Record, index: number) => formatMessage("actionNumber", index + 1, actionFamilyLabel(textValue(action.action_family))); - return <> - {textValue(analysis.decision) &&
-

{t("actions.recommended")}

-

{textValue(analysis.decision)}

- {textValue(analysis.uncertainty) &&

{t("actions.uncertainty")}
{textValue(analysis.uncertainty)}

} - {textValue(analysis.sensitivity) &&

{t("actions.changeConditions")}
{textValue(analysis.sensitivity)}

} - {textValue(analysis.preference_question) &&

{t("actions.questions")}
{textValue(analysis.preference_question)}

} -
} - {projectId && textValue(plan.plan_id) && actions.some(action => action.execution_authority === "HUMAN_REQUIRED_R3") && <> - {approvalNote && {approvalNote}} - } - {criteria.length > 0 &&
-

{t("actions.criteria")}

-
    {criteria.map((criterion, index) =>
  • {criterion.mandatory === true && {t("actions.required")}} {textValue(criterion.name)} - {textValue(criterion.rationale) && <>
    {textValue(criterion.rationale)}}
  • )}
- {evaluations.length > 0 && - {t("followup.criterion")}{actions.map((action, index) => {columnLabel(action, index)})} - {criteria.map((criterion, index) => {textValue(criterion.name)} - {actions.map((action, column) => {evaluations.filter(item => item.action_id === action.action_id && item.criterion_id === criterion.criterion_id) - .map((item, k) =>

{textValue(item.scenario) && {textValue(item.scenario)}: }{textValue(item.assessment)} {cite(stringValues(item.evidence_refs))}

)})})} -
} -
} - {orderedActions(actions).map(({ action, heading, key }) => {heading &&

{heading}

}
-

{textValue(action.specification)}

-
- {reversibilityLabel(textValue(action.reversibility)) ?? t("actions.reversibilityUnknown")} - {stateTag(action) && {stateTag(action)}}
- {staleNote(action, links) && {staleNote(action, links)}} - {againstNote(action) && {againstNote(action)}} -

{textValue(action.expected_information_value)}

- {projectId && textValue(action.action_id) - ? - : } - {stringValues(action.missing_evidence).map((value, j) =>

{t("labels.requiredEvidence")}{" "}{value}

)} - {stringValues(action.source_refs).length > 0 &&

{t("history.evidence")}{" "}{cite(stringValues(action.source_refs))}

} -

{action.execution_authority === "HUMAN_REQUIRED_R3" ? t("actions.protected") : t("actions.proposalNotice")}

- -
)} - ; + standingActionNote( + [...stringValues(action.hypothesis_ids), ...stringValues(action.hypothesis_refs)], + discrimination, + ); + const approvalNote = + actions + .filter((action) => action.execution_authority === "HUMAN_REQUIRED_R3") + .map(againstNote) + .find(Boolean) ?? null; + const columnLabel = (action: Record, index: number) => + formatMessage("actionNumber", index + 1, actionFamilyLabel(textValue(action.action_family))); + return ( + <> + {textValue(analysis.decision) && ( +
+

{t("actions.recommended")}

+

{textValue(analysis.decision)}

+ {textValue(analysis.uncertainty) && ( +

+ {t("actions.uncertainty")} +
+ {textValue(analysis.uncertainty)} +

+ )} + {textValue(analysis.sensitivity) && ( +

+ {t("actions.changeConditions")} +
+ {textValue(analysis.sensitivity)} +

+ )} + {textValue(analysis.preference_question) && ( +

+ {t("actions.questions")} +
+ {textValue(analysis.preference_question)} +

+ )} +
+ )} + {projectId && + textValue(plan.plan_id) && + actions.some((action) => action.execution_authority === "HUMAN_REQUIRED_R3") && ( + <> + {approvalNote && ( + + {approvalNote} + + )} + + + )} + {criteria.length > 0 && ( +
+

{t("actions.criteria")}

+
    + {criteria.map((criterion, index) => ( +
  • + {criterion.mandatory === true && ( + + {t("actions.required")} + + )}{" "} + {textValue(criterion.name)} + {textValue(criterion.rationale) && ( + <> +
    + {textValue(criterion.rationale)} + + )} +
  • + ))} +
+ {evaluations.length > 0 && ( + + + + {t("followup.criterion")} + {actions.map((action, index) => ( + + {columnLabel(action, index)} + + ))} + + + + {criteria.map((criterion, index) => ( + + {textValue(criterion.name)} + {actions.map((action, column) => ( + + {evaluations + .filter( + (item) => + item.action_id === action.action_id && + item.criterion_id === criterion.criterion_id, + ) + .map((item, k) => ( +

+ {textValue(item.scenario) && {textValue(item.scenario)}: } + {textValue(item.assessment)} {cite(stringValues(item.evidence_refs))} +

+ ))} + + ))} + + ))} + +
+ )} +
+ )} + {orderedActions(actions).map(({ action, heading, key }) => ( + + {heading &&

{heading}

} +
+

{textValue(action.specification)}

+
+ + + + {reversibilityLabel(textValue(action.reversibility)) ?? + t("actions.reversibilityUnknown")} + + {stateTag(action) && {stateTag(action)}} +
+ {staleNote(action, links) && ( + + {staleNote(action, links)} + + )} + {againstNote(action) && ( + + {againstNote(action)} + + )} +

{textValue(action.expected_information_value)}

+ {projectId && textValue(action.action_id) ? ( + + ) : ( + + )} + {stringValues(action.missing_evidence).map((value, j) => ( +

+ {t("labels.requiredEvidence")} {value} +

+ ))} + {stringValues(action.source_refs).length > 0 && ( +

+ {t("history.evidence")} {cite(stringValues(action.source_refs))} +

+ )} +

+ {action.execution_authority === "HUMAN_REQUIRED_R3" + ? t("actions.protected") + : t("actions.proposalNotice")} +

+ +
+
+ ))} + + ); } diff --git a/apps/web/src/components/ActionCompareApproval.test.tsx b/apps/web/src/components/ActionCompareApproval.test.tsx index c03ebb0a..17392c33 100644 --- a/apps/web/src/components/ActionCompareApproval.test.tsx +++ b/apps/web/src/components/ActionCompareApproval.test.tsx @@ -7,27 +7,80 @@ import excerpt from "../api/fixtures/iris-judgment-excerpt.json"; import { ActionCompare } from "./ActionCompare"; const state = vi.hoisted(() => ({ calls: [] as string[] })); -const auth = { authorization_id: "auth:1", step_id: "step:send", state: "STALE", stale_reason: "MATERIAL_CHANGE", payload: {}, - material_changes: [{ field: "channel", label: "전달 경로", before: "EMAIL", after: "FORM", words: [], attachments: [] }], decision_history: [], created_at: "2026-09-30T00:00:00Z" }; -vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()), +const auth = { + authorization_id: "auth:1", + step_id: "step:send", + state: "STALE", + stale_reason: "MATERIAL_CHANGE", + payload: {}, + material_changes: [ + { + field: "channel", + label: "전달 경로", + before: "EMAIL", + after: "FORM", + words: [], + attachments: [], + }, + ], + decision_history: [], + created_at: "2026-09-30T00:00:00Z", +}; +vi.mock("../api/rpcClient", async (importOriginal) => ({ + ...(await importOriginal()), rpc: async (method: string, input: Record) => { state.calls.push(method); - if (method === "action/plan/read") return { value: { plan: { plan_id: input.plan_id }, authorizations: [auth] }, state: "SUCCEEDED", operation_id: "" }; - if (method === "action/authorization/read") return { value: { authorization: auth, effective_state: "STALE", payload_current: false, review_needed: false }, state: "SUCCEEDED", operation_id: "" }; + if (method === "action/plan/read") + return { + value: { plan: { plan_id: input.plan_id }, authorizations: [auth] }, + state: "SUCCEEDED", + operation_id: "", + }; + if (method === "action/authorization/read") + return { + value: { + authorization: auth, + effective_state: "STALE", + payload_current: false, + review_needed: false, + }, + state: "SUCCEEDED", + operation_id: "", + }; return { value: {}, state: "SUCCEEDED", operation_id: "" }; - } })); + }, +})); -let root: Root | undefined; let node: HTMLDivElement; -afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); state.calls = []; }); +let root: Root | undefined; +let node: HTMLDivElement; +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + node?.remove(); + state.calls = []; +}); async function mount(result: Record, projectId?: string) { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - node = document.createElement("div"); document.body.append(node); root = createRoot(node); + node = document.createElement("div"); + document.body.append(node); + root = createRoot(node); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - await act(async () => root!.render()); - for (let i = 0; i < 8; i++) await act(async () => { await new Promise(r => setTimeout(r, 10)); }); + await act(async () => + root!.render( + + + , + ), + ); + for (let i = 0; i < 8; i++) + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); } const protectedResult = () => { - const result = structuredClone(excerpt) as unknown as { action_plan: { plan_id?: string; alternatives: Record[] } }; + const result = structuredClone(excerpt) as unknown as { + action_plan: { plan_id?: string; alternatives: Record[] }; + }; result.action_plan.plan_id = "plan:1"; result.action_plan.alternatives[0].execution_authority = "HUMAN_REQUIRED_R3"; return result as unknown as Record; @@ -42,7 +95,8 @@ it("shows what changed since approval next to a protected action", async () => { it("does not read approvals when no action needs one or when no project is given", async () => { await mount(protectedResult()); expect(state.calls).not.toContain("action/plan/read"); - await act(async () => root!.unmount()); node.remove(); + await act(async () => root!.unmount()); + node.remove(); const plain = structuredClone(excerpt) as unknown as Record; await mount(plain, "project:p"); expect(state.calls).not.toContain("action/plan/read"); diff --git a/apps/web/src/components/ActionCompareLive.tsx b/apps/web/src/components/ActionCompareLive.tsx index 0202ae09..8057d2c5 100644 --- a/apps/web/src/components/ActionCompareLive.tsx +++ b/apps/web/src/components/ActionCompareLive.tsx @@ -3,8 +3,21 @@ import { useHypothesisLinks } from "./useHypothesisLinks"; import { useDiscrimination } from "./useJudgmentRecords"; /** The next-actions comparison with a mark on each action that rests on a hypothesis whose trace verdict changed. */ -export function ActionCompareLive({ result, projectId }: { result: Record; projectId: string }) { +export function ActionCompareLive({ + result, + projectId, +}: { + result: Record; + projectId: string; +}) { const linked = useHypothesisLinks(projectId); const discrimination = useDiscrimination(projectId); - return ; + return ( + + ); } diff --git a/apps/web/src/components/ActionCompareStale.test.tsx b/apps/web/src/components/ActionCompareStale.test.tsx index 08e413ec..3044e3e3 100644 --- a/apps/web/src/components/ActionCompareStale.test.tsx +++ b/apps/web/src/components/ActionCompareStale.test.tsx @@ -5,16 +5,28 @@ import type { HypothesisLink } from "../api/hypothesisLink"; import { ActionCompare } from "./ActionCompare"; const link = (id: string, patch: Partial = {}): HypothesisLink => ({ - hypothesis_id: id, hypothesis_revision_digest: "d".repeat(64), statement: "s", subject_kind: "CRITERION", subject_id: "C-1", subject_title: "비 조건 탐지율", - state: "STALE", change: "CONTENT_CHANGED", link_state: "HOLD_NO_RESULT", current_state: "FAIL_COMPUTED", current_verdict_revision: "r".repeat(64), recheck: null, ...patch, + hypothesis_id: id, + hypothesis_revision_digest: "d".repeat(64), + statement: "s", + subject_kind: "CRITERION", + subject_id: "C-1", + subject_title: "비 조건 탐지율", + state: "STALE", + change: "CONTENT_CHANGED", + link_state: "HOLD_NO_RESULT", + current_state: "FAIL_COMPUTED", + current_verdict_revision: "r".repeat(64), + recheck: null, + ...patch, }); // The first action rests on h-1, the second on h-2, the third on nothing that has a link. const render = (links?: HypothesisLink[]) => { const result = structuredClone(excerpt) as Record; - const alternatives = (result.action_plan as { alternatives: Record[] }).alternatives; + const alternatives = (result.action_plan as { alternatives: Record[] }) + .alternatives; alternatives[0].hypothesis_ids = ["h-1"]; alternatives[1].hypothesis_ids = ["h-2"]; - return renderToStaticMarkup(); + return renderToStaticMarkup(); }; const NOTE = "이 행동이 기대는 가설"; const cards = (html: string) => html.split('
{ - const found = cards(render([link("h-1", { change: "EVIDENCE_ONLY", link_state: "PASS_COMPUTED", current_state: "PASS_COMPUTED" }), link("h-2", { state: "RECHECKED" })])); + const found = cards( + render([ + link("h-1", { + change: "EVIDENCE_ONLY", + link_state: "PASS_COMPUTED", + current_state: "PASS_COMPUTED", + }), + link("h-2", { state: "RECHECKED" }), + ]), + ); expect(found[0]).toContain("판정 상태는 같고 근거만 바뀜"); expect(found[1]).not.toContain(NOTE); }); diff --git a/apps/web/src/components/ActionEffort.test.tsx b/apps/web/src/components/ActionEffort.test.tsx index 910c56a6..8e36de19 100644 --- a/apps/web/src/components/ActionEffort.test.tsx +++ b/apps/web/src/components/ActionEffort.test.tsx @@ -5,31 +5,79 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vitest"; import { ActionEffort } from "./ActionEffort"; -const state = vi.hoisted(() => ({ estimates: [] as unknown[], running: false, readFails: false, calls: [] as { method: string; input: Record }[] })); -vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()), +const state = vi.hoisted(() => ({ + estimates: [] as unknown[], + running: false, + readFails: false, + calls: [] as { method: string; input: Record }[], +})); +vi.mock("../api/rpcClient", async (importOriginal) => ({ + ...(await importOriginal()), rpc: async (method: string, input: Record) => { state.calls.push({ method, input }); if (method === "action/read") { if (state.readFails) throw new Error("synthetic read failure"); - return { value: { research_running: state.running, action: { revision_digest: "a".repeat(64), generation_details: { effort_estimates: state.estimates } } }, state: "SUCCEEDED", operation_id: "" }; + return { + value: { + research_running: state.running, + action: { + revision_digest: "a".repeat(64), + generation_details: { effort_estimates: state.estimates }, + }, + }, + state: "SUCCEEDED", + operation_id: "", + }; } - return { value: { action: { revision_digest: "b".repeat(64) } }, state: "SUCCEEDED", operation_id: "" }; - } })); + return { + value: { action: { revision_digest: "b".repeat(64) } }, + state: "SUCCEEDED", + operation_id: "", + }; + }, +})); -let root: Root | undefined; let container: HTMLDivElement; -const ai = { dimension: "TIME", band: "MEDIUM", estimator_type: "AI", estimator_ref: "ACTION_PLANNER", basis_text: "담당자 회신이 필요", assumptions: [], created_at: "2026-09-30T00:00:00Z" }; +let root: Root | undefined; +let container: HTMLDivElement; +const ai = { + dimension: "TIME", + band: "MEDIUM", + estimator_type: "AI", + estimator_ref: "ACTION_PLANNER", + basis_text: "담당자 회신이 필요", + assumptions: [], + created_at: "2026-09-30T00:00:00Z", +}; async function mount(initial: unknown[] = [ai]) { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - container = document.createElement("div"); document.body.append(container); root = createRoot(container); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - await act(async () => root!.render()); - for (let i = 0; i < 10; i++) await act(async () => { await new Promise(r => setTimeout(r, 10)); }); + await act(async () => + root!.render( + + + , + ), + ); + for (let i = 0; i < 10; i++) + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); +} +async function open() { + await act(async () => + ( + container.querySelector("button[aria-label='사람 추정 입력 열기']") as HTMLButtonElement + ).click(), + ); } -async function open() { await act(async () => (container.querySelector("button[aria-label='사람 추정 입력 열기']") as HTMLButtonElement).click()); } async function pick(label: string, value: string) { await act(async () => { const select = container.querySelector(`select[aria-label='${label}']`)!; - select.value = value; select.dispatchEvent(new Event("change", { bubbles: true })); + select.value = value; + select.dispatchEvent(new Event("change", { bubbles: true })); }); } async function type(label: string, value: string) { @@ -39,20 +87,43 @@ async function type(label: string, value: string) { input.dispatchEvent(new Event("input", { bubbles: true })); }); } -afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); state.estimates = []; state.running = false; state.readFails = false; state.calls = []; }); +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + container?.remove(); + state.estimates = []; + state.running = false; + state.readFails = false; + state.calls = []; +}); it("turns the human estimate button off with a reason while an investigation runs", async () => { state.running = true; await mount(); - const button = container.querySelector("button[aria-label='사람 추정 입력 열기']")!; + const button = container.querySelector( + "button[aria-label='사람 추정 입력 열기']", + )!; expect(button.disabled).toBe(true); expect(container.textContent).toContain("조사가 끝난 뒤 제안할 수 있습니다"); }); it("reads the current record so an estimate added after the result still shows", async () => { - state.estimates = [ai, { ...ai, band: "HIGH", estimator_type: "HUMAN", estimator_ref: "human:local-user", basis_text: "현장 시험", created_at: "2026-09-30T01:00:00Z" }]; + state.estimates = [ + ai, + { + ...ai, + band: "HIGH", + estimator_type: "HUMAN", + estimator_ref: "human:local-user", + basis_text: "현장 시험", + created_at: "2026-09-30T01:00:00Z", + }, + ]; await mount([ai]); - expect(state.calls[0]).toEqual({ method: "action/read", input: { project_id: "project:p", action_id: "action:1" } }); + expect(state.calls[0]).toEqual({ + method: "action/read", + input: { project_id: "project:p", action_id: "action:1" }, + }); expect(container.textContent).toContain("AI 추정: 보통"); expect(container.textContent).toContain("사람 추정: 김"); }); @@ -66,11 +137,20 @@ it("falls back to the result's own estimates when the record cannot be read", as it("sends a person's band with its basis as a new revision bound to the record it read", async () => { await mount([ai]); await open(); - await pick("시간 추정", "HIGH"); await type("시간 근거", "현장 시험이 필요"); - await act(async () => (container.querySelector("button[aria-label='사람 추정 저장']") as HTMLButtonElement).click()); - const revise = state.calls.find(call => call.method === "action/revise")!; - expect(revise.input).toMatchObject({ project_id: "project:p", action_id: "action:1", expected_revision_digest: "a".repeat(64), patch: {}, - estimator_ref: "human:local-user", human_effort_estimates: [{ dimension: "TIME", band: "HIGH", basis_text: "현장 시험이 필요" }] }); + await pick("시간 추정", "HIGH"); + await type("시간 근거", "현장 시험이 필요"); + await act(async () => + (container.querySelector("button[aria-label='사람 추정 저장']") as HTMLButtonElement).click(), + ); + const revise = state.calls.find((call) => call.method === "action/revise")!; + expect(revise.input).toMatchObject({ + project_id: "project:p", + action_id: "action:1", + expected_revision_digest: "a".repeat(64), + patch: {}, + estimator_ref: "human:local-user", + human_effort_estimates: [{ dimension: "TIME", band: "HIGH", basis_text: "현장 시험이 필요" }], + }); expect(JSON.stringify(revise.input)).not.toMatch(/"band":\s*[0-9]/); }); @@ -78,7 +158,9 @@ it("does not send a band without a basis sentence", async () => { await mount([ai]); await open(); await pick("비용·품 추정", "LOW"); - await act(async () => (container.querySelector("button[aria-label='사람 추정 저장']") as HTMLButtonElement).click()); - expect(state.calls.some(call => call.method === "action/revise")).toBe(false); + await act(async () => + (container.querySelector("button[aria-label='사람 추정 저장']") as HTMLButtonElement).click(), + ); + expect(state.calls.some((call) => call.method === "action/revise")).toBe(false); expect(container.textContent).toContain("근거 한 줄을 적어 주세요"); }); diff --git a/apps/web/src/components/ActionEffort.tsx b/apps/web/src/components/ActionEffort.tsx index 68d03155..f32f6535 100644 --- a/apps/web/src/components/ActionEffort.tsx +++ b/apps/web/src/components/ActionEffort.tsx @@ -11,13 +11,20 @@ import { objectValue, textValue } from "../api/presentation"; import { rpc } from "../api/rpcClient"; import { parseEstimates, type Estimate } from "./effortEstimates"; import { researchRunningNotice } from "./memoryEdit"; -import { effortBandLabel, effortBandOptions, effortDimensionLabel, estimatorLabel } from "./statusLabels"; +import { + effortBandLabel, + effortBandOptions, + effortDimensionLabel, + estimatorLabel, +} from "./statusLabels"; const DIMENSIONS = ["TIME", "COST_EFFORT"] as const; const HUMAN_REF = "human:local-user"; const latest = (estimates: Estimate[], dimension: string, type: string) => - [...estimates].reverse().find(item => item.dimension === dimension && item.estimator_type === type); + [...estimates] + .reverse() + .find((item) => item.dimension === dimension && item.estimator_type === type); function DimensionLine({ dimension, estimates }: { dimension: string; estimates: Estimate[] }) { const ai = latest(estimates, dimension, "AI"); @@ -25,73 +32,239 @@ function DimensionLine({ dimension, estimates }: { dimension: string; estimates: const name = effortDimensionLabel(dimension); const band = (item: Estimate) => effortBandLabel(dimension, item.band); if (ai && human && ai.band !== human.band) { - return

{name}: {estimatorLabel("AI")}: {band(ai)} / {estimatorLabel("HUMAN")}: {band(human)} - {human.basis_text && · {human.basis_text}}{ai.basis_text && {" "}{t("labels.aiBasis")}{" "}{ai.basis_text}}

; + return ( +

+ {name}:{" "} + + {estimatorLabel("AI")}: {band(ai)} + {" "} + /{" "} + + {estimatorLabel("HUMAN")}: {band(human)} + + {human.basis_text && · {human.basis_text}} + {ai.basis_text && ( + + {" "} + {t("labels.aiBasis")} {ai.basis_text} + + )} +

+ ); } if (ai && human) { - return

{name}: {band(human)}{" "}{t("labels.estimatesAgree")}{human.basis_text && · {human.basis_text}}

; + return ( +

+ {name}: {band(human)} {t("labels.estimatesAgree")} + {human.basis_text && · {human.basis_text}} +

+ ); } const only = ai ?? human; - if (!only || only.band === "UNKNOWN") return

{name}{t("labels.unknownCheck")}{only?.basis_text && · {only.basis_text}}

; - return

{name}: {band(only)} · {estimatorLabel(only.estimator_type)}{only.basis_text && <> · {only.basis_text}}

; + if (!only || only.band === "UNKNOWN") + return ( +

+ {name} + {t("labels.unknownCheck")} + {only?.basis_text && · {only.basis_text}} +

+ ); + return ( +

+ {name}: {band(only)} · {estimatorLabel(only.estimator_type)} + {only.basis_text && <> · {only.basis_text}} +

+ ); } /** Time and cost-effort bands for one action. Bands are labels only; nothing here adds, ranks or converts them. */ export function EffortLines({ estimates, legacy }: { estimates: Estimate[]; legacy?: string }) { - if (estimates.length === 0) return

{t("labels.costTime")}{" "}{legacy ?? t("state.unknown")}

; - return
{DIMENSIONS.map(dimension => )}
; + if (estimates.length === 0) + return ( +

+ {t("labels.costTime")} {legacy ?? t("state.unknown")} +

+ ); + return ( +
+ {DIMENSIONS.map((dimension) => ( + + ))} +
+ ); } type Draft = Record; -const emptyDraft = (): Draft => ({ TIME: { band: "", basis: "" }, COST_EFFORT: { band: "", basis: "" } }); +const emptyDraft = (): Draft => ({ + TIME: { band: "", basis: "" }, + COST_EFFORT: { band: "", basis: "" }, +}); /** Shows the record's current estimates and lets a person add their own as a new revision. */ -export function ActionEffort({ projectId, actionId, initial, legacy }: { projectId: string; actionId: string; initial: unknown; legacy?: string }) { +export function ActionEffort({ + projectId, + actionId, + initial, + legacy, +}: { + projectId: string; + actionId: string; + initial: unknown; + legacy?: string; +}) { useTranslation(); const client = useQueryClient(); const ownership = useDraftGeneration(JSON.stringify([projectId, actionId])); const key = ["action-effort", projectId, actionId]; - const record = useQuery({ queryKey: key, retry: false, - queryFn: ({ signal }) => rpc<{ action: Record; research_running?: boolean }>("action/read", { project_id: projectId, action_id: actionId }, crypto.randomUUID(), signal), - refetchInterval: current => current.state.data?.value.research_running ? 3000 : false }); + const record = useQuery({ + queryKey: key, + retry: false, + queryFn: ({ signal }) => + rpc<{ action: Record; research_running?: boolean }>( + "action/read", + { project_id: projectId, action_id: actionId }, + crypto.randomUUID(), + signal, + ), + refetchInterval: (current) => (current.state.data?.value.research_running ? 3000 : false), + }); const running = record.data?.value.research_running === true; const blocked = useReadAccessProtection(key, record.error); const action = objectValue(record.data?.value.action); const details = objectValue(action.generation_details); - const estimates = parseEstimates(record.data && Array.isArray(details.effort_estimates) ? details.effort_estimates : initial); + const estimates = parseEstimates( + record.data && Array.isArray(details.effort_estimates) ? details.effort_estimates : initial, + ); const digest = textValue(action.revision_digest); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(emptyDraft); const [message, setMessage] = useState(""); const save = useMutation({ - mutationFn: ({ entries }: { entries: { dimension: string; band: string; basis_text: string }[]; ticket: DraftTicket }) => rpc("action/revise", { - project_id: projectId, action_id: actionId, expected_revision_digest: digest, patch: {}, evidence_refs: [], - get reason() { return t("effort.humanEntry"); }, estimator_ref: HUMAN_REF, human_effort_estimates: entries }, crypto.randomUUID()), - onSuccess: async (_result, input) => { if (ownership.isCurrent(input.ticket)) { ownership.advance(); setDraft(emptyDraft()); setOpen(false); setMessage(""); } await client.invalidateQueries({ queryKey: key }); }, + mutationFn: ({ + entries, + }: { + entries: { dimension: string; band: string; basis_text: string }[]; + ticket: DraftTicket; + }) => + rpc( + "action/revise", + { + project_id: projectId, + action_id: actionId, + expected_revision_digest: digest, + patch: {}, + evidence_refs: [], + get reason() { + return t("effort.humanEntry"); + }, + estimator_ref: HUMAN_REF, + human_effort_estimates: entries, + }, + crypto.randomUUID(), + ), + onSuccess: async (_result, input) => { + if (ownership.isCurrent(input.ticket)) { + ownership.advance(); + setDraft(emptyDraft()); + setOpen(false); + setMessage(""); + } + await client.invalidateQueries({ queryKey: key }); + }, }); const submit = () => { - const entries = DIMENSIONS.filter(dimension => draft[dimension].band).map(dimension => ({ dimension, band: draft[dimension].band, basis_text: draft[dimension].basis.trim() })); - if (entries.length === 0) { setMessage("effort.choose"); return; } - if (entries.some(entry => entry.band !== "UNKNOWN" && !entry.basis_text)) { setMessage("effort.basisNeeded"); return; } - setMessage(""); save.mutate({ entries, ticket: ownership.capture() }); + const entries = DIMENSIONS.filter((dimension) => draft[dimension].band).map((dimension) => ({ + dimension, + band: draft[dimension].band, + basis_text: draft[dimension].basis.trim(), + })); + if (entries.length === 0) { + setMessage("effort.choose"); + return; + } + if (entries.some((entry) => entry.band !== "UNKNOWN" && !entry.basis_text)) { + setMessage("effort.basisNeeded"); + return; + } + setMessage(""); + save.mutate({ entries, ticket: ownership.capture() }); + }; + const set = (dimension: string, part: "band" | "basis", value: string) => { + ownership.advance(); + setDraft((current) => ({ ...current, [dimension]: { ...current[dimension], [part]: value } })); }; - const set = (dimension: string, part: "band" | "basis", value: string) => { ownership.advance(); setDraft(current => ({ ...current, [dimension]: { ...current[dimension], [part]: value } })); }; - if (blocked) return {t("records.failed")}; - return
- - - {running && {researchRunningNotice}} - {open &&
- {DIMENSIONS.map(dimension =>
- + set(dimension, "basis", event.target.value)} + /> +
+ ))} + {t("effort.preserveAI")} + + {message &&

{t(message)}

} + {save.error && ( +

+ {t("effort.failed")} {save.error.message} +

+ )} +
+ )} +
+ ); } diff --git a/apps/web/src/components/AnswerBody.tsx b/apps/web/src/components/AnswerBody.tsx index 4b40ee13..148bca6f 100644 --- a/apps/web/src/components/AnswerBody.tsx +++ b/apps/web/src/components/AnswerBody.tsx @@ -4,25 +4,61 @@ import { Fragment, useMemo } from "react"; import { parseAnswer, splitAnswerLead, type Inline } from "./answerText"; /** The answer as a hold lead line, headings, paragraphs, lists and bold text, with each source shown as a numbered button. */ -export function AnswerBody({ answer, onCite, language }: { +export function AnswerBody({ + answer, + onCite, + language, +}: { answer: string; language?: ResponseLanguage | null; onCite: (spanId: string, numbers: Record) => void; }) { const { lead, body } = useMemo(() => splitAnswerLead(answer), [answer]); const parsed = useMemo(() => parseAnswer(body), [body]); - const numbers = useMemo(() => Object.fromEntries(parsed.citations.map(item => [item.spanId, item.n])), [parsed]); - const render = (items: Inline[]) => items.map((item, index) => { - const body = item.kind === "text" - ? item.text - : ; - return {item.bold ? {body} : body}; - }); - return
- {lead &&

{lead}

} - {parsed.blocks.map((block, index) => block.kind === "ul" - ?
    {block.items.map((item, itemIndex) =>
  • {render(item)}
  • )}
- : block.heading ?

{render(block.inline)}

- :

{render(block.inline)}

)} -
; + const numbers = useMemo( + () => Object.fromEntries(parsed.citations.map((item) => [item.spanId, item.n])), + [parsed], + ); + const render = (items: Inline[]) => + items.map((item, index) => { + const body = + item.kind === "text" ? ( + item.text + ) : ( + + ); + return {item.bold ? {body} : body}; + }); + return ( +
+ {lead && ( +

+ {lead} +

+ )} + {parsed.blocks.map((block, index) => + block.kind === "ul" ? ( +
    + {block.items.map((item, itemIndex) => ( +
  • {render(item)}
  • + ))} +
+ ) : block.heading ? ( +

+ {render(block.inline)} +

+ ) : ( +

{render(block.inline)}

+ ), + )} +
+ ); } diff --git a/apps/web/src/components/AnswerCitations.test.tsx b/apps/web/src/components/AnswerCitations.test.tsx index 8f2bed15..632e4655 100644 --- a/apps/web/src/components/AnswerCitations.test.tsx +++ b/apps/web/src/components/AnswerCitations.test.tsx @@ -12,7 +12,10 @@ import type { PackRunResult } from "../types"; import { evidenceResponse, manifest } from "../api/resultEvidenceTestFixture"; import { webcrypto } from "node:crypto"; -vi.mock("../api/rpcClient", async original => ({ ...await original(), rpc: vi.fn() })); +vi.mock("../api/rpcClient", async (original) => ({ + ...(await original()), + rpc: vi.fn(), +})); let root: Root | undefined; let node: HTMLDivElement; let client: QueryClient; @@ -22,26 +25,55 @@ const scrolled = vi.fn(); afterEach(async () => { if (root) await act(async () => root!.unmount()); - root = undefined; node?.remove(); client?.clear(); vi.resetAllMocks(); scrolled.mockReset(); + root = undefined; + node?.remove(); + client?.clear(); + vi.resetAllMocks(); + scrolled.mockReset(); }); function Flow({ result }: { result: Record }) { const [detail, setDetail] = useState(null); - return <> - {detail && setDetail(null)}/>}; + return ( + <> + + {detail && ( + setDetail(null)} + /> + )} + + ); } async function mount(node2: React.ReactElement) { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); Object.defineProperty(crypto, "subtle", { value: webcrypto.subtle, configurable: true }); Element.prototype.scrollIntoView = scrolled; client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - node = document.createElement("div"); document.body.append(node); root = createRoot(node); - await act(async () => root!.render({node2})); + node = document.createElement("div"); + document.body.append(node); + root = createRoot(node); + await act(async () => + root!.render({node2}), + ); } async function settle(check: () => boolean) { for (let i = 0; i < 200; i++) { - await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); if (check()) return; } throw new Error(`not rendered: ${node.textContent?.slice(-500)}`); @@ -50,21 +82,37 @@ const cites = () => Array.from(node.querySelectorAll("button. it("shows numbered citations and bold text instead of internal ids and asterisks", async () => { const opened: ResearchDetail[] = []; - await mount( opened.push(detail)} - historySelection={{ kind: "result", scope: fixture.scope, operationId: fixture.operationId }}/>); + await mount( + opened.push(detail)} + historySelection={{ kind: "result", scope: fixture.scope, operationId: fixture.operationId }} + />, + ); const text = node.querySelector(".answer-text")!.textContent!; expect(text).not.toContain("span:"); expect(text).not.toContain("**"); expect(text).toContain("[1]"); // the hold lead is its own bold line; the first bold run of the body text follows it - expect(node.querySelector(".answer-text .answer-lead strong")?.textContent).toBe("검증 보류 · 부분 초안:"); - expect(node.querySelector(".answer-text p:not(.answer-lead) strong")?.textContent).toContain("1세부"); + expect(node.querySelector(".answer-text .answer-lead strong")?.textContent).toBe( + "검증 보류 · 부분 초안:", + ); + expect(node.querySelector(".answer-text p:not(.answer-lead) strong")?.textContent).toContain( + "1세부", + ); const first = cites()[0]; expect(first.textContent).toBe("[1]"); // Neither the tooltip nor the name read aloud carries the range id; both say what the button does. expect(first.title).toBe("근거 1 보기"); expect(first.getAttribute("aria-label")).toBe("근거 1 보기"); - expect([...node.querySelectorAll("[title], [aria-label]")].filter(element => /span:/.test((element.getAttribute("title") ?? "") + (element.getAttribute("aria-label") ?? "")))).toEqual([]); + expect( + [...node.querySelectorAll("[title], [aria-label]")].filter((element) => + /span:/.test( + (element.getAttribute("title") ?? "") + (element.getAttribute("aria-label") ?? ""), + ), + ), + ).toEqual([]); await act(async () => first.click()); expect(opened).toHaveLength(1); expect(opened[0].kind).toBe("evidence"); @@ -73,20 +121,35 @@ it("shows numbered citations and bold text instead of internal ids and asterisks }); it("keeps one number for a source cited twice", async () => { - await mount( {}}/>); - expect(cites().map(item => item.textContent)).toEqual(["[1]", "[2]", "[1]"]); + await mount( + {}} + />, + ); + expect(cites().map((item) => item.textContent)).toEqual(["[1]", "[2]", "[1]"]); }); function serve(result: Record) { - vi.mocked(rpc).mockImplementation(async (_method, args) => evidenceResponse(Number(args.selected_evidence_offset ?? 0), args.include_selected_evidence === true, result)); + vi.mocked(rpc).mockImplementation(async (_method, args) => + evidenceResponse( + Number(args.selected_evidence_offset ?? 0), + args.include_selected_evidence === true, + result, + ), + ); } -const cited = (id: string) => ({ ...fixture.result, answer: `이 문장은 근거에 기대어 있습니다 (${id}).` }); +const cited = (id: string) => ({ + ...fixture.result, + answer: `이 문장은 근거에 기대어 있습니다 (${id}).`, +}); it("moves to the cited source card in the evidence panel and highlights it", async () => { const target = manifest.source_refs[10]; const result = cited(target); serve(result); - await mount(); + await mount(); await act(async () => cites()[0].click()); await settle(() => Boolean(node.querySelector('.evidence-item[data-span-id="' + target + '"]'))); const card = node.querySelector('.evidence-item[data-span-id="' + target + '"]')!; @@ -99,7 +162,7 @@ it("moves to the cited source card in the evidence panel and highlights it", asy it("reads later pages to find a cited source whose original text cannot be shown", async () => { const result = cited(manifest.source_refs[120]); serve(result); - await mount(); + await mount(); await act(async () => cites()[0].click()); await settle(() => Boolean(node.textContent?.includes("이 근거는 원문을 표시할 수 없습니다"))); expect(node.textContent).toContain("전체 149개 중 149개 확인"); @@ -108,7 +171,7 @@ it("reads later pages to find a cited source whose original text cannot be shown it("says so when the cited source is not in the answer's evidence list", async () => { const result = cited("span:no-such-source-0001"); serve(result); - await mount(); + await mount(); await act(async () => cites()[0].click()); await settle(() => Boolean(node.textContent?.includes("이 근거를 목록에서 찾지 못했습니다"))); expect(node.textContent).toContain("전체 149개 중 149개 확인"); diff --git a/apps/web/src/components/AnswerHold.tsx b/apps/web/src/components/AnswerHold.tsx index 48457081..75d68355 100644 --- a/apps/web/src/components/AnswerHold.tsx +++ b/apps/web/src/components/AnswerHold.tsx @@ -13,14 +13,34 @@ import { EvidenceUseSummary } from "./EvidenceUseSummary"; * An earlier round never gets its checks (the server sends them for the latest request only), so it says that * instead of "not yet". */ -export function AnswerHold({ status, coverage, facts, earlierRound }: { - status: string; coverage?: CoverageMatrix | null; facts?: ReactNode; earlierRound?: boolean; +export function AnswerHold({ + status, + coverage, + facts, + earlierRound, +}: { + status: string; + coverage?: CoverageMatrix | null; + facts?: ReactNode; + earlierRound?: boolean; }) { const { rows, total } = coverageCounts(coverage); - return - {facts} - {rows.length > 0 && } - {total === 0 &&

{t("labels.answerScope")}{" "}{t(earlierRound ? "answerHold.coverageEarlier" : "answerHold.coverageMissing")}

} -
; + return ( + + {facts} + {rows.length > 0 && } + {total === 0 && ( +

+ {t("labels.answerScope")}{" "} + {t(earlierRound ? "answerHold.coverageEarlier" : "answerHold.coverageMissing")} +

+ )} +
+ ); } - diff --git a/apps/web/src/components/AnswerReadability.test.tsx b/apps/web/src/components/AnswerReadability.test.tsx index 9e3aca65..42813bd6 100644 --- a/apps/web/src/components/AnswerReadability.test.tsx +++ b/apps/web/src/components/AnswerReadability.test.tsx @@ -7,22 +7,73 @@ import { criterionText, plainReason, splitReadable } from "./plainWording"; const digest = "a".repeat(64); const row = (target: string, question: string, extra: Record = {}) => ({ - requirement_id: "r-" + target, target, question, status: "UNRESOLVED" as const, applicability: "APPLICABLE", - relation: "INSUFFICIENT", validation: "INCONCLUSIVE", blocker: "answer:HOLD", review_refs: [], evidence_refs: [], reason_codes: ["answer:HOLD"], ...extra }); -const coverage = (rows: ReturnType[]) => ({ schema_version: "1.0.0" as const, request_revision_digest: digest, availability: "AVAILABLE" as const, - reason_codes: [], requirement_set_revision_digest: null, coverage_revision_digest: null, rows, - summary: { satisfied: 0, unresolved: rows.length, not_applicable: 0, not_assessed: 0, hold_targets: [], reason_codes: [] } }); -const progress = (remaining: string[], unknowns: string[] = []) => ({ schema_version: "1.0.0" as const, request_revision_digest: digest, result_revision_digest: digest, - state: "HOLD" as const, currentness: { state: "CURRENT" as const, reasons: [], execution_eligible: true }, progress_items: [], recorded_checks: [], - remaining_gaps: remaining, unknowns, next_user_action: { schema_version: "1.0.0" as const, action_type: "REVIEW_GAPS" as const, label: "review", reason_codes: [], requires_permission: false, target: null, basis: {} } }); -const visible = (html: string) => html.replace(//g, "").replace(/<[^>]+>/g, " ").replace(/'/g, "\u0027"); + requirement_id: "r-" + target, + target, + question, + status: "UNRESOLVED" as const, + applicability: "APPLICABLE", + relation: "INSUFFICIENT", + validation: "INCONCLUSIVE", + blocker: "answer:HOLD", + review_refs: [], + evidence_refs: [], + reason_codes: ["answer:HOLD"], + ...extra, +}); +const coverage = (rows: ReturnType[]) => ({ + schema_version: "1.0.0" as const, + request_revision_digest: digest, + availability: "AVAILABLE" as const, + reason_codes: [], + requirement_set_revision_digest: null, + coverage_revision_digest: null, + rows, + summary: { + satisfied: 0, + unresolved: rows.length, + not_applicable: 0, + not_assessed: 0, + hold_targets: [], + reason_codes: [], + }, +}); +const progress = (remaining: string[], unknowns: string[] = []) => ({ + schema_version: "1.0.0" as const, + request_revision_digest: digest, + result_revision_digest: digest, + state: "HOLD" as const, + currentness: { state: "CURRENT" as const, reasons: [], execution_eligible: true }, + progress_items: [], + recorded_checks: [], + remaining_gaps: remaining, + unknowns, + next_user_action: { + schema_version: "1.0.0" as const, + action_type: "REVIEW_GAPS" as const, + label: "review", + reason_codes: [], + requires_permission: false, + target: null, + basis: {}, + }, +}); +const visible = (html: string) => + html + .replace(//g, "") + .replace(/<[^>]+>/g, " ") + .replace(/'/g, "\u0027"); const render = (result: Record, extra: Record = {}) => - renderToStaticMarkup( {}} {...extra}/>); + renderToStaticMarkup( + {}} {...extra} />, + ); const REASONS = [ - "criteria exist but official evaluator inputs are incomplete", "no cutoff-eligible evidence exists", - "comparison conditions have not been confirmed", "counterevidence has not been checked", - "measurement implementation is not verified", "criterion semantics still require expert confirmation", + "criteria exist but official evaluator inputs are incomplete", + "no cutoff-eligible evidence exists", + "comparison conditions have not been confirmed", + "counterevidence has not been checked", + "measurement implementation is not verified", + "criterion semantics still require expert confirmation", ]; describe("the sentences the program writes about missing checks", () => { @@ -37,11 +88,16 @@ describe("the sentences the program writes about missing checks", () => { it("keeps Korean text, and sets an unknown English sentence aside as technical", () => { expect(splitReadable(["외부 검증 필요", REASONS[0], "some new english reason", "42"])).toEqual({ - plain: ["외부 검증 필요", plainReason(REASONS[0]), "42"], technical: ["some new english reason"] }); + plain: ["외부 검증 필요", plainReason(REASONS[0]), "42"], + technical: ["some new english reason"], + }); }); it("words the built-in criterion targets and keeps ids out of the title", () => { - const known = criterionText("answer", "Requested target, fields, source locator and adjacent context"); + const known = criterionText( + "answer", + "Requested target, fields, source locator and adjacent context", + ); expect(known.title).toMatch(/[가-힣]/); expect(known.question).toMatch(/[가-힣]/); expect(known.question).not.toMatch(/[A-Za-z]{3,}/); @@ -57,8 +113,11 @@ describe("the sentences the program writes about missing checks", () => { describe("the answer screen", () => { it("shows new English prose in a Korean interface when the saved request selected English", () => { - const result = {response_language: "en", answer: "The source says ‘온도’ and records 12 ms.", - assessment: {missing_items: ["An independent measurement is still needed."]}}; + const result = { + response_language: "en", + answer: "The source says ‘온도’ and records 12 ms.", + assessment: { missing_items: ["An independent measurement is still needed."] }, + }; const before = JSON.stringify(result); const text = visible(render(result)); expect(text).toContain("An independent measurement is still needed."); @@ -68,16 +127,27 @@ describe("the answer screen", () => { }); it("shows no internal wording outside the folded technical information", () => { - const html = render({ answer: "a", answer_status: "PARTIAL_HOLD", assessment: { missing_items: REASONS } }, { - progressSummary: progress([REASONS[1]], [REASONS[2]]), - coverageMatrix: coverage([ - row("answer", "Requested target, fields, source locator and adjacent context"), - row("time", "Requested time and source applicability"), - row("SYN-TC-RAIN", "연결 자료에 비 결과가 있는가?"), - ]), - }); + const html = render( + { answer: "a", answer_status: "PARTIAL_HOLD", assessment: { missing_items: REASONS } }, + { + progressSummary: progress([REASONS[1]], [REASONS[2]]), + coverageMatrix: coverage([ + row("answer", "Requested target, fields, source locator and adjacent context"), + row("time", "Requested time and source applicability"), + row("SYN-TC-RAIN", "연결 자료에 비 결과가 있는가?"), + ]), + }, + ); const text = visible(html); - for (const english of [...REASONS, "Requested target", "Requested time", "INSUFFICIENT", "SYN-TC-RAIN", "answer", "gap"]) { + for (const english of [ + ...REASONS, + "Requested target", + "Requested time", + "INSUFFICIENT", + "SYN-TC-RAIN", + "answer", + "gap", + ]) { expect(text, english).not.toContain(english); } expect(text).toContain("남은 확인 사항"); @@ -92,8 +162,13 @@ describe("the answer screen", () => { describe("what the program records as still open", () => { afterEach(() => setLocale("ko")); const ASKED = "LAB-42의 A 조건이 목표에 미달한 이유는 무엇인가?"; - const RECORDED = ["Resolve " + ASKED, "Resolve Discriminating predictions and counterevidence", - "Resolve Applicable test profile before causal promotion", "Resolve LAB-42", "Resolve detection_rate"]; + const RECORDED = [ + "Resolve " + ASKED, + "Resolve Discriminating predictions and counterevidence", + "Resolve Applicable test profile before causal promotion", + "Resolve LAB-42", + "Resolve detection_rate", + ]; it("names an open built-in criterion by its title and the open question as it was asked, without the program's English verb", () => { const { plain, sentences, codes, technical } = splitItems(RECORDED, "ko"); @@ -113,12 +188,19 @@ describe("what the program records as still open", () => { setLocale("ja"); expect(splitItems([RECORDED[1]], "ja").plain).toEqual(["反証の根拠"]); setLocale("en"); - expect(splitItems([RECORDED[1], RECORDED[2]], "en").plain).toEqual(["Counterevidence", "Test to apply"].map((_, index) => splitItems([RECORDED[index + 1]], "en").plain[0])); + expect(splitItems([RECORDED[1], RECORDED[2]], "en").plain).toEqual( + ["Counterevidence", "Test to apply"].map( + (_, index) => splitItems([RECORDED[index + 1]], "en").plain[0], + ), + ); for (const line of splitItems(RECORDED, "en").plain) expect(line).not.toMatch(/^Resolve /); }); it("keeps the recorded lines in the card's folded technical block and nowhere else", () => { - const html = render({ answer: "a", answer_status: "PARTIAL_HOLD" }, { progressSummary: progress(RECORDED) }); + const html = render( + { answer: "a", answer_status: "PARTIAL_HOLD" }, + { progressSummary: progress(RECORDED) }, + ); expect(visible(html)).not.toContain("Resolve"); for (const line of RECORDED) expect(html, line).toContain(line); }); diff --git a/apps/web/src/components/ApprovalCard.test.tsx b/apps/web/src/components/ApprovalCard.test.tsx index 0e7612a4..f2412b0d 100644 --- a/apps/web/src/components/ApprovalCard.test.tsx +++ b/apps/web/src/components/ApprovalCard.test.tsx @@ -7,65 +7,185 @@ import { ApprovalCard } from "./ApprovalCard"; import { ApprovalSection } from "./ApprovalSection"; import { parseApproval } from "./approvalView"; -const state = vi.hoisted(() => ({ authorizations: [] as Record[], planFails: false, calls: [] as { method: string; input: Record }[] })); -vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()), +const state = vi.hoisted(() => ({ + authorizations: [] as Record[], + planFails: false, + calls: [] as { method: string; input: Record }[], +})); +vi.mock("../api/rpcClient", async (importOriginal) => ({ + ...(await importOriginal()), rpc: async (method: string, input: Record) => { state.calls.push({ method, input }); if (method === "action/plan/read") { if (state.planFails) throw new Error("synthetic plan failure"); - return { value: { plan: { plan_id: input.plan_id, revision_digest: "e".repeat(64) }, authorizations: state.authorizations }, state: "SUCCEEDED", operation_id: "" }; + return { + value: { + plan: { plan_id: input.plan_id, revision_digest: "e".repeat(64) }, + authorizations: state.authorizations, + }, + state: "SUCCEEDED", + operation_id: "", + }; } if (method === "action/authorization/read") { - const item = state.authorizations.find(entry => entry.authorization_id === input.authorization_id)!; - return { value: { authorization: item, effective_state: item.state, payload_current: item.state === "APPROVED", review_needed: false }, state: "SUCCEEDED", operation_id: "" }; + const item = state.authorizations.find( + (entry) => entry.authorization_id === input.authorization_id, + )!; + return { + value: { + authorization: item, + effective_state: item.state, + payload_current: item.state === "APPROVED", + review_needed: false, + }, + state: "SUCCEEDED", + operation_id: "", + }; } if (method === "action/authorization/prepare") { - state.authorizations = [...state.authorizations, { ...state.authorizations[0], authorization_id: "auth:2", state: "PENDING", stale_reason: null, material_changes: [], - decision_history: [], created_at: "2026-09-30T02:00:00Z" }]; + state.authorizations = [ + ...state.authorizations, + { + ...state.authorizations[0], + authorization_id: "auth:2", + state: "PENDING", + stale_reason: null, + material_changes: [], + decision_history: [], + created_at: "2026-09-30T02:00:00Z", + }, + ]; } return { value: {}, state: "SUCCEEDED", operation_id: "" }; - } })); + }, +})); -let root: Root | undefined; let node: HTMLDivElement; -afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); state.authorizations = []; state.planFails = false; state.calls = []; }); +let root: Root | undefined; +let node: HTMLDivElement; +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + node?.remove(); + state.authorizations = []; + state.planFails = false; + state.calls = []; +}); async function mount(element: React.ReactElement) { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - node = document.createElement("div"); document.body.append(node); root = createRoot(node); + node = document.createElement("div"); + document.body.append(node); + root = createRoot(node); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - await act(async () => root!.render({element})); - for (let i = 0; i < 8; i++) await act(async () => { await new Promise(r => setTimeout(r, 10)); }); + await act(async () => + root!.render({element}), + ); + for (let i = 0; i < 8; i++) + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); } -const payload = { action_kind: "COMMUNICATION_SUBMISSION", channel: "EMAIL", content: { body_text: "안녕하세요 담당자님 시험 성적서를 보내 주세요", attachments: [{ name: "요청서.pdf", digest: "1".repeat(64) }] }, - targets: ["b".repeat(64)], disclosed_data: ["프로젝트 이름"], execution_roles: ["project-owner"], risk_tier: "R3", reversibility: "IRREVERSIBLE" }; -const record = (patch: Record = {}) => ({ authorization_id: "auth:1", step_id: "step:send", state: "APPROVED", stale_reason: null, plan_revision_digest: "a".repeat(64), - predecessor_output_digests: [], target_baseline_digests: ["b".repeat(64)], policy_version: "policy:current", - payload, material_changes: [], decision_history: [{ decision: "APPROVE", decided_at: "2026-09-30T00:00:00Z" }], schema_version: "2.0.0", created_at: "2026-09-30T00:00:00Z", ...patch }); -const bodyChange = { field: "body", label: "본문", before: "", after: "", attachments: [], - words: [{ op: "keep", text: "안녕하세요" }, { op: "keep", text: "담당자님" }, { op: "keep", text: "시험" }, { op: "del", text: "성적서를" }, { op: "add", text: "성적서와 원자료를" }, - { op: "keep", text: "보내" }, { op: "keep", text: "주세요" }, { op: "keep", text: "빨리" }, { op: "keep", text: "부탁드립니다" }] }; -const attachmentChange = { field: "attachments", label: "첨부", before: "요청서.pdf", after: "요청서-v2.pdf", words: [], - attachments: [{ before_name: "요청서.pdf", before_digest: "1".repeat(64), after_name: "요청서-v2.pdf", after_digest: "2".repeat(64) }] }; -const stale = (changes: unknown[]) => record({ state: "STALE", stale_reason: "MATERIAL_CHANGE", material_changes: changes, - decision_history: [{ decision: "APPROVE", decided_at: "2026-09-30T00:00:00Z" }, { decision: "STALE", stale_at: "2026-09-30T01:00:00Z" }] }); -const view = (item: Record, extra: Record = {}) => parseApproval({ authorization: item, effective_state: item.state, payload_current: item.state === "APPROVED", review_needed: false, ...extra }); +const payload = { + action_kind: "COMMUNICATION_SUBMISSION", + channel: "EMAIL", + content: { + body_text: "안녕하세요 담당자님 시험 성적서를 보내 주세요", + attachments: [{ name: "요청서.pdf", digest: "1".repeat(64) }], + }, + targets: ["b".repeat(64)], + disclosed_data: ["프로젝트 이름"], + execution_roles: ["project-owner"], + risk_tier: "R3", + reversibility: "IRREVERSIBLE", +}; +const record = (patch: Record = {}) => ({ + authorization_id: "auth:1", + step_id: "step:send", + state: "APPROVED", + stale_reason: null, + plan_revision_digest: "a".repeat(64), + predecessor_output_digests: [], + target_baseline_digests: ["b".repeat(64)], + policy_version: "policy:current", + payload, + material_changes: [], + decision_history: [{ decision: "APPROVE", decided_at: "2026-09-30T00:00:00Z" }], + schema_version: "2.0.0", + created_at: "2026-09-30T00:00:00Z", + ...patch, +}); +const bodyChange = { + field: "body", + label: "본문", + before: "", + after: "", + attachments: [], + words: [ + { op: "keep", text: "안녕하세요" }, + { op: "keep", text: "담당자님" }, + { op: "keep", text: "시험" }, + { op: "del", text: "성적서를" }, + { op: "add", text: "성적서와 원자료를" }, + { op: "keep", text: "보내" }, + { op: "keep", text: "주세요" }, + { op: "keep", text: "빨리" }, + { op: "keep", text: "부탁드립니다" }, + ], +}; +const attachmentChange = { + field: "attachments", + label: "첨부", + before: "요청서.pdf", + after: "요청서-v2.pdf", + words: [], + attachments: [ + { + before_name: "요청서.pdf", + before_digest: "1".repeat(64), + after_name: "요청서-v2.pdf", + after_digest: "2".repeat(64), + }, + ], +}; +const stale = (changes: unknown[]) => + record({ + state: "STALE", + stale_reason: "MATERIAL_CHANGE", + material_changes: changes, + decision_history: [ + { decision: "APPROVE", decided_at: "2026-09-30T00:00:00Z" }, + { decision: "STALE", stale_at: "2026-09-30T01:00:00Z" }, + ], + }); +const view = (item: Record, extra: Record = {}) => + parseApproval({ + authorization: item, + effective_state: item.state, + payload_current: item.state === "APPROVED", + review_needed: false, + ...extra, + }); it("puts the changed parts first: what moved, and only the changed words with a little context", async () => { - await mount(); + await mount(); const text = node.textContent ?? ""; expect(text).toContain("승인 뒤 바뀜: 본문 · 첨부"); expect(node.querySelector("del")?.textContent).toBe("성적서를"); expect(node.querySelector("ins")?.textContent).toBe("성적서와 원자료를"); expect(text).toContain("…"); expect(text).not.toContain("안녕하세요"); - expect(text).toContain("요청서.pdf"); expect(text).toContain("요청서-v2.pdf"); - expect(text).toContain("11111111"); expect(text).toContain("22222222"); + expect(text).toContain("요청서.pdf"); + expect(text).toContain("요청서-v2.pdf"); + expect(text).toContain("11111111"); + expect(text).toContain("22222222"); expect(text).not.toMatch(/MATERIAL_CHANGE|attachments|"body"/); }); it("keeps the earlier approval as history of the version it covered and keeps the full content folded", async () => { - await mount(); + await mount(); expect(node.textContent).toContain("이전 승인은 바뀌기 전 버전에 유효했음"); - const toggle = [...node.querySelectorAll("button")].find(b => b.textContent === "전체 내용 보기") as HTMLButtonElement; + const toggle = [...node.querySelectorAll("button")].find( + (b) => b.textContent === "전체 내용 보기", + ) as HTMLButtonElement; expect(toggle.getAttribute("aria-expanded")).toBe("false"); await act(async () => toggle.click()); expect(toggle.getAttribute("aria-expanded")).toBe("true"); @@ -74,52 +194,111 @@ it("keeps the earlier approval as history of the version it covered and keeps th it("offers re-approval and not-sending only for a stale approval", async () => { const calls: string[] = []; - await mount( calls.push("again")} onDecline={() => calls.push("no")}/>); - const buttons = [...node.querySelectorAll("button")].map(b => b.textContent); - expect(buttons).toContain("바뀐 내용으로 승인 요청 다시 만들기"); expect(buttons).toContain("보내지 않음"); + await mount( + calls.push("again")} + onDecline={() => calls.push("no")} + />, + ); + const buttons = [...node.querySelectorAll("button")].map((b) => b.textContent); + expect(buttons).toContain("바뀐 내용으로 승인 요청 다시 만들기"); + expect(buttons).toContain("보내지 않음"); expect(buttons).not.toContain("바뀐 내용 확인 후 다시 승인"); - for (const label of ["바뀐 내용으로 승인 요청 다시 만들기", "보내지 않음"]) await act(async () => ([...node.querySelectorAll("button")].find(b => b.textContent === label) as HTMLButtonElement).click()); + for (const label of ["바뀐 내용으로 승인 요청 다시 만들기", "보내지 않음"]) + await act(async () => + ( + [...node.querySelectorAll("button")].find( + (b) => b.textContent === label, + ) as HTMLButtonElement + ).click(), + ); expect(calls).toEqual(["again", "no"]); - await act(async () => root!.unmount()); node.remove(); - await mount( undefined} onDecline={() => undefined}/>); - expect([...node.querySelectorAll("button")].map(b => b.textContent)).not.toContain("보내지 않음"); + await act(async () => root!.unmount()); + node.remove(); + await mount( + undefined} + onDecline={() => undefined} + />, + ); + expect([...node.querySelectorAll("button")].map((b) => b.textContent)).not.toContain( + "보내지 않음", + ); expect(node.textContent).toContain("승인됨"); }); it("says a look is advised when only the evidence moved, without voiding the approval", async () => { - await mount(); - expect(node.textContent).toContain("승인됨"); expect(node.textContent).toContain("근거가 바뀌어 다시 살펴보는 것이 좋습니다"); + await mount(); + expect(node.textContent).toContain("승인됨"); + expect(node.textContent).toContain("근거가 바뀌어 다시 살펴보는 것이 좋습니다"); expect(node.textContent).not.toContain("승인 뒤 바뀜"); }); it("re-approval prepares a new approval for the current plan revision with the same digests and policy", async () => { state.authorizations = [stale([bodyChange])]; - await mount(); - await act(async () => ([...node.querySelectorAll("button")].find(b => b.textContent === "바뀐 내용으로 승인 요청 다시 만들기") as HTMLButtonElement).click()); - for (let i = 0; i < 8; i++) await act(async () => { await new Promise(r => setTimeout(r, 10)); }); + await mount(); + await act(async () => + ( + [...node.querySelectorAll("button")].find( + (b) => b.textContent === "바뀐 내용으로 승인 요청 다시 만들기", + ) as HTMLButtonElement + ).click(), + ); + for (let i = 0; i < 8; i++) + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); expect(node.textContent).toContain("승인 대기"); - expect(node.textContent).toContain("승인 요청을 만들었습니다. 승인자가 결정하기 전까지 아무것도 보내지 않습니다"); + expect(node.textContent).toContain( + "승인 요청을 만들었습니다. 승인자가 결정하기 전까지 아무것도 보내지 않습니다", + ); expect(node.textContent).not.toContain("승인됨"); - const prepared = state.calls.find(call => call.method === "action/authorization/prepare")!; - expect(prepared.input).toEqual({ project_id: "project:p", plan_id: "plan:1", step_id: "step:send", plan_revision_digest: "e".repeat(64), - predecessor_output_digests: [], target_baseline_digests: ["b".repeat(64)], policy_version: "policy:current" }); + const prepared = state.calls.find((call) => call.method === "action/authorization/prepare")!; + expect(prepared.input).toEqual({ + project_id: "project:p", + plan_id: "plan:1", + step_id: "step:send", + plan_revision_digest: "e".repeat(64), + predecessor_output_digests: [], + target_baseline_digests: ["b".repeat(64)], + policy_version: "policy:current", + }); }); it("marks a change as not sent on this screen only, without any server call that sends", async () => { state.authorizations = [stale([bodyChange])]; - await mount(); - await act(async () => ([...node.querySelectorAll("button")].find(b => b.textContent === "보내지 않음") as HTMLButtonElement).click()); + await mount(); + await act(async () => + ( + [...node.querySelectorAll("button")].find( + (b) => b.textContent === "보내지 않음", + ) as HTMLButtonElement + ).click(), + ); expect(node.querySelector(".approval-card")).toBeNull(); expect(node.textContent).toContain("이 화면에만 적용"); - expect(state.calls.map(call => call.method).filter(method => !["action/plan/read", "action/authorization/read"].includes(method))).toEqual([]); + expect( + state.calls + .map((call) => call.method) + .filter((method) => !["action/plan/read", "action/authorization/read"].includes(method)), + ).toEqual([]); }); it("reads the plan's approvals and shows the newest state per step, or nothing when the plan cannot be read", async () => { state.authorizations = [stale([bodyChange])]; - await mount( undefined}/>); - expect(state.calls[0]).toEqual({ method: "action/plan/read", input: { project_id: "project:p", plan_id: "plan:1" } }); + await mount( + undefined} />, + ); + expect(state.calls[0]).toEqual({ + method: "action/plan/read", + input: { project_id: "project:p", plan_id: "plan:1" }, + }); expect(node.textContent).toContain("승인 뒤 바뀜: 본문"); - await act(async () => root!.unmount()); node.remove(); state.planFails = true; - await mount(); + await act(async () => root!.unmount()); + node.remove(); + state.planFails = true; + await mount(); expect(node.textContent).toBe(""); }); diff --git a/apps/web/src/components/ApprovalCard.tsx b/apps/web/src/components/ApprovalCard.tsx index 02d423ed..89062454 100644 --- a/apps/web/src/components/ApprovalCard.tsx +++ b/apps/web/src/components/ApprovalCard.tsx @@ -3,49 +3,152 @@ import { t } from "../i18n/locale"; import { Button, Callout, Collapse, Tag } from "@blueprintjs/core"; import { useState } from "react"; import { objectList, objectValue, textValue } from "../api/presentation"; -import { compactWords, listOf, shortDigest, stateLabels, type Approval, type ApprovalChange } from "./approvalView"; +import { + compactWords, + listOf, + shortDigest, + stateLabels, + type Approval, + type ApprovalChange, +} from "./approvalView"; import { statusLabel } from "./statusLabels"; function ChangeView({ change }: { change: ApprovalChange }) { - return
-
{change.label}
- {change.words.length > 0 &&

{compactWords(change.words).map((part, index) => - part.op === "del" ? {part.text}{" "} : part.op === "add" ? {part.text}{" "} - : part.op === "gap" ? … : {part.text} )}

} - {change.attachments.map((pair, index) =>

- {pair.beforeName ?? t("criteriaChange.none")}{pair.beforeDigest && ({shortDigest(pair.beforeDigest)}…)} - {" → "}{pair.afterName ?? t("criteriaChange.none")}{pair.afterDigest && ({shortDigest(pair.afterDigest)}…)}

)} - {change.words.length === 0 && change.attachments.length === 0 &&

{change.before || t("criteriaChange.none")} → {change.after || t("criteriaChange.none")}

} -
; + return ( +
+
{change.label}
+ {change.words.length > 0 && ( +

+ {compactWords(change.words).map((part, index) => + part.op === "del" ? ( + + {part.text}{" "} + + ) : part.op === "add" ? ( + + {part.text}{" "} + + ) : part.op === "gap" ? ( + + …{" "} + + ) : ( + {part.text} + ), + )} +

+ )} + {change.attachments.map((pair, index) => ( +

+ {pair.beforeName ?? t("criteriaChange.none")} + {pair.beforeDigest && ( + ({shortDigest(pair.beforeDigest)}…) + )} + {" → "} + {pair.afterName ?? t("criteriaChange.none")} + {pair.afterDigest && ( + ({shortDigest(pair.afterDigest)}…) + )} +

+ ))} + {change.words.length === 0 && change.attachments.length === 0 && ( +

+ {change.before || t("criteriaChange.none")} → {change.after || t("criteriaChange.none")} +

+ )} +
+ ); } function FullContent({ payload }: { payload: Record }) { const content = objectValue(payload.content); - const attachments = objectList(content.attachments).map(item => textValue(item.name)).filter(Boolean); + const attachments = objectList(content.attachments) + .map((item) => textValue(item.name)) + .filter(Boolean); const rows: [string, string][] = [ - [t("approval.kind"), textValue(payload.action_kind)], [t("approval.route"), textValue(payload.channel)], [t("approval.body"), textValue(content.body_text)], - [t("approval.attachments"), attachments.join(", ")], [t("approval.disclosure"), listOf(payload.disclosed_data).join(", ")], - [t("approval.role"), listOf(payload.execution_roles).join(", ")], [t("approval.risk"), statusLabel(textValue(payload.risk_tier)) ?? textValue(payload.risk_tier)], + [t("approval.kind"), textValue(payload.action_kind)], + [t("approval.route"), textValue(payload.channel)], + [t("approval.body"), textValue(content.body_text)], + [t("approval.attachments"), attachments.join(", ")], + [t("approval.disclosure"), listOf(payload.disclosed_data).join(", ")], + [t("approval.role"), listOf(payload.execution_roles).join(", ")], + [t("approval.risk"), statusLabel(textValue(payload.risk_tier)) ?? textValue(payload.risk_tier)], ]; - return
{rows.filter(([, text]) => text).map(([label, text]) =>
{label}
{text}
)}
; + return ( +
+ {rows + .filter(([, text]) => text) + .map(([label, text]) => ( +
+
{label}
+
{text}
+
+ ))} +
+ ); } /** An approval, with what changed since it was given shown first and the full content folded away. */ -export function ApprovalCard({ approval, onReapprove, onDecline }: { approval: Approval; onReapprove?: () => void; onDecline?: () => void }) { +export function ApprovalCard({ + approval, + onReapprove, + onDecline, +}: { + approval: Approval; + onReapprove?: () => void; + onDecline?: () => void; +}) { const [open, setOpen] = useState(false); const stale = approval.state === "STALE"; - return
-
{stateLabels[approval.state] ?? approval.state}
- {stale && change.label).join(" · ") || t("trace.field.content"))}> - {approval.changes.map(change => )} -

{t("approval.previous")}

-
} - {!stale && approval.reviewNeeded && {t("approval.basisChanged")}} - - - {stale && (onReapprove || onDecline) &&
- {onReapprove && } - {onDecline && } -
} -
; + return ( +
+
+ + {stateLabels[approval.state] ?? approval.state} + +
+ {stale && ( + change.label).join(" · ") || t("trace.field.content"), + )} + > + {approval.changes.map((change) => ( + + ))} +

{t("approval.previous")}

+
+ )} + {!stale && approval.reviewNeeded && ( + + {t("approval.basisChanged")} + + )} + + + + + {stale && (onReapprove || onDecline) && ( +
+ {onReapprove && ( + + )} + {onDecline && } +
+ )} +
+ ); } diff --git a/apps/web/src/components/ApprovalSection.tsx b/apps/web/src/components/ApprovalSection.tsx index 58440949..b936037d 100644 --- a/apps/web/src/components/ApprovalSection.tsx +++ b/apps/web/src/components/ApprovalSection.tsx @@ -13,42 +13,103 @@ import { useReadAccessProtection } from "./useReadAccessProtection"; * "Make the approval request again" only prepares a pending request for the current content; approvers still decide it. * "보내지 않음" is remembered on this screen only: no message is sent either way. */ -export function ApprovalSection({ projectId, planId, onReapprove, onDecline }: { - projectId: string; planId: string; onReapprove?: (approval: Approval) => void; onDecline?: (approval: Approval) => void; +export function ApprovalSection({ + projectId, + planId, + onReapprove, + onDecline, +}: { + projectId: string; + planId: string; + onReapprove?: (approval: Approval) => void; + onDecline?: (approval: Approval) => void; }) { const client = useQueryClient(); const key = ["approvals", projectId, planId]; const [declined, setDeclined] = useState([]); const query = useQuery({ - queryKey: key, retry: false, + queryKey: key, + retry: false, queryFn: async ({ signal }) => { - const plan = await rpc>("action/plan/read", { project_id: projectId, plan_id: planId }, crypto.randomUUID(), signal); + const plan = await rpc>( + "action/plan/read", + { project_id: projectId, plan_id: planId }, + crypto.randomUUID(), + signal, + ); const latest = new Map>(); for (const record of objectList(plan.value.authorizations)) { const seen = latest.get(textValue(record.step_id)); - if (!seen || textValue(record.created_at) >= textValue(seen.created_at)) latest.set(textValue(record.step_id), record); + if (!seen || textValue(record.created_at) >= textValue(seen.created_at)) + latest.set(textValue(record.step_id), record); } - const approvals = await Promise.all([...latest.values()].map(async record => parseApproval(objectValue((await rpc>("action/authorization/read", - { project_id: projectId, authorization_id: textValue(record.authorization_id) }, crypto.randomUUID(), signal)).value)))); + const approvals = await Promise.all( + [...latest.values()].map(async (record) => + parseApproval( + objectValue( + ( + await rpc>( + "action/authorization/read", + { project_id: projectId, authorization_id: textValue(record.authorization_id) }, + crypto.randomUUID(), + signal, + ) + ).value, + ), + ), + ), + ); return { approvals, planRevision: textValue(objectValue(plan.value.plan).revision_digest) }; }, }); const reapprove = useMutation({ - mutationFn: (approval: Approval) => rpc("action/authorization/prepare", { project_id: projectId, plan_id: planId, step_id: approval.stepId, - plan_revision_digest: query.data?.planRevision ?? "", predecessor_output_digests: approval.predecessors, target_baseline_digests: approval.targetBaselines, - policy_version: approval.policyVersion }, crypto.randomUUID()), + mutationFn: (approval: Approval) => + rpc( + "action/authorization/prepare", + { + project_id: projectId, + plan_id: planId, + step_id: approval.stepId, + plan_revision_digest: query.data?.planRevision ?? "", + predecessor_output_digests: approval.predecessors, + target_baseline_digests: approval.targetBaselines, + policy_version: approval.policyVersion, + }, + crypto.randomUUID(), + ), onSuccess: () => client.invalidateQueries({ queryKey: key }), }); const blocked = useReadAccessProtection(key, query.error); const requested = reapprove.isSuccess; - const shown = (query.data?.approvals ?? []).filter(approval => !declined.includes(approval.id)); + const shown = (query.data?.approvals ?? []).filter((approval) => !declined.includes(approval.id)); if (blocked || !query.data || query.data.approvals.length === 0) return null; - return <> - {reapprove.error && {t("approval.recreateFailed")}{" "}{reapprove.error.message}} - {requested && {t("approval.created")}} - {shown.map(approval => { onReapprove?.(approval); if (!onReapprove) reapprove.mutate(approval); }} - onDecline={() => { onDecline?.(approval); setDeclined(current => [...current, approval.id]); }}/>)} - {declined.length > 0 &&

{t("approval.notSentNotice")}

} - ; + return ( + <> + {reapprove.error && ( + + {t("approval.recreateFailed")} {reapprove.error.message} + + )} + {requested && ( + + {t("approval.created")} + + )} + {shown.map((approval) => ( + { + onReapprove?.(approval); + if (!onReapprove) reapprove.mutate(approval); + }} + onDecline={() => { + onDecline?.(approval); + setDeclined((current) => [...current, approval.id]); + }} + /> + ))} + {declined.length > 0 &&

{t("approval.notSentNotice")}

} + + ); } diff --git a/apps/web/src/components/ArchivedProjects.tsx b/apps/web/src/components/ArchivedProjects.tsx index a6fed18a..e1870900 100644 --- a/apps/web/src/components/ArchivedProjects.tsx +++ b/apps/web/src/components/ArchivedProjects.tsx @@ -16,7 +16,13 @@ import { useReadAccessProtection } from "./useReadAccessProtection"; * One archived project: its name and the same kind of menu the open projects have. Restoring and deleting * sit in the menu, so a permanent delete is not one slip away from a restore. */ -function ArchivedProjectRow({ project, restoring, busy, onRestore, onDelete }: { +function ArchivedProjectRow({ + project, + restoring, + busy, + onRestore, + onDelete, +}: { project: ProjectSummary; restoring: boolean; busy: boolean; @@ -31,11 +37,28 @@ function ArchivedProjectRow({ project, restoring, busy, onRestore, onDelete }: { }; const menu = (
- {onDelete && ( - )} @@ -43,16 +66,36 @@ function ArchivedProjectRow({ project, restoring, busy, onRestore, onDelete }: { ); return (
- {project.name} - -
); } /** Archived projects are read-only and hidden from the list; this shows them on demand so one can be restored. */ -export function ArchivedProjects({ scopeKey, hosted = false, storageScope, onDeleted }: { +export function ArchivedProjects({ + scopeKey, + hosted = false, + storageScope, + onDeleted, +}: { scopeKey: string; /** The hosted review environment cannot delete projects, so it gets no delete button. */ hosted?: boolean; @@ -64,41 +107,98 @@ export function ArchivedProjects({ scopeKey, hosted = false, storageScope, onDel const [open, setOpen] = useState(false); const [deleting, setDeleting] = useState(null); const [deleted, setDeleted] = useState(false); - const archived = useQuery({ queryKey: ["projects", scopeKey, "archived"], enabled: open, - queryFn: ({ signal }) => rpc<{ projects: ProjectSummary[] }>("project/list", { project_id: "system:projects", include_archived: true }, crypto.randomUUID(), signal) }); + const archived = useQuery({ + queryKey: ["projects", scopeKey, "archived"], + enabled: open, + queryFn: ({ signal }) => + rpc<{ projects: ProjectSummary[] }>( + "project/list", + { project_id: "system:projects", include_archived: true }, + crypto.randomUUID(), + signal, + ), + }); const restore = useMutation({ - mutationFn: (target: ProjectSummary) => rpc("project/restore", { project_id: target.project_id, expected_revision: target.revision }, crypto.randomUUID()), + mutationFn: (target: ProjectSummary) => + rpc( + "project/restore", + { project_id: target.project_id, expected_revision: target.revision }, + crypto.randomUUID(), + ), onSuccess: () => client.invalidateQueries({ queryKey: ["projects"] }), onError: rereadProjects(client), }); const blocked = useReadAccessProtection(["projects", scopeKey, "archived"], archived.error); - const rows = (blocked ? [] : archived.data?.value.projects ?? []).filter(item => item.lifecycle === "ARCHIVED_READ_ONLY"); + const rows = (blocked ? [] : (archived.data?.value.projects ?? [])).filter( + (item) => item.lifecycle === "ARCHIVED_READ_ONLY", + ); const afterDelete = (projectId: string) => { - client.removeQueries({ predicate: query => query.queryKey.includes(projectId) }); + client.removeQueries({ predicate: (query) => query.queryKey.includes(projectId) }); void client.invalidateQueries({ queryKey: ["projects"] }); if (storageScope) forgetProject(storageScope, projectId); setDeleted(true); onDeleted?.(projectId); }; - return
- - {open &&
- {!blocked && archived.isPending &&

{t("archive.loading")}

} - {(archived.error || blocked) && {t("archive.failed")}} - {!blocked && archived.isSuccess && rows.length === 0 &&

{t("archive.empty")}

} - {rows.map(item => ( - restore.mutate(item)} - onDelete={hosted ? undefined : () => { setDeleted(false); setDeleting(item); }} + return ( +
+ + {open && ( +
+ {!blocked && archived.isPending && ( +

+ {t("archive.loading")} +

+ )} + {(archived.error || blocked) && ( + + {t("archive.failed")} + + )} + {!blocked && archived.isSuccess && rows.length === 0 && ( +

{t("archive.empty")}

+ )} + {rows.map((item) => ( + restore.mutate(item)} + onDelete={ + hosted + ? undefined + : () => { + setDeleted(false); + setDeleting(item); + } + } + /> + ))} + {!blocked && ( + + )} + {deleted && !deleting && ( +

+ {t("archive.deleteDone")} +

+ )} +
+ )} + {deleting && ( + setDeleting(null)} + onDeleted={afterDelete} /> - ))} - {!blocked && } - {deleted && !deleting &&

{t("archive.deleteDone")}

} -
} - {deleting && setDeleting(null)} onDeleted={afterDelete}/>} -
; + )} +
+ ); } diff --git a/apps/web/src/components/AsyncDraftAndCitationRegression.test.tsx b/apps/web/src/components/AsyncDraftAndCitationRegression.test.tsx index d11d4237..f9fc1e11 100644 --- a/apps/web/src/components/AsyncDraftAndCitationRegression.test.tsx +++ b/apps/web/src/components/AsyncDraftAndCitationRegression.test.tsx @@ -1,70 +1,203 @@ // @vitest-environment jsdom -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, expect, it, vi } from 'vitest'; -import { useCitationGate } from './SourceCitationGate'; -import { FirstRunSetup } from './FirstRunSetup'; -import { setLocale, t, useTranslation } from '../i18n/locale'; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { useCitationGate } from "./SourceCitationGate"; +import { FirstRunSetup } from "./FirstRunSetup"; +import { setLocale, t, useTranslation } from "../i18n/locale"; -const fake=vi.hoisted(()=>({resolveRegister:null as null|((v:unknown)=>void),sent:[] as unknown[], - unknown:false,resolveConfirm:null as null|((v:unknown)=>void)})); -vi.mock('../api/rpcClient',async(importOriginal)=>{ - const original=await importOriginal>(); - return {...original,rpc:async(method:string,input:Record)=>{ - if(method==='project/source/list')return {value:{artifacts:['a','b'].map(id=>({artifact_id:id,source_uri:id+'.txt',byte_sha256:'a'.repeat(64),cutoff_state:fake.unknown?'UNKNOWN_TIME':'ELIGIBLE'})), - source_times:fake.unknown?['a','b'].map(id=>({artifact_id:id,source_version_id:'v:'+id,byte_sha256:'a'.repeat(64),cutoff_state:'UNKNOWN_TIME',revision:1,assessment_digest:'a'.repeat(64),cutoff_at:'2026-10-01T00:00:00Z'})):[],cutoff_basis:{cutoff_at:'2026-10-01T00:00:00Z',project_revision:1}}}; - if(method==='project/source/time/confirm')return new Promise(resolve=>{fake.resolveConfirm=resolve;}); - if(method==='workspace/ready')return {value:{ready:false,model_connected:false,setup_complete:false,workspace_readable:true,execution_ready:false,workspace_id:'workspace:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',setup:{schema_version:1,revision:1,internet_consent:'UNDECIDED',internet_grant_id:null}}}; - if(method==='model/credential/list')return {value:{accounts:[{provider:'openai',label:'ChatGPT',connected:false,has_key:false,oauth:false,login_supported:false,login_kind:'unsupported'}]}}; - if(method==='model/credential/register'){fake.sent.push(input);return new Promise(resolve=>{fake.resolveRegister=resolve;});} - throw new Error('Unexpected synthetic RPC '+method); - }}; +const fake = vi.hoisted(() => ({ + resolveRegister: null as null | ((v: unknown) => void), + sent: [] as unknown[], + unknown: false, + resolveConfirm: null as null | ((v: unknown) => void), +})); +vi.mock("../api/rpcClient", async (importOriginal) => { + const original = await importOriginal>(); + return { + ...original, + rpc: async (method: string, input: Record) => { + if (method === "project/source/list") + return { + value: { + artifacts: ["a", "b"].map((id) => ({ + artifact_id: id, + source_uri: id + ".txt", + byte_sha256: "a".repeat(64), + cutoff_state: fake.unknown ? "UNKNOWN_TIME" : "ELIGIBLE", + })), + source_times: fake.unknown + ? ["a", "b"].map((id) => ({ + artifact_id: id, + source_version_id: "v:" + id, + byte_sha256: "a".repeat(64), + cutoff_state: "UNKNOWN_TIME", + revision: 1, + assessment_digest: "a".repeat(64), + cutoff_at: "2026-10-01T00:00:00Z", + })) + : [], + cutoff_basis: { cutoff_at: "2026-10-01T00:00:00Z", project_revision: 1 }, + }, + }; + if (method === "project/source/time/confirm") + return new Promise((resolve) => { + fake.resolveConfirm = resolve; + }); + if (method === "workspace/ready") + return { + value: { + ready: false, + model_connected: false, + setup_complete: false, + workspace_readable: true, + execution_ready: false, + workspace_id: "workspace:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + setup: { + schema_version: 1, + revision: 1, + internet_consent: "UNDECIDED", + internet_grant_id: null, + }, + }, + }; + if (method === "model/credential/list") + return { + value: { + accounts: [ + { + provider: "openai", + label: "ChatGPT", + connected: false, + has_key: false, + oauth: false, + login_supported: false, + login_kind: "unsupported", + }, + ], + }, + }; + if (method === "model/credential/register") { + fake.sent.push(input); + return new Promise((resolve) => { + fake.resolveRegister = resolve; + }); + } + throw new Error("Unexpected synthetic RPC " + method); + }, + }; }); -let root:Root,el:HTMLDivElement,client:QueryClient; -async function mount(ui:React.ReactNode){ - Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true});setLocale('ko'); - el=document.createElement('div');document.body.append(el);root=createRoot(el); - client=new QueryClient({defaultOptions:{queries:{retry:false},mutations:{retry:false}}}); - await act(async()=>{root.render({ui})}); - await tick(); +let root: Root, el: HTMLDivElement, client: QueryClient; +async function mount(ui: React.ReactNode) { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + setLocale("ko"); + el = document.createElement("div"); + document.body.append(el); + root = createRoot(el); + client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + await act(async () => { + root.render({ui}); + }); + await tick(); } -async function tick(){await act(async()=>{await new Promise(resolve=>setTimeout(resolve,30))})} -afterEach(async()=>{if(root)await act(async()=>root.unmount());client?.clear();el?.remove();setLocale('ko');fake.resolveRegister=null;fake.sent=[];fake.unknown=false;fake.resolveConfirm=null}); -function Surface(){useTranslation();const gate=useCitationGate('project:synthetic');return <>{gate.message}} -it('expected locale change updates existing citation notice',async()=>{ - await mount();await act(async()=>el.querySelector('button')!.click());await tick(); - const before=el.querySelector('output')!.textContent; - expect(before).toBe(t('citation.ready')); - await act(async()=>setLocale('en')); - const after=el.querySelector('output')!.textContent,expected=t('citation.ready'); - expect(after).toBe(expected); +async function tick() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + client?.clear(); + el?.remove(); + setLocale("ko"); + fake.resolveRegister = null; + fake.sent = []; + fake.unknown = false; + fake.resolveConfirm = null; +}); +function Surface() { + useTranslation(); + const gate = useCitationGate("project:synthetic"); + return ( + <> + + {gate.message} + + ); +} +it("expected locale change updates existing citation notice", async () => { + await mount(); + await act(async () => el.querySelector("button")!.click()); + await tick(); + const before = el.querySelector("output")!.textContent; + expect(before).toBe(t("citation.ready")); + await act(async () => setLocale("en")); + const after = el.querySelector("output")!.textContent, + expected = t("citation.ready"); + expect(after).toBe(expected); }); -it('expected newer API key draft survives older submission completion',async()=>{ - await mount({}}/>); - const article=[...el.querySelectorAll('article')].find(a=>a.querySelector('strong')?.textContent==='ChatGPT')!; - expect(article).toBeTruthy(); - await act(async()=>article.querySelector('.provider-key-toggle')!.click()); - const input=el.querySelector('input[type=password]')!; - const change=async(v:string)=>{await act(async()=>{Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,v);input.dispatchEvent(new Event('input',{bubbles:true}))})}; - await change('SYNTHETIC_OLD_NOT_A_REAL_KEY'); - const submit=[...article.querySelectorAll('button')].find(b=>b.textContent===t('setup.registerKey'))!; - await act(async()=>submit.click());await tick();expect(fake.resolveRegister).not.toBeNull(); - await change('SYNTHETIC_NEW_NOT_A_REAL_KEY'); - const before=input.value; - await act(async()=>fake.resolveRegister!({value:{credential:{provider:'openai',connected:true}}}));await tick(); - const remaining=el.querySelector('input[type=password]')?.value??null; - expect(remaining).toBe(before); +it("expected newer API key draft survives older submission completion", async () => { + await mount( {}} />); + const article = [...el.querySelectorAll("article")].find( + (a) => a.querySelector("strong")?.textContent === "ChatGPT", + )!; + expect(article).toBeTruthy(); + await act(async () => article.querySelector(".provider-key-toggle")!.click()); + const input = el.querySelector("input[type=password]")!; + const change = async (v: string) => { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, v); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + }; + await change("SYNTHETIC_OLD_NOT_A_REAL_KEY"); + const submit = [...article.querySelectorAll("button")].find( + (b) => b.textContent === t("setup.registerKey"), + )!; + await act(async () => submit.click()); + await tick(); + expect(fake.resolveRegister).not.toBeNull(); + await change("SYNTHETIC_NEW_NOT_A_REAL_KEY"); + const before = input.value; + await act(async () => + fake.resolveRegister!({ value: { credential: { provider: "openai", connected: true } } }), + ); + await tick(); + const remaining = el.querySelector("input[type=password]")?.value ?? null; + expect(remaining).toBe(before); }); -function RaceSurface(){const gate=useCitationGate('project:synthetic');return <>{gate.dialog}} -it('expected newer citation dialog survives older confirmation completion',async()=>{ - fake.unknown=true;await mount(); - const click=async(text:string)=>{const b=[...document.querySelectorAll('button')].find(x=>x.textContent===text);expect(b).toBeTruthy();await act(async()=>b!.click());await tick()}; - await click('ask-a');await click(t('sourceTime.existed'));expect(fake.resolveConfirm).not.toBeNull(); - await click(t('sourceTime.skip'));await click('ask-b'); - const before=document.querySelector('.bp6-dialog')?.textContent??null;expect(before).toContain('b.txt'); - await act(async()=>fake.resolveConfirm!({value:{source_time:{}}}));await tick(); - const after=document.querySelector('.bp6-dialog')?.textContent??null; - expect(after??'').toContain('b.txt'); +function RaceSurface() { + const gate = useCitationGate("project:synthetic"); + return ( + <> + + + {gate.dialog} + + ); +} +it("expected newer citation dialog survives older confirmation completion", async () => { + fake.unknown = true; + await mount(); + const click = async (text: string) => { + const b = [...document.querySelectorAll("button")].find((x) => x.textContent === text); + expect(b).toBeTruthy(); + await act(async () => b!.click()); + await tick(); + }; + await click("ask-a"); + await click(t("sourceTime.existed")); + expect(fake.resolveConfirm).not.toBeNull(); + await click(t("sourceTime.skip")); + await click("ask-b"); + const before = document.querySelector(".bp6-dialog")?.textContent ?? null; + expect(before).toContain("b.txt"); + await act(async () => fake.resolveConfirm!({ value: { source_time: {} } })); + await tick(); + const after = document.querySelector(".bp6-dialog")?.textContent ?? null; + expect(after ?? "").toContain("b.txt"); }); diff --git a/apps/web/src/components/AutoRetrySwitch.test.tsx b/apps/web/src/components/AutoRetrySwitch.test.tsx index 2878d844..0ce67c3f 100644 --- a/apps/web/src/components/AutoRetrySwitch.test.tsx +++ b/apps/web/src/components/AutoRetrySwitch.test.tsx @@ -7,47 +7,95 @@ import { AutoRetrySwitch } from "./AutoRetrySwitch"; import { modelHelpMessages } from "../i18n/modelHelpMessages"; // 사용자 결정 20261010-01: a project without saved settings is on. -const state = vi.hoisted(() => ({ enabled: true, digest: null as string | null, calls: [] as { method: string; input: Record }[], fail: false })); -vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()), +const state = vi.hoisted(() => ({ + enabled: true, + digest: null as string | null, + calls: [] as { method: string; input: Record }[], + fail: false, +})); +vi.mock("../api/rpcClient", async (importOriginal) => ({ + ...(await importOriginal()), rpc: async (method: string, input: Record) => { state.calls.push({ method, input }); if (state.fail) throw new Error("synthetic failure"); - if (method === "model/callSettings/update") { state.enabled = input.auto_retry_interrupted_model_call as boolean; state.digest = "s".repeat(64); } - return { value: { auto_retry_interrupted_model_call: state.enabled, settings_digest: state.digest }, state: "SUCCEEDED", operation_id: "" }; - } })); + if (method === "model/callSettings/update") { + state.enabled = input.auto_retry_interrupted_model_call as boolean; + state.digest = "s".repeat(64); + } + return { + value: { auto_retry_interrupted_model_call: state.enabled, settings_digest: state.digest }, + state: "SUCCEEDED", + operation_id: "", + }; + }, +})); -let root: Root | undefined; let container: HTMLDivElement; -const settle = async () => { for (let i = 0; i < 8; i++) await act(async () => { await new Promise(r => setTimeout(r, 10)); }); }; +let root: Root | undefined; +let container: HTMLDivElement; +const settle = async () => { + for (let i = 0; i < 8; i++) + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); +}; async function mount() { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - container = document.createElement("div"); document.body.append(container); root = createRoot(container); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - await act(async () => root!.render()); + await act(async () => + root!.render( + + + , + ), + ); await settle(); } -afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); document.body.innerHTML = ""; state.enabled = true; state.digest = null; state.calls = []; state.fail = false; }); +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + container?.remove(); + document.body.innerHTML = ""; + state.enabled = true; + state.digest = null; + state.calls = []; + state.fail = false; +}); it("starts on as the server says, explains the cost, and sends the digest it read when turned off", async () => { await mount(); const input = container.querySelector("input[type=checkbox]")!; // 사용자 결정 20261010-01: show the server's new default and let the user turn it off. expect(input.checked).toBe(true); - expect(container.textContent).toContain("모델 호출이 중간에 끊기면 그 호출만 최대 2번 자동으로 다시 시도합니다. 토큰이 더 쓰입니다."); - expect(container.textContent).toContain("끄면 끊긴 결과 카드의 \"이어서 조사\"로 다시 할 수 있습니다."); + expect(container.textContent).toContain( + "모델 호출이 중간에 끊기면 그 호출만 최대 2번 자동으로 다시 시도합니다. 토큰이 더 쓰입니다.", + ); + expect(container.textContent).toContain( + '끄면 끊긴 결과 카드의 "이어서 조사"로 다시 할 수 있습니다.', + ); expect(modelHelpMessages["modelHelp.retry"].ko).toContain("토큰이 더 쓰입니다"); - expect(state.calls).toEqual([{ method: "model/callSettings/read", input: { project_id: "project:p" } }]); + expect(state.calls).toEqual([ + { method: "model/callSettings/read", input: { project_id: "project:p" } }, + ]); await act(async () => input.click()); await settle(); - const update = state.calls.find(call => call.method === "model/callSettings/update")!; - expect(update.input).toEqual({ project_id: "project:p", auto_retry_interrupted_model_call: false, expected_digest: null }); + const update = state.calls.find((call) => call.method === "model/callSettings/update")!; + expect(update.input).toEqual({ + project_id: "project:p", + auto_retry_interrupted_model_call: false, + expected_digest: null, + }); expect(container.querySelector("input[type=checkbox]")!.checked).toBe(false); }); it("keeps an existing saved off choice instead of replacing it with the default", async () => { - state.enabled = false; state.digest = "saved"; + state.enabled = false; + state.digest = "saved"; await mount(); expect(container.querySelector("input[type=checkbox]")!.checked).toBe(false); - expect(state.calls.map(call => call.method)).toEqual(["model/callSettings/read"]); + expect(state.calls.map((call) => call.method)).toEqual(["model/callSettings/read"]); }); it("says so when the setting cannot be read instead of showing it as off", async () => { @@ -56,4 +104,3 @@ it("says so when the setting cannot be read instead of showing it as off", async expect(container.textContent).toContain("자동 재시도 설정을 읽지 못했습니다"); expect(container.querySelector("input[type=checkbox]")).toBeNull(); }); - diff --git a/apps/web/src/components/AutoRetrySwitch.tsx b/apps/web/src/components/AutoRetrySwitch.tsx index 5bf5fcdc..bfb49def 100644 --- a/apps/web/src/components/AutoRetrySwitch.tsx +++ b/apps/web/src/components/AutoRetrySwitch.tsx @@ -5,28 +5,50 @@ import { rpc } from "../api/rpcClient"; type CallSettings = { auto_retry_interrupted_model_call: boolean; settings_digest: string | null }; - /** The project's switch for sending a cut-off model call again (at most twice). It shows what the server says. */ export function AutoRetrySwitch({ projectId }: { projectId: string }) { const client = useQueryClient(); const key = ["model-call-settings", projectId]; const query = useQuery({ queryKey: key, - queryFn: ({ signal }) => rpc("model/callSettings/read", { project_id: projectId }, crypto.randomUUID(), signal), + queryFn: ({ signal }) => + rpc( + "model/callSettings/read", + { project_id: projectId }, + crypto.randomUUID(), + signal, + ), }); const change = useMutation({ - mutationFn: (enabled: boolean) => rpc("model/callSettings/update", { - project_id: projectId, auto_retry_interrupted_model_call: enabled, expected_digest: query.data?.value.settings_digest ?? null, - }, crypto.randomUUID()), + mutationFn: (enabled: boolean) => + rpc( + "model/callSettings/update", + { + project_id: projectId, + auto_retry_interrupted_model_call: enabled, + expected_digest: query.data?.value.settings_digest ?? null, + }, + crypto.randomUUID(), + ), onSettled: () => client.invalidateQueries({ queryKey: key }), }); if (query.isPending) return null; - if (query.error || !query.data) return

{t("modelHelp.retryReadFailed")}

; - return
- change.mutate(event.currentTarget.checked)}/> - {t("modelHelp.retry")} - {change.error && {t("modelHelp.changeFailed")}} -
; + if (query.error || !query.data) + return ( +

+ {t("modelHelp.retryReadFailed")} +

+ ); + return ( +
+ change.mutate(event.currentTarget.checked)} + /> + {t("modelHelp.retry")} + {change.error && {t("modelHelp.changeFailed")}} +
+ ); } - diff --git a/apps/web/src/components/CapabilityWorkbench.test.tsx b/apps/web/src/components/CapabilityWorkbench.test.tsx index e36898fc..f4a7d30a 100644 --- a/apps/web/src/components/CapabilityWorkbench.test.tsx +++ b/apps/web/src/components/CapabilityWorkbench.test.tsx @@ -9,103 +9,201 @@ import { WorkspaceErrorBoundary } from "./WorkspaceErrorBoundary"; import { FileConnectionPanel } from "./FileConnectionPanel"; import { visibleRecordViews } from "../api/recordViews"; -const fixture=vi.hoisted(()=>({calls:[] as {method:string;input:Record}[],defer:false,resolve:null as null|(()=>void)})); -vi.mock("../api/files",()=>({stageFile:async()=>({relative_path:"web/fixture.md",media_type:"text/markdown",byte_sha256:"a".repeat(64),bytes:20,filename:"fixture.md"})})); -vi.mock("../api/rpcClient",async(importOriginal)=>({...(await importOriginal()), - rpc:async(method:string,input:Record)=>{ - fixture.calls.push({method,input}); - if(fixture.defer)await new Promise(resolve=>{fixture.resolve=resolve;}); - return {state:"SUCCEEDED",operation_id:"",value:{scope_result:`${input.project_id}-only-result`}}; +const fixture = vi.hoisted(() => ({ + calls: [] as { method: string; input: Record }[], + defer: false, + resolve: null as null | (() => void), +})); +vi.mock("../api/files", () => ({ + stageFile: async () => ({ + relative_path: "web/fixture.md", + media_type: "text/markdown", + byte_sha256: "a".repeat(64), + bytes: 20, + filename: "fixture.md", + }), +})); +vi.mock("../api/rpcClient", async (importOriginal) => ({ + ...(await importOriginal()), + rpc: async (method: string, input: Record) => { + fixture.calls.push({ method, input }); + if (fixture.defer) + await new Promise((resolve) => { + fixture.resolve = resolve; + }); + return { + state: "SUCCEEDED", + operation_id: "", + value: { scope_result: `${input.project_id}-only-result` }, + }; }, })); -let root:Root;let container:HTMLDivElement;let client:QueryClient; -const tick=()=>new Promise(resolve=>setTimeout(resolve,15)); -async function render(node:ReactNode){await act(async()=>{root.render({node});await tick();});} -async function mount(node:ReactNode){Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true});fixture.calls=[];fixture.defer=false;fixture.resolve=null;container=document.createElement("div");document.body.append(container);root=createRoot(container);client=new QueryClient({defaultOptions:{queries:{retry:false},mutations:{retry:false}}});await render(node);} -async function click(label:string){const button=Array.from(container.querySelectorAll("button")).find(item=>item.textContent?.includes(label));expect(button).toBeTruthy();await act(async()=>{button!.click();await tick();});} -afterEach(async()=>{if(root)await act(async()=>root.unmount());client?.clear();container?.remove();}); +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +const tick = () => new Promise((resolve) => setTimeout(resolve, 15)); +async function render(node: ReactNode) { + await act(async () => { + root.render({node}); + await tick(); + }); +} +async function mount(node: ReactNode) { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + fixture.calls = []; + fixture.defer = false; + fixture.resolve = null; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + await render(node); +} +async function click(label: string) { + const button = Array.from(container.querySelectorAll("button")).find((item) => + item.textContent?.includes(label), + ); + expect(button).toBeTruthy(); + await act(async () => { + button!.click(); + await tick(); + }); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + client?.clear(); + container?.remove(); +}); -it("a malformed panel leaves navigation available and resets on a different view",async()=>{ - const logger=vi.spyOn(console,"error").mockImplementation(()=>{}); - function BrokenPanel():ReactNode{throw new Error("TEST_MALFORMED_RESPONSE");} - try{ - await mount(<>); +it("a malformed panel leaves navigation available and resets on a different view", async () => { + const logger = vi.spyOn(console, "error").mockImplementation(() => {}); + function BrokenPanel(): ReactNode { + throw new Error("TEST_MALFORMED_RESPONSE"); + } + try { + await mount( + <> + + + + + , + ); expect(container.textContent).toContain("프로젝트 탐색 유지"); expect(container.textContent).toContain("이 화면을 표시하지 못했습니다"); - await render(<>

기록 조회 가능

); + await render( + <> + + +

기록 조회 가능

+
+ , + ); expect(container.textContent).toContain("기록 조회 가능"); - }finally{logger.mockRestore();} + } finally { + logger.mockRestore(); + } }); -it("catalog exploration is inert and command execution requires the exact-input acknowledgement",async()=>{ - await mount(); +it("catalog exploration is inert and command execution requires the exact-input acknowledgement", async () => { + await mount(); expect(fixture.calls).toHaveLength(0); await click("model/settings/update"); expect(fixture.calls).toHaveLength(0); - const run=Array.from(container.querySelectorAll("button")).find(item=>item.textContent?.includes("확인한 명령 실행"))!; + const run = Array.from(container.querySelectorAll("button")).find((item) => + item.textContent?.includes("확인한 명령 실행"), + )!; expect(run.disabled).toBe(true); - await act(async()=>{container.querySelector('input[type="checkbox"]')!.click();}); + await act(async () => { + container.querySelector('input[type="checkbox"]')!.click(); + }); expect(run.disabled).toBe(false); await click("확인한 명령 실행"); - expect(fixture.calls).toEqual([{method:"model/settings/update",input:{project_id:"p"}}]); + expect(fixture.calls).toEqual([{ method: "model/settings/update", input: { project_id: "p" } }]); }); it("does not expose ProjectPack or field fixtures in the local API workbench", async () => { await mount(); - const methods = [...container.querySelectorAll(".method-item strong")].map(item => item.textContent ?? ""); - expect(methods.some(method => method.startsWith("projectpack/"))).toBe(false); - expect(methods.some(method => method.startsWith("field/"))).toBe(false); - const namespaces = [...container.querySelectorAll('[aria-label="기능 namespace"] option')].map(option => option.value); + const methods = [...container.querySelectorAll(".method-item strong")].map( + (item) => item.textContent ?? "", + ); + expect(methods.some((method) => method.startsWith("projectpack/"))).toBe(false); + expect(methods.some((method) => method.startsWith("field/"))).toBe(false); + const namespaces = [ + ...container.querySelectorAll('[aria-label="기능 namespace"] option'), + ].map((option) => option.value); expect(namespaces).not.toContain("projectpack"); expect(namespaces).not.toContain("field"); }); -it("late capability response is not relabeled with the next project's context",async()=>{ - await mount(); +it("late capability response is not relabeled with the next project's context", async () => { + await mount(); await click("model/settings/read"); - fixture.defer=true;await click("이 조회 실행"); - await render(); - await act(async()=>{fixture.resolve!();await tick();}); + fixture.defer = true; + await click("이 조회 실행"); + await render(); + await act(async () => { + fixture.resolve!(); + await tick(); + }); expect(container.textContent).not.toContain("p-only-result"); expect(container.querySelector("#rpc-input")!.value).toContain('"q"'); }); -it("keeps fixture and command-only field namespaces out of the visible record browser",async()=>{ - await mount({}}/>); +it("keeps fixture and command-only field namespaces out of the visible record browser", async () => { + await mount( {}} />); expect(container.querySelectorAll("button.domain-card")).toHaveLength(visibleRecordViews.length); expect(fixture.calls).toHaveLength(0); expect(container.textContent).not.toContain("검증 예제"); expect(container.textContent).not.toContain("현장 평가"); expect(fixture.calls).toHaveLength(0); - await click("프로젝트 기억");await click("기록 불러오기"); - expect(fixture.calls).toEqual([{method:"memory/list",input:{project_id:"p"}}]); + await click("프로젝트 기억"); + await click("기록 불러오기"); + expect(fixture.calls).toEqual([{ method: "memory/list", input: { project_id: "p" } }]); }); -it("late curated record responses cannot replace another project or namespace",async()=>{ - await mount({}}/>); - fixture.defer=true;await click("기록 불러오기"); - await render({}}/>); - await act(async()=>{fixture.resolve!();await tick();}); +it("late curated record responses cannot replace another project or namespace", async () => { + await mount( {}} />); + fixture.defer = true; + await click("기록 불러오기"); + await render( {}} />); + await act(async () => { + fixture.resolve!(); + await tick(); + }); expect(container.textContent).not.toContain("p-only-result"); expect(container.textContent).toContain("조회 전"); }); -it("successful file connect resets the native chooser and permits selecting the same file again",async()=>{ - await mount(); - const input=container.querySelector('input[type="file"]')!; - const file=new File(["fixture source"],"fixture.md",{type:"text/markdown"}); - let nativeValue="C:\\fakepath\\fixture.md"; - Object.defineProperty(input,"value",{configurable:true,get:()=>nativeValue,set:(value:string)=>{nativeValue=value;}}); - Object.defineProperty(input,"files",{configurable:true,value:[file]}); - await act(async()=>{ - input.dispatchEvent(new Event("change",{bubbles:true})); - const authority=container.querySelector('[aria-label="자료 성격"]')!; - authority.value="INFORMAL";authority.dispatchEvent(new Event("change",{bubbles:true})); +it("successful file connect resets the native chooser and permits selecting the same file again", async () => { + await mount(); + const input = container.querySelector('input[type="file"]')!; + const file = new File(["fixture source"], "fixture.md", { type: "text/markdown" }); + let nativeValue = "C:\\fakepath\\fixture.md"; + Object.defineProperty(input, "value", { + configurable: true, + get: () => nativeValue, + set: (value: string) => { + nativeValue = value; + }, }); - await click("자료 연결");expect(nativeValue).toBe(""); + Object.defineProperty(input, "files", { configurable: true, value: [file] }); + await act(async () => { + input.dispatchEvent(new Event("change", { bubbles: true })); + const authority = container.querySelector('[aria-label="자료 성격"]')!; + authority.value = "INFORMAL"; + authority.dispatchEvent(new Event("change", { bubbles: true })); + }); + await click("자료 연결"); + expect(nativeValue).toBe(""); expect(container.querySelector('[aria-label="기준시점 상태"]')).toBeNull(); expect(fixture.calls[0].input.cutoff_state).toBe("ELIGIBLE"); expect(fixture.calls[0].input.authority).toBe("INFORMAL"); - await act(async()=>{input.dispatchEvent(new Event("change",{bubbles:true}));}); - await click("자료 연결");expect(fixture.calls.filter(call=>call.method==="project/source/connect")).toHaveLength(2); + await act(async () => { + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + await click("자료 연결"); + expect(fixture.calls.filter((call) => call.method === "project/source/connect")).toHaveLength(2); }); diff --git a/apps/web/src/components/CapabilityWorkbench.tsx b/apps/web/src/components/CapabilityWorkbench.tsx index 6a5c0bb9..393e5f99 100644 --- a/apps/web/src/components/CapabilityWorkbench.tsx +++ b/apps/web/src/components/CapabilityWorkbench.tsx @@ -4,77 +4,310 @@ import { Button, Callout, Checkbox, InputGroup, Tag, TextArea } from "@blueprint import { Select } from "./Select"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useDeferredValue, useState } from "react"; -import { capabilityCatalog, filterCapabilities, namespaceLabels, namespaces, parseInvocation, transportFor, type Capability } from "../api/capabilities"; +import { + capabilityCatalog, + filterCapabilities, + namespaceLabels, + namespaces, + parseInvocation, + transportFor, + type Capability, +} from "../api/capabilities"; import { rpc, RpcError } from "../api/rpcClient"; import { RecordInspector } from "./RecordInspector"; -function MethodDetail({ method, projectId, threadId }: { method: Capability; projectId: string; threadId?: string }) { +function MethodDetail({ + method, + projectId, + threadId, +}: { + method: Capability; + projectId: string; + threadId?: string; +}) { const client = useQueryClient(); - const [input, setInput] = useState(() => JSON.stringify({ project_id: projectId, - ...(threadId && method.namespace === "thread" ? { thread_id: threadId } : {}), - }, null, 2)); + const [input, setInput] = useState(() => + JSON.stringify( + { + project_id: projectId, + ...(threadId && method.namespace === "thread" ? { thread_id: threadId } : {}), + }, + null, + 2, + ), + ); const [confirmed, setConfirmed] = useState(false); const [requestKey, setRequestKey] = useState(() => crypto.randomUUID()); let validation = ""; - try { parseInvocation(input, projectId); } catch (error) { validation = error instanceof Error ? error.message : t("advanced.invalidInput"); } + try { + parseInvocation(input, projectId); + } catch (error) { + validation = error instanceof Error ? error.message : t("advanced.invalidInput"); + } const directRead = transportFor(method.name) === "/rpc/query"; const command = method.surface !== "QUERY" && !directRead; const invocation = useMutation({ - mutationFn: (captured: { input: Record; key: string }) => rpc>(method.name, captured.input, captured.key), - onSuccess: () => { if (command) void client.invalidateQueries(); }, + mutationFn: (captured: { input: Record; key: string }) => + rpc>(method.name, captured.input, captured.key), + onSuccess: () => { + if (command) void client.invalidateQueries(); + }, }); - return
-
{method.surface}{method.canonical ? "canonical" : "compatibility alias"}
-

{method.name}

-
{t("advanced.owner")}
{method.canonical_owner}
{t("advanced.policy")}
{method.policy}
-
{t("advanced.entrypoint")}
{method.normal_entrypoint}
{t("advanced.contract")}
{method.behavioral_evidence.join(" · ")}
- {method.alias_target &&
{t("advanced.alias")}
{method.alias_target}
} -
{t("advanced.transport")}
{transportFor(method.name)}
- - {command ? t("advanced.commandNotice") : - directRead ? t("advanced.queryNotice") : - t("advanced.journalNotice")} - - -