diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json
index 4e8176e..c887988 100644
--- a/SOURCE_MANIFEST.json
+++ b/SOURCE_MANIFEST.json
@@ -1,6 +1,6 @@
{
"schema_version": "thoth-source-preview-manifest-v1",
- "created_at": "2026-10-11T11:42:23.815981+00:00",
+ "created_at": "2026-10-11T12:00:52.485237+00:00",
"license": "MIT",
"git_history_included": false,
"full_suite_verified": false,
@@ -348,13 +348,13 @@
},
{
"path": "apps/web/src/api/resultEvidence.test.ts",
- "sha256": "5fc6731dbfa58e9e68dd93c7ea73b06119285d627b8fba117ae44b2b8a25b309",
- "bytes": 5943
+ "sha256": "4ce2f6102016c86ad2d1881c0362ecb458502d5045cf2274b4b15d64b9bb9e07",
+ "bytes": 8096
},
{
"path": "apps/web/src/api/resultEvidence.ts",
- "sha256": "2ec47763b71b7afadbf596d1324c8e3e38d0c7266ce0d952e967cb4702b86d3a",
- "bytes": 7869
+ "sha256": "badd582b14dc01f2ce422756700d72310948ac032ccd5a4b5a665b2e1a3a85b0",
+ "bytes": 8343
},
{
"path": "apps/web/src/api/resultEvidenceIdentity.test.ts",
@@ -458,8 +458,8 @@
},
{
"path": "apps/web/src/components/AnswerHold.tsx",
- "sha256": "e682f1265361256d4f5e433fa4e7098eba2e094c87fc38c9ce597019f9ccfb6e",
- "bytes": 1561
+ "sha256": "887d4b5bd9396f712536ba523b6ce13f2005d68b010da156dab94afcb01f51fb",
+ "bytes": 1590
},
{
"path": "apps/web/src/components/AnswerReadability.test.tsx",
@@ -481,6 +481,11 @@
"sha256": "60e8441c6af7d4971832fb1cc6eebb18b0fa2d603028644b974d86c01982cace",
"bytes": 3590
},
+ {
+ "path": "apps/web/src/components/ArchivedProjects.tsx",
+ "sha256": "0acefdc9d1ba41463f2b7017240971291e85f050b1fec813946437b7b3afcce4",
+ "bytes": 2392
+ },
{
"path": "apps/web/src/components/AuditTrail.tsx",
"sha256": "91e6d770cd55fe85c687dde67d4b92c125454b11f8e7ca07d8e2303cab989904",
@@ -543,13 +548,13 @@
},
{
"path": "apps/web/src/components/ContractV3Screens.test.tsx",
- "sha256": "ac7213a8ddf9973cc5c3afd1b905dd1866e05a3af8b385e942b37c2ec4a87f77",
- "bytes": 7243
+ "sha256": "cc58a42347636f4d117d503d4f7495606cff79c5541327049f6ff543104ffdc0",
+ "bytes": 8555
},
{
"path": "apps/web/src/components/ConversationTimeline.tsx",
- "sha256": "48dc6fce194ebaa7bbea234f394edf74a5cb193754d77383e05558376d7362e3",
- "bytes": 10085
+ "sha256": "2cea7be6a13f3a518f5ea28b9362e55f2f447fa6ef7844c3c3014bc43feba333",
+ "bytes": 10209
},
{
"path": "apps/web/src/components/ConversationTimelineDeniedRetention.test.tsx",
@@ -568,8 +573,13 @@
},
{
"path": "apps/web/src/components/ConversationTimelineRefetch.test.tsx",
- "sha256": "3ca5886e9bbbe3c46d2b28723afe42898f8fd1347862d5a1502faf88b2e3bf12",
- "bytes": 7547
+ "sha256": "cc09437334807466bc778342d6c8b3668af40492617b4660dc560aab0d69411e",
+ "bytes": 7602
+ },
+ {
+ "path": "apps/web/src/components/ConversationTimelineSynthetic.test.tsx",
+ "sha256": "68980715c34e1c3bea9d28dbd8bfb278defdcab6e104ba84a0a5ef5e35d9f502",
+ "bytes": 3886
},
{
"path": "apps/web/src/components/Disclosure.tsx",
@@ -618,8 +628,8 @@
},
{
"path": "apps/web/src/components/ExpectedResults.tsx",
- "sha256": "3d0fdcae3969e2b8926316bf1196947c5c490c88164888b3f4ccc261bcba27d4",
- "bytes": 1275
+ "sha256": "3de49d9ded39f4d7acd164d4296ec7de3b43cb87ad1a15ddc2e6e43cba0e4e68",
+ "bytes": 1547
},
{
"path": "apps/web/src/components/FileConnectionPanel.tsx",
@@ -638,13 +648,13 @@
},
{
"path": "apps/web/src/components/HypothesisCompare.test.tsx",
- "sha256": "efd9ebb1f547365b188d4d0c34efb93dc945676eca6baaeb316ecfd7476dd818",
- "bytes": 6869
+ "sha256": "fcce6c8fd20a89b844518a0395a66cd8afc8f334bb60d6fbfe2cd61234d8de8f",
+ "bytes": 8536
},
{
"path": "apps/web/src/components/HypothesisCompare.tsx",
- "sha256": "71e230152818510e92a1e0ff33043e72ed8e7e0fd1bb082c0a97ec8196eba28b",
- "bytes": 9156
+ "sha256": "ce858c804b4a1b266320f812270bb6efc6628ab39456ff8d162254fe0bc9490d",
+ "bytes": 9398
},
{
"path": "apps/web/src/components/HypothesisCompareDraft.test.tsx",
@@ -693,8 +703,8 @@
},
{
"path": "apps/web/src/components/HypothesisScreen.test.tsx",
- "sha256": "1d3f68fd3458e5a1b384c4f5f6d671ebae1c1abded92720bc966628f63edda02",
- "bytes": 10645
+ "sha256": "ddf07819291960009011f0a3b7b9cbe139301af2461b125f5ec88a72542d5499",
+ "bytes": 10655
},
{
"path": "apps/web/src/components/LessonRecall.test.tsx",
@@ -713,8 +723,8 @@
},
{
"path": "apps/web/src/components/LiveProjectWorkspace.tsx",
- "sha256": "09114b62bea55d41764f811fa38932b92bb97d5e6a8b4f330afb99db50fed028",
- "bytes": 42864
+ "sha256": "24764098e6ce17bcd6441a41b8d40894cc67f5bff95baca651dd09e8d06dfd91",
+ "bytes": 43374
},
{
"path": "apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx",
@@ -748,8 +758,8 @@
},
{
"path": "apps/web/src/components/ModelSettings.test.tsx",
- "sha256": "2a12649f0a89d9a3ba3cba7d5b6500680dbb5ba81e0079991695bbc1a2614b00",
- "bytes": 23318
+ "sha256": "da9d04a0670973eb67d99da7cf1b4a4c59ce824be5160dd59395ce1489219ce5",
+ "bytes": 23343
},
{
"path": "apps/web/src/components/ModelSettings.tsx",
@@ -761,6 +771,11 @@
"sha256": "a94bc861fcb5403c5575f0424bcdf9f6548feb87d1b8eac090415d6a31829293",
"bytes": 19518
},
+ {
+ "path": "apps/web/src/components/ProjectArchiveRestore.test.tsx",
+ "sha256": "0c4a5e09c15eebc5e65b228b9cf35eb0827a7421976add249b2ff0457ae75d6e",
+ "bytes": 7820
+ },
{
"path": "apps/web/src/components/ProjectLanding.test.tsx",
"sha256": "7320a8b86b880d32d0bf66d06452c0ca1ecaf72b743fc5c5c76786b9c1758cea",
@@ -833,18 +848,18 @@
},
{
"path": "apps/web/src/components/ResearchEvidenceDetail.test.tsx",
- "sha256": "9b2e1573fa475382ba29a6af339263c29200e9971f2759a7dfb8874d392fa4d2",
- "bytes": 10627
+ "sha256": "db7715c70ee563e82544944ea955b339ab1224b8de32ba9bd70398b68856601d",
+ "bytes": 10927
},
{
"path": "apps/web/src/components/ResearchFollowupSummary.tsx",
- "sha256": "dcf25f66a3e2ed86978828551022df13e51292c733f57dc5b9aef6bbc8492c0b",
- "bytes": 10005
+ "sha256": "008da642697cd4cfa493399095768704d236f8b747199743e972d50899cc731b",
+ "bytes": 10033
},
{
"path": "apps/web/src/components/ResearchHold.test.tsx",
- "sha256": "4c99039a3f0189fb080f4dee407173673d4749bd6dca560d5aa7f33cc5615ad1",
- "bytes": 5848
+ "sha256": "2107537ed6cf5fdfc34ce290175f63c33821df8fb645ccf7807c4d1b133462f8",
+ "bytes": 7061
},
{
"path": "apps/web/src/components/ResearchLiveProgress.tsx",
@@ -873,8 +888,8 @@
},
{
"path": "apps/web/src/components/ResultEvidencePanel.tsx",
- "sha256": "46ac8d2966cfd8533ebae696ad7f232d1393d9d5da04d32d0b6fc9bab2fc7a11",
- "bytes": 5106
+ "sha256": "897a8f56fef32d758bbe8a42e10dd2ed1af4b5a66a8cf3496e85068d40600598",
+ "bytes": 5396
},
{
"path": "apps/web/src/components/ResultExample.tsx",
@@ -883,8 +898,8 @@
},
{
"path": "apps/web/src/components/ResultScreen.test.tsx",
- "sha256": "f8e0e257e0106bb792deb265842b461cb4263a7bba05b165e559278bb1b1c8a8",
- "bytes": 11671
+ "sha256": "1794413e19a4604e2a614d6b9f91123c177a17a91d82334ab131e3d02be05a2a",
+ "bytes": 12825
},
{
"path": "apps/web/src/components/ReviewDialog.tsx",
@@ -931,6 +946,11 @@
"sha256": "8ba883cf96879aa2254fd9d4919a4d33c0fc93d9e9bc77c8e653a58dc521c977",
"bytes": 742
},
+ {
+ "path": "apps/web/src/components/SyntheticDataNotice.tsx",
+ "sha256": "df4bfdb7ce007d47e263a5920d317bd0b2d3b47bef3efdb718dc6724864bc659",
+ "bytes": 1093
+ },
{
"path": "apps/web/src/components/TestActionDraft.test.tsx",
"sha256": "f8b421b3d715d61ccc6df44e33ea1ab6196d69f33dc6d0fbef30be06bb6eccc9",
@@ -1198,8 +1218,8 @@
},
{
"path": "apps/web/src/components/hypothesisView.ts",
- "sha256": "b44f62a2a2f4fea84b3c515770172f8c83243b78f24a6b425dbb4eed733890b3",
- "bytes": 4319
+ "sha256": "a98e0ff4289c89f57fb7f599b137f77f7d7a197d974657aca1b567b4dc7f6d9a",
+ "bytes": 4530
},
{
"path": "apps/web/src/components/judgmentRecordText.test.ts",
@@ -1278,8 +1298,8 @@
},
{
"path": "apps/web/src/components/statusLabels.ts",
- "sha256": "ae64b841f2787503a58fe55e44acc062651fd0c18612a4f5913b27dd98f62441",
- "bytes": 9561
+ "sha256": "df8adae9511fb200d9cd52058c5e2d746a60ad3d0cea2e2aeab11380132fe612",
+ "bytes": 9569
},
{
"path": "apps/web/src/components/testActionDraftText.ts",
@@ -1423,8 +1443,8 @@
},
{
"path": "apps/web/src/workstation.css",
- "sha256": "98455faba74c66ddd2589f44e0efab2a9eea7bc2540ddd8bc76aa84b894ff070",
- "bytes": 58207
+ "sha256": "267ede96732993f45f0e2e790555d5645322065580145536ca81cbc9a2a10256",
+ "bytes": 58447
},
{
"path": "apps/web/timeline-preview.html",
@@ -1458,8 +1478,8 @@
},
{
"path": "config/atomicity-paths.json",
- "sha256": "7942ea74409013ca29f7cfd7bb4eddeda8e24486af54465a9b93cc585741e1f3",
- "bytes": 3325795
+ "sha256": "bf9e73eebbaf8293b9afdcbb938eaa039afb2eeefa3e4ae63b9375ec2b7cabb5",
+ "bytes": 3331085
},
{
"path": "config/atomicity-requirements.json",
@@ -1698,8 +1718,8 @@
},
{
"path": "docs/architecture/rpc-method-catalog.md",
- "sha256": "fbbf54d025d85e73ae93a32dc071de988ea87c5e554ea7461d58cdd434d06a85",
- "bytes": 14166
+ "sha256": "c4fa7fb1183ff70cd5c1c79a7fc3099caaddbb5cac5ffd4f51e63524f008ec33",
+ "bytes": 14182
},
{
"path": "docs/architecture/user-activity-projection.md",
@@ -2448,8 +2468,8 @@
},
{
"path": "schemas/protocol/public-method-catalog.json",
- "sha256": "9c8e14688ccd2bc92bc2487213c7dd4fbe2cd625572a0673f1f2282a7b29e95a",
- "bytes": 157952
+ "sha256": "8a74465cef7448dd9315aec0d496cfba39685e6f5e693b059655cf079689e5e4",
+ "bytes": 158337
},
{
"path": "schemas/protocol/restore-apply-input.schema.json",
@@ -3628,8 +3648,8 @@
},
{
"path": "src/thoth/adapters/storage/projects.py",
- "sha256": "c71c056c3a4937adc624f7b14c6f814f678ce908025eef8a9be1cae2d2d46505",
- "bytes": 5271
+ "sha256": "1ca1ba845696b29ea386a88cde1e826008a40791ed552c9a3e2c405a21668ca1",
+ "bytes": 5871
},
{
"path": "src/thoth/adapters/storage/receipt_dag.py",
@@ -3738,8 +3758,8 @@
},
{
"path": "src/thoth/application/commands/__init__.py",
- "sha256": "4690d507f942d1fa997d6704c4f36bf935a5e1b2bbf94776088b9d4f88c6aa1c",
- "bytes": 2462
+ "sha256": "2487dec0f6120ed611c5d15185192dbc00c48610aec0d588b3de6d2ff610100e",
+ "bytes": 2570
},
{
"path": "src/thoth/application/commands/action_create_input.py",
@@ -3916,6 +3936,11 @@
"sha256": "067cfcdcfbe62c6a0b059d7f820d14f1254d0c896e5d308da57a303a9a32c520",
"bytes": 20158
},
+ {
+ "path": "src/thoth/application/commands/project_restore.py",
+ "sha256": "b85ebb31635bf9a90f87c955106bb13eb61dc903fb47d04ca2c065b69bacb41a",
+ "bytes": 3391
+ },
{
"path": "src/thoth/application/commands/projections.py",
"sha256": "0ca4838a787612d0e4b94de790bfdb89fa8a64af70f1fa7b68e84d3dff1b165a",
@@ -5208,8 +5233,8 @@
},
{
"path": "src/thoth/apps/management_routes.py",
- "sha256": "7454abd0c5187480b00090a3c31c078a4760735fb28897d25bad02297354b321",
- "bytes": 2949
+ "sha256": "e17ef04ae90adc48b944f0eb0529ca2a4264ba4235467f70b68ac5534f255a84",
+ "bytes": 3272
},
{
"path": "src/thoth/apps/memory_composition.py",
@@ -5278,8 +5303,8 @@
},
{
"path": "src/thoth/apps/runtime.py",
- "sha256": "fe6fafe731562a6d0b301d4f48d6154c3ffc40e59030f28144b243da7b7a8f63",
- "bytes": 26648
+ "sha256": "81f2718bbb0698f6a1c1cc3c83ac6be6f4d46306574052eb6af725b9018fa1d9",
+ "bytes": 26656
},
{
"path": "src/thoth/apps/runtime_types.py",
@@ -6133,8 +6158,8 @@
},
{
"path": "src/thoth/ports/project.py",
- "sha256": "bae4b572d7adce68496411aea718d0392ee3d8fcc892c26f5e847fd293d9f39e",
- "bytes": 458
+ "sha256": "200d58cb8d244aea66d1fe980f8f52d94992cf6f28e571bacdffd68026c466ba",
+ "bytes": 678
},
{
"path": "src/thoth/ports/projectpack.py",
@@ -6258,8 +6283,8 @@
},
{
"path": "src/thoth/protocol/notification_catalog.py",
- "sha256": "122cfaa053962ecc6ca21e44fe7c14e402efcfc637b406ac98d6e6a7aa394234",
- "bytes": 17850
+ "sha256": "da3c37d824d3636a1c3c2c1582a0cdea5cc750eb8ebe6b21fc790bd8d384ce38",
+ "bytes": 17901
},
{
"path": "src/thoth/protocol/notification_rules.py",
@@ -6278,8 +6303,8 @@
},
{
"path": "src/thoth/protocol/registry.py",
- "sha256": "ae6ee4f18638e6a0fa4b21aff2fed4ce2eaad50059b9fc38710cb05b8a65cce8",
- "bytes": 13225
+ "sha256": "413c7b4ac5079afac8e0cadc33126bfdd26c198ccdee6974a14b5be94608cee2",
+ "bytes": 13249
},
{
"path": "src/thoth/protocol/schema_export.py",
@@ -6433,7 +6458,7 @@
},
{
"path": "tests/architecture/test_public_protocol_exact_parity.py",
- "sha256": "2cbc2a3db6641a5207120047e864a7649c98447e09fcb7720a5cfaef7c802f0e",
+ "sha256": "c228a0d87dcf771049d24a021737002013645f824d596ec5e8f06dbe8c7ea44b",
"bytes": 5631
},
{
@@ -6813,8 +6838,8 @@
},
{
"path": "tests/fixtures/runtime_registration_events.json",
- "sha256": "937f5bdf6a1861031537acc2afb592dad64f1ba45095d4e3fb1366b432b3e84b",
- "bytes": 112131
+ "sha256": "18b4236f9f0d283985bbfc15b170d9c62bfaa328248ea0f7974bbc76b9bf39d3",
+ "bytes": 112407
},
{
"path": "tests/fixtures/structured_source_fixture.py",
@@ -7666,6 +7691,11 @@
"sha256": "c767beb6bff4187d94ec7cd8fc5effd76fea81ad566f85f2ee835e99d07e979b",
"bytes": 10230
},
+ {
+ "path": "tests/integration/test_project_restore_rpc.py",
+ "sha256": "5ea3b65a3355d88a42351b48990d183a25fe6fc1cbc323d8e1543e25fbfba79e",
+ "bytes": 7902
+ },
{
"path": "tests/integration/test_projectpack_rpc.py",
"sha256": "28d4704b5dcdec45b4e76c5314429cc54f6273ac910ae2b82b452168f68fe841",
diff --git a/apps/web/src/api/resultEvidence.test.ts b/apps/web/src/api/resultEvidence.test.ts
index 70b91b9..9cbe68d 100644
--- a/apps/web/src/api/resultEvidence.test.ts
+++ b/apps/web/src/api/resultEvidence.test.ts
@@ -87,3 +87,27 @@ it("binds the historical manifest body without requiring operation-only envelope
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));
+ // 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);
+ const { contract_version: _contract, ...withoutWrapperKey } = partial.result;
+ void _contract;
+ 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("연결을 확인하지 못했습니다");
+});
+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("연결을 확인하지 못했습니다");
+});
diff --git a/apps/web/src/api/resultEvidence.ts b/apps/web/src/api/resultEvidence.ts
index bdf551f..f1ea7d2 100644
--- a/apps/web/src/api/resultEvidence.ts
+++ b/apps/web/src/api/resultEvidence.ts
@@ -31,6 +31,11 @@ export function resultContentKey(value: unknown): string {
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]));
+}
function mismatch(): never { throw new Error("선택한 답변과 근거의 연결을 확인하지 못했습니다. 답변을 다시 확인해 주세요."); }
function answerContent(value: Record, thread: string, epoch: unknown, terminal: unknown) {
@@ -56,12 +61,13 @@ async function read(selection: ResultSelection, displayed: Record결론 상태: {label}
{facts}
{rows.length > 0 && }
- 답할 수 있는 범위: {rows.length ? `기준 충족 ${supported}/${rows.length} · 확인 필요 ${needsCheck}` : "평가기준 정보를 아직 읽지 못했습니다"}
+ 답할 수 있는 범위: {rows.length ? `답변 점검 항목 ${rows.length}개 중 충족 ${supported} · 확인 필요 ${needsCheck}` : "답변 점검 항목 정보를 아직 읽지 못했습니다"}
{counter && {counter}
}
{next && 다음 확보: {next}
}
;
diff --git a/apps/web/src/components/ArchivedProjects.tsx b/apps/web/src/components/ArchivedProjects.tsx
new file mode 100644
index 0000000..3db7fb7
--- /dev/null
+++ b/apps/web/src/components/ArchivedProjects.tsx
@@ -0,0 +1,32 @@
+import { Button, Callout } from "@blueprintjs/core";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { rpc } from "../api/rpcClient";
+import type { ProjectSummary } from "../types";
+
+/** Archived projects are read-only and hidden from the list; this shows them on demand so one can be restored. */
+export function ArchivedProjects({ scopeKey }: { scopeKey: string }) {
+ const client = useQueryClient();
+ const [open, setOpen] = 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 restore = useMutation({
+ mutationFn: (target: ProjectSummary) => rpc("project/restore", { project_id: target.project_id, expected_revision: target.revision }, crypto.randomUUID()),
+ onSuccess: () => client.invalidateQueries({ queryKey: ["projects"] }),
+ });
+ const rows = (archived.data?.value.projects ?? []).filter(item => item.lifecycle === "ARCHIVED_READ_ONLY");
+ return
+
setOpen(value => !value)}>보관한 프로젝트 보기
+ {open &&
+ {archived.isPending &&
보관한 프로젝트를 읽는 중…
}
+ {archived.error &&
보관한 프로젝트를 읽지 못했습니다. 없다는 뜻은 아닙니다. }
+ {archived.isSuccess && rows.length === 0 &&
보관한 프로젝트가 없습니다.
}
+ {rows.map(item =>
+ {item.name}
+ restore.mutate(item)}>되살리기
+
)}
+ {restore.error &&
되살리지 못했습니다. {restore.error.message} }
+
}
+
;
+}
diff --git a/apps/web/src/components/ContractV3Screens.test.tsx b/apps/web/src/components/ContractV3Screens.test.tsx
index b1df628..c3ea392 100644
--- a/apps/web/src/components/ContractV3Screens.test.tsx
+++ b/apps/web/src/components/ContractV3Screens.test.tsx
@@ -26,6 +26,12 @@ const result = (tests: Json[], conditions?: string[]) => {
return copy as unknown as Record;
};
const order = (html: string, names: string[]) => names.map(name => html.indexOf(name));
+// Gives the first hypotheses a statement (an empty string means none was written).
+const named = (value: Record, statements: string[]) => {
+ const hypotheses = (value as unknown as { portfolio: { hypotheses: Json[] } }).portfolio.hypotheses;
+ statements.forEach((statement, at) => { hypotheses[at].statement = statement; });
+ return value;
+};
const sorted = (positions: number[]) => positions.every((value, at) => value >= 0 && (at === 0 || value > positions[at - 1]));
const section = (value: Record) => renderToStaticMarkup();
@@ -83,23 +89,32 @@ it("shows the model's refutation conditions apart, as an unconfirmed suggestion,
});
it("lists what each hypothesis is expected to give for a test, in words, with how many sources it rests on", async () => {
- await mount(result([test("t1", "시험", rows([0, "나쁘다", ["span:a", "span:b"]], [1, "같다"], [2, "모름"]))]));
+ await mount(named(result([test("t1", "시험", rows([0, "나쁘다", ["span:a", "span:b"]], [1, "같다"], [2, "모름"]))]), ["탐지 정의 오류", "라벨 오류", "표본 부족"]));
const text = card().querySelector("details.expected-results")!.textContent!;
expect(text).toContain("각 가설의 예상 결과(AI 제안)");
- expect(text).toContain("가설 1: 나쁘다 · 근거 2개");
- expect(text).toContain("가설 2: 같다");
- expect(text).toContain("가설 3: 모름");
+ expect(text).toContain("가설 1(탐지 정의 오류): 나쁘다 · 근거 2개");
+ expect(text).toContain("가설 2(라벨 오류): 같다");
+ expect(text).toContain("가설 3(표본 부족): 모름");
expect(text).toContain("확률이나 점수가 아닙니다");
expect(text).not.toMatch(LEAK);
});
-it("marks a prediction that rests on no source, and leaves 모름 and sourced predictions as they were", async () => {
- await mount(result([test("t1", "시험", rows([0, "나쁘다", ["span:a"]], [1, "같다"], [2, "모름"]))]));
+it("marks a prediction that no material confirmed, and leaves 모름 and sourced predictions as they were", async () => {
+ await mount(named(result([test("t1", "시험", rows([0, "나쁘다", ["span:a"]], [1, "같다"], [2, "모름"]))]), ["가", "나", "다"]));
const lines = [...card().querySelectorAll("details.expected-results li")].map(item => item.textContent);
- expect(lines).toEqual(["가설 1: 나쁘다 · 근거 1개", "가설 2: 같다 · 근거 없는 예측", "가설 3: 모름"]);
+ expect(lines).toEqual(["가설 1(가): 나쁘다 · 근거 1개", "가설 2(나): 같다 · 자료로 확인되지 않은 예상", "가설 3(다): 모름"]);
expect(lines.join(" ")).not.toMatch(BANNED);
});
+it("shortens a long hypothesis statement in the list and writes only the number when the statement is empty", async () => {
+ const long = "탐지 정의와 판정 기준이 서로 달라 같은 장면을 다르게 세는 오류가 생긴다고 보는 설명입니다";
+ await mount(named(result([test("t1", "시험", rows([0, "나쁘다", ["span:a"]], [1, "같다"], [2, "모름"]))]), [long, "", "표본 부족"]));
+ const lines = [...card().querySelectorAll("details.expected-results li")].map(item => item.textContent!);
+ expect(lines[0]).toBe(`가설 1(${long.slice(0, 39)}…): 나쁘다 · 근거 1개`);
+ expect(lines[1]).toBe("가설 2: 같다 · 자료로 확인되지 않은 예상");
+ expect(lines[2]).toBe("가설 3(표본 부족): 모름");
+});
+
it("shows nothing of v3 on an old result", async () => {
await mount(result([test("t1", "시험")]));
expect(card().querySelector(".ai-refutation")).toBeNull();
diff --git a/apps/web/src/components/ConversationTimeline.tsx b/apps/web/src/components/ConversationTimeline.tsx
index d948679..7980263 100644
--- a/apps/web/src/components/ConversationTimeline.tsx
+++ b/apps/web/src/components/ConversationTimeline.tsx
@@ -15,6 +15,7 @@ import { ResearchResultCard, type ResearchDetail } from "./ResearchResultCard";
import { ResearchLiveProgress } from "./ResearchLiveProgress";
import { resultUsageLine } from "./resultUsage";
import { useHypothesisLinks } from "./useHypothesisLinks";
+import { SyntheticDataNotice } from "./SyntheticDataNotice";
export function ConversationTimeline({ projectId, threadId, status, sourceUris, onDetail, onContinue, onOpenHistory, onOpenTrace, onRetry, onResume, resuming = false, queuedInstruction = false }: {
projectId: string; threadId: string; status?: ResearchStatus; sourceUris?: string[]; queuedInstruction?: boolean;
@@ -66,6 +67,7 @@ export function ConversationTimeline({ projectId, threadId, status, sourceUris,
const resumeOperation = liveStatus?.request?.operation_id;
const emptyExistingThread = Boolean(threadId && query.data && turns.length === 0 && !currentQuestion && !running);
return
+ {!example && }
{query.hasNextPage && void query.fetchNextPage()}>이전 대화 불러오기 }
{query.error && {query.data ? "대화를 다시 확인하지 못했습니다. 마지막으로 불러온 답변을 표시하며, 최신 상태는 확인되지 않았습니다. " : "대화를 불러오지 못했습니다. "} void query.refetch()}>다시 읽기 }
{query.data?.pages.some(page => page.limited) && 조회 범위를 넘는 과거 이력이 있습니다. 기록에서 확인할 수 있습니다. }
diff --git a/apps/web/src/components/ConversationTimelineRefetch.test.tsx b/apps/web/src/components/ConversationTimelineRefetch.test.tsx
index abf633a..3b835be 100644
--- a/apps/web/src/components/ConversationTimelineRefetch.test.tsx
+++ b/apps/web/src/components/ConversationTimelineRefetch.test.tsx
@@ -72,8 +72,8 @@ it("keeps the saved answer with a stale-read warning after window refocus fails
await settle(() => Boolean(container.textContent?.includes("최신 상태는 확인되지 않았습니다")));
expect(container.textContent).toContain("저장된 답변 1");
expect(container.textContent).not.toContain("연구를 완료하지 못했습니다");
- // only reads; the marks for hypotheses that came from a trace row are one more read-only query
- expect(vi.mocked(rpc).mock.calls.every(([method]) => ["thread/activity/list", "operation/result/read", "hypothesis/link/list"].includes(method))).toBe(true);
+ // only reads; the marks for hypotheses that came from a trace row and the synthetic-data line (a trace read) are more read-only queries
+ expect(vi.mocked(rpc).mock.calls.every(([method]) => ["thread/activity/list", "operation/result/read", "hypothesis/link/list", "trace/read"].includes(method))).toBe(true);
await returnToWindow("success");
await settle(() => !container.textContent?.includes("최신 상태는 확인되지 않았습니다"));
expect(container.textContent).toContain("저장된 답변 1");
diff --git a/apps/web/src/components/ConversationTimelineSynthetic.test.tsx b/apps/web/src/components/ConversationTimelineSynthetic.test.tsx
new file mode 100644
index 0000000..0c222a5
--- /dev/null
+++ b/apps/web/src/components/ConversationTimelineSynthetic.test.tsx
@@ -0,0 +1,74 @@
+// @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 { rpc } from "../api/rpcClient";
+import type { TraceItem, TraceView } from "../api/trace";
+import { ConversationTimeline } from "./ConversationTimeline";
+
+vi.mock("../api/rpcClient", async importOriginal => ({
+ ...await importOriginal(),
+ rpc: vi.fn(),
+}));
+
+let root: Root | undefined;
+let container: HTMLDivElement;
+let client: QueryClient;
+
+const item = (title: string, fields: Record = {}): TraceItem => ({ item_id: "i1", item_key: "k1", kind: "REQUIREMENT", title, fields, source_span_refs: [] });
+const view = (...items: TraceItem[]): TraceView => ({ record_digest: null, set_digest: "d", items, links: [], rules: [], results: [], verdicts: [], confirmations: [], pending_changes: [] });
+const conversation = { operation_id: "", state: "SUCCEEDED", value: { conversation: { turns: [], next_before_epoch: null, history_limited: false } } };
+
+afterEach(async () => {
+ if (root) await act(async () => root!.unmount());
+ root = undefined; client?.clear(); container?.remove(); vi.resetAllMocks();
+});
+
+async function mount(trace: TraceView | Error) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ vi.mocked(rpc).mockImplementation(async method => {
+ if (method === "trace/read") { if (trace instanceof Error) throw trace; return { operation_id: "", state: "SUCCEEDED", value: trace }; }
+ return conversation;
+ });
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ await act(async () => root!.render(
+ undefined}/> ));
+ for (let index = 0; index < 30; index += 1) {
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
+ if (container.textContent?.includes("이 작업의 대화 기록을 현재 화면에서 불러올 수 없습니다")) {
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)); }); // let the trace read settle too
+ return;
+ }
+ }
+ throw new Error("The conversation never finished loading");
+}
+const notice = () => container.querySelector(".conversation-synthetic-notice");
+
+it("shows a thin synthetic-data line above the conversation when the project's trace carries the demo marker", async () => {
+ await mount(view(item("SYNTHETIC DEMO DATA - 요구사항")));
+ expect(notice()?.textContent).toBe("합성 데이터입니다. 실제 측정값이 아니며 공학 용도로 쓸 수 없습니다.");
+ expect(container.firstElementChild!.firstElementChild).toBe(notice());
+ const calls = vi.mocked(rpc).mock.calls.map(([method]) => method);
+ expect(calls.filter(method => method === "trace/read")).toHaveLength(1);
+});
+
+it("also recognises the synthetic_notice field the trace banner reads", async () => {
+ await mount(view(item("요구사항", { synthetic_notice: "SYNTHETIC DEMO DATA - NOT MEASURED" })));
+ expect(notice()).not.toBeNull();
+});
+
+it("shows nothing for an ordinary project", async () => {
+ await mount(view(item("검출율 요구사항", { note: "실측" })));
+ expect(notice()).toBeNull();
+ expect(container.textContent).not.toContain("합성 데이터");
+});
+
+it("shows nothing when the trace cannot be read or has no trace table", async () => {
+ await mount(new Error("denied"));
+ expect(notice()).toBeNull();
+ await act(async () => root!.unmount()); root = undefined; client.clear(); container.remove();
+ await mount(view());
+ expect(notice()).toBeNull();
+});
diff --git a/apps/web/src/components/ExpectedResults.tsx b/apps/web/src/components/ExpectedResults.tsx
index e553e5c..ddc3bb4 100644
--- a/apps/web/src/components/ExpectedResults.tsx
+++ b/apps/web/src/components/ExpectedResults.tsx
@@ -1,6 +1,9 @@
import { objectList, stringValues, textValue } from "../api/presentation";
import type { HypothesisRow } from "./hypothesisView";
+const SHORT = 40;
+const shortStatement = (text: string) => (text.length > SHORT ? text.slice(0, SHORT - 1) + "…" : text);
+
/** What the generator said each hypothesis would give for one test (contract v3), in words. An unconfirmed suggestion; no probability or score. */
export function ExpectedResults({ test, rows }: { test: Record; rows: HypothesisRow[] }) {
const table = objectList(test.expected_by_hypothesis);
@@ -11,8 +14,9 @@ export function ExpectedResults({ test, rows }: { test: Record;
const index = rows.findIndex(row => row.id === textValue(item.hypothesis_id));
const sources = stringValues(item.basis).length;
const expected = textValue(item.expected);
- const note = sources > 0 ? " · 근거 " + sources + "개" : expected.trim() === "모름" ? "" : " · 근거 없는 예측";
- return {"가설 " + (index >= 0 ? index + 1 : "?") + ": " + expected + note} ;
+ const note = sources > 0 ? " · 근거 " + sources + "개" : expected.trim() === "모름" ? "" : " · 자료로 확인되지 않은 예상";
+ const statement = index >= 0 ? shortStatement(rows[index].statement.trim()) : "";
+ return {"가설 " + (index >= 0 ? index + 1 : "?") + (statement ? "(" + statement + ")" : "") + ": " + expected + note} ;
})}
모델이 낸 제안이며 사람이 확인하기 전에는 미확정입니다. 같은 말은 같은 결과로 봅니다. 확률이나 점수가 아닙니다.
;
diff --git a/apps/web/src/components/HypothesisCompare.test.tsx b/apps/web/src/components/HypothesisCompare.test.tsx
index 99d752b..4f13d90 100644
--- a/apps/web/src/components/HypothesisCompare.test.tsx
+++ b/apps/web/src/components/HypothesisCompare.test.tsx
@@ -47,7 +47,7 @@ it("merges evidence_refs with the older support field and counts each source onc
it("shows counts, the counter-evidence search state and no internal ids on the cards", async () => {
await mount(result(hyps => { hyps[1].critical_review = { terminal: "UNRESOLVED_NO_RESULTS", reasons: ["x"] }; }));
const [first, second] = cards();
- expect(first.textContent).toContain(`뒷받침 3 · 반박 - · 미평가 ${selected.length - 3}`);
+ expect(first.textContent).toContain(`뒷받침 3 · 반박 - · 아직 대조 안 한 근거 ${selected.length - 3}/${selected.length}`);
expect(first.textContent).toContain("반대 근거 탐색 안 함");
expect(second.textContent).toContain("반대 근거 탐색에서 결과가 없어 미해결");
expect(second.textContent).not.toContain("반대 근거 탐색 안 함");
@@ -64,7 +64,10 @@ it("filters the relation table to counter and unevaluated cells by default", asy
expect(summary).toBe(`표시 ${selected.length}/전체 ${selected.length}`);
const labels = Array.from(table.querySelectorAll("tbody td .bp6-tag, tbody td .relation-cell")).map(item => item.textContent);
expect(labels).toContain("지지");
- expect(labels).toContain("미평가");
+ expect(labels).toContain("대조 안 함");
+ expect(labels).not.toContain("미평가");
+ expect(node.textContent).toContain("반박·대조 안 한 것만");
+ expect(node.querySelector("table.relation-table")!.closest("section")!.textContent).toContain("선택된 근거 전체 중 이 가설과 아직 견주어 보지 않은 근거의 수");
const all = Array.from(node.querySelectorAll("button")).find(button => button.textContent === "전체 보기")!;
await act(async () => all.click());
expect(node.querySelector(".relation-summary")!.textContent).toBe(`표시 ${selected.length}/전체 ${selected.length}`);
@@ -108,6 +111,26 @@ it("omits the unevaluated count when the answer's selected evidence list is unkn
const value = result(); delete value.selected_evidence_refs;
await mount(value);
expect(cards()[0].textContent).toContain("뒷받침 3 · 반박 -");
- expect(cards()[0].textContent).not.toContain("미평가");
+ expect(cards()[0].textContent).not.toContain("대조 안 한");
expect(node.querySelector("table.relation-table")).toBeNull();
});
+it("shows the counter-evidence count when sources exist even though no search was recorded, and keeps the search state apart", async () => {
+ await mount(result(hyps => { hyps[0].counterevidence_refs = extra.slice(0, 3); }));
+ const text = cards()[0].textContent!;
+ expect(text).toContain("반박 3");
+ expect(text).not.toContain("반박 -");
+ expect(text).toContain("반대 근거 탐색 안 함"); // whether it was searched is a separate fact
+ expect(text).not.toContain("반박 근거를 찾지 못함");
+ expect(cards()[1].textContent).toContain("반박 -"); // nothing found and nothing searched stays a dash
+});
+
+it("shows a real zero and the not-found note for a finished search, and the count when it found some", async () => {
+ await mount(result(hyps => {
+ hyps[0].critical_review = { terminal: "UNRESOLVED_NO_RESULTS", reasons: ["x"] };
+ hyps[1].critical_review = { terminal: "UNRESOLVED_NO_RESULTS", reasons: ["x"] }; hyps[1].counterevidence_refs = extra.slice(0, 2);
+ }));
+ expect(cards()[0].textContent).toContain("반박 0");
+ expect(cards()[0].textContent).toContain("반박 근거를 찾지 못함 (지지된다는 뜻은 아닙니다)");
+ expect(cards()[1].textContent).toContain("반박 2");
+ expect(cards()[1].textContent).not.toContain("반박 근거를 찾지 못함");
+});
diff --git a/apps/web/src/components/HypothesisCompare.tsx b/apps/web/src/components/HypothesisCompare.tsx
index e518c48..0fc813c 100644
--- a/apps/web/src/components/HypothesisCompare.tsx
+++ b/apps/web/src/components/HypothesisCompare.tsx
@@ -24,7 +24,7 @@ import { STANDING_NOTE, standingLine } from "./judgmentRecordText";
import type { SameView } from "../api/hypothesisSame";
import { SameHypothesis } from "./SameHypothesis";
-const relationLabel: Record = { SUPPORT: "지지", COUNTER: "반박", NOT_APPLICABLE: "해당 없음", UNEVALUATED: "미평가" };
+const relationLabel: Record = { SUPPORT: "지지", COUNTER: "반박", NOT_APPLICABLE: "해당 없음", UNEVALUATED: "대조 안 함" };
const relationIntent = { SUPPORT: "success", COUNTER: "danger", NOT_APPLICABLE: "none", UNEVALUATED: "none" } as const;
const standingOf = (items: DiscriminationItem[] | undefined, id: string) => items?.find(item => item.hypothesis_id === id)?.standing;
@@ -63,8 +63,8 @@ export function HypothesisCompare({ result, review, projectId, links, distributi
{row.counterTerminal ? `반대 근거 탐색: ${counterReviewLabel(row.counterTerminal)}` : "반대 근거 탐색 안 함"}
{row.counterTerminal && row.counter.length === 0 && 반박 근거를 찾지 못함 (지지된다는 뜻은 아닙니다)
}
- 뒷받침 {row.support.length} {" · "}반박 {row.counterTerminal ? row.counter.length : "-"}
- {row.unevaluated !== null && <>{" · "}미평가 {row.unevaluated} >}
+ 뒷받침 {row.support.length} {" · "}반박 {row.counter.length > 0 || row.counterTerminal ? row.counter.length : "-"}
+ {row.unevaluated !== null && <>{" · "}아직 대조 안 한 근거 {row.unevaluated}/{selected?.length} >}
반대 근거를 찾을 질문 {row.counterQueries.map((query, j) => {query}
)}
{row.aiRefutation.length > 0 && AI 제안(미확정) 기각 조건
@@ -83,10 +83,10 @@ export function HypothesisCompare({ result, review, projectId, links, distributi
)}
{table && shown &&
근거와 가설의 관계
- AI가 판단한 관계입니다. 칸을 직접 고치지 않고, 자료 추가나 방향 지시로 다시 조사하게 합니다. 아직 살펴보지 않은 칸은 "미평가"로 보입니다.
+ AI가 판단한 관계입니다. 칸을 직접 고치지 않고, 자료 추가나 방향 지시로 다시 조사하게 합니다. 아직 견주어 보지 않은 칸은 "대조 안 함"으로 보입니다. 가설 카드의 "아직 대조 안 한 근거 N/M"은 선택된 근거 전체 중 이 가설과 아직 견주어 보지 않은 근거의 수입니다.
{counterNote && {counterNote}
}
- setOnlyOpen(true)}>반박·미평가만
+ setOnlyOpen(true)}>반박·대조 안 한 것만
setOnlyOpen(false)}>전체 보기
{`표시 ${shown.length}/전체 ${table.length}`}
diff --git a/apps/web/src/components/HypothesisScreen.test.tsx b/apps/web/src/components/HypothesisScreen.test.tsx
index 6dc0848..efb3bb7 100644
--- a/apps/web/src/components/HypothesisScreen.test.tsx
+++ b/apps/web/src/components/HypothesisScreen.test.tsx
@@ -109,9 +109,9 @@ it("writes unevaluated cells as text, marks a row that tells hypotheses apart, a
await mount(value);
const rows = Array.from(node.querySelectorAll("table.relation-table tbody tr"));
expect(rows[0].textContent).toContain("가설을 가름");
- expect(rows[0].textContent).toContain("미평가"); // written as a word, not an empty cell
+ expect(rows[0].textContent).toContain("대조 안 함"); // written as a word, not an empty cell
expect(rows[1].textContent).not.toContain("가설을 가름");
- expect(Array.from(rows[1].querySelectorAll("td")).slice(1).every(cell => cell.textContent!.includes("미평가"))).toBe(true);
+ expect(Array.from(rows[1].querySelectorAll("td")).slice(1).every(cell => cell.textContent!.includes("대조 안 함"))).toBe(true);
});
it("does not mark a row as telling hypotheses apart when only one is evaluated and the rest are not", () => {
diff --git a/apps/web/src/components/LiveProjectWorkspace.tsx b/apps/web/src/components/LiveProjectWorkspace.tsx
index a5e10cb..e8dea2f 100644
--- a/apps/web/src/components/LiveProjectWorkspace.tsx
+++ b/apps/web/src/components/LiveProjectWorkspace.tsx
@@ -30,6 +30,7 @@ import { isHttpUrl, sourceCardHost, sourceCardTitle } from "../sourceDisplay";
import { eyebrowLabel } from "./statusLabels";
import { WorkspaceErrorBoundary } from "./WorkspaceErrorBoundary";
import { ConversationTimeline } from "./ConversationTimeline";
+import { ArchivedProjects } from "./ArchivedProjects";
import { ResearchComposer } from "./ResearchComposer";
import { ResearchSidePanel, type SidePanelTab } from "./ResearchSidePanel";
import { TracePage } from "./TracePage";
@@ -187,16 +188,17 @@ function ProjectNavItem({ project, active, removing, onOpen, onRemove }: {
;
}
-export function ProjectLanding({ projects, loading = false, contextRestoreIssue = null, setupIssue = null, onOpen, onCreated }: {
+export function ProjectLanding({ projects, loading = false, contextRestoreIssue = null, setupIssue = null, onOpen, onCreated, openCreate = false, onOpenCreateHandled }: {
projects: ProjectSummary[];
loading?: boolean;
contextRestoreIssue?: "CORRUPT" | "UNAVAILABLE" | null;
setupIssue?: WorkspaceSetupIssue | null;
onOpen: (projectId: string) => void;
- onCreated: (projectId: string) => void;
+ onCreated: (projectId: string) => void; openCreate?: boolean; onOpenCreateHandled?: () => void;
}) {
const [creating,setCreating] = useState(false);
useEffect(()=>{if(!loading&&projects.length===0)setCreating(true);},[loading,projects.length]);
+ useEffect(()=>{if(openCreate){setCreating(true);onOpenCreateHandled?.();}},[openCreate,onOpenCreateHandled]);
const restoreNotice = contextRestoreIssue && {contextRestoreIssue === "CORRUPT"
? "마지막 작업 위치 기록을 읽지 못했습니다. 원본을 자동 삭제하지 않았습니다."
: "이 브라우저의 마지막 작업 위치를 읽지 못했습니다. 저장소 접근과 작업 공간 식별자를 확인하세요."}
@@ -223,6 +225,7 @@ export function LiveProjectWorkspace() {
const [selectionVersion,setSelectionVersion] = useState(0);
const [rowOrigin,setRowOrigin] = useState(null);
const [traceFocus,setTraceFocus] = useState(null);
+ const [openCreate,setOpenCreate] = useState(false);
const epoch = useRef(0);
const previousScope = useRef(null);
const pendingAdmission = useRef(null);
@@ -316,7 +319,7 @@ export function LiveProjectWorkspace() {
},
});
const removeProject = (target: ProjectSummary) => {
- if (!window.confirm(`프로젝트 "${target.name}"을 목록에서 제거할까요? 데이터는 보관되고 기본 목록에서 숨겨집니다.`)) return;
+ if (!window.confirm(`프로젝트 "${target.name}"을 보관할까요? 보관하면 읽기 전용이 되고 목록에서 숨겨집니다. '보관한 프로젝트 보기'에서 되살릴 수 있습니다.`)) return;
archiveProject.mutate(target);
};
if (readyQuery.isPending || (readyQuery.isSuccess && !ready)) {
@@ -356,7 +359,7 @@ export function LiveProjectWorkspace() {
}
return
@@ -383,7 +386,7 @@ export function LiveProjectWorkspace() {
onUserInput={cancelOpenLatest} onAdmitted={threadId=>{pendingAdmission.current=threadId;setContext(current=>({...current,threadId}));}}
rowOrigin={rowOrigin} onOriginDone={()=>setRowOrigin(null)} onInvestigate={investigate} traceFocus={traceFocus} onOpenTrace={openTraceRow}/> :
isTimelineExampleRequested() && page === "research" ? :
- {missingSelection&&{missingSelection==="project"?"저장된 프로젝트를 현재 목록에서 확인하지 못해 선택을 비웠습니다.":"저장된 작업 선택 확인 필요"} 저장된 초안은 자동 제출하지 않습니다. }{contextStoreFailed&&!hosted&&이 브라우저에 마지막 작업 위치를 저장하지 못했습니다. 재시작 후 자동 복원이 제한될 수 있습니다. }{!hosted&&!validWorkspaceId(workspaceId)&&워크스페이스 식별자를 확인하지 못해 브라우저 재시작용 초안 저장을 사용할 수 없습니다. }{page==="settings"?<>{hosted?운영 정보와 사용 현황은 프로젝트를 선택한 뒤 확인할 수 있습니다. :모델 연결 프로젝트별 기본 모델은 프로젝트를 선택한 뒤 설정합니다.
}>:{if(renderedEpoch===epoch.current)chooseProject(projectId,"resources");}}/>} }
+ {missingSelection&&{missingSelection==="project"?"저장된 프로젝트를 현재 목록에서 확인하지 못해 선택을 비웠습니다.":"저장된 작업 선택 확인 필요"} 저장된 초안은 자동 제출하지 않습니다. }{contextStoreFailed&&!hosted&&이 브라우저에 마지막 작업 위치를 저장하지 못했습니다. 재시작 후 자동 복원이 제한될 수 있습니다. }{!hosted&&!validWorkspaceId(workspaceId)&&워크스페이스 식별자를 확인하지 못해 브라우저 재시작용 초안 저장을 사용할 수 없습니다. }{page==="settings"?<>{hosted?운영 정보와 사용 현황은 프로젝트를 선택한 뒤 확인할 수 있습니다. :모델 연결 프로젝트별 기본 모델은 프로젝트를 선택한 뒤 설정합니다.
}>:setOpenCreate(false)} contextRestoreIssue={contextRestoreIssue} setupIssue={setupIssue} projects={visibleProjects} loading={projectsQuery.isPending||projectsQuery.isFetching} onOpen={openProject} onCreated={projectId=>{if(renderedEpoch===epoch.current)chooseProject(projectId,"resources");}}/>} }
;
}
diff --git a/apps/web/src/components/ModelSettings.test.tsx b/apps/web/src/components/ModelSettings.test.tsx
index a44fcdf..55018d1 100644
--- a/apps/web/src/components/ModelSettings.test.tsx
+++ b/apps/web/src/components/ModelSettings.test.tsx
@@ -279,8 +279,8 @@ it("a late admission cannot replace a different selected thread",async()=>{
it("ordinary navigation only automatically calls the actual nonmutating query surface",async()=>{
await mount();
- // A8: opening a project also reads its latest work (read-only thread queries), and which hypotheses came from a trace row (a read-only query).
- expect(fixture.calls.filter(call=>!["project/list","thread/list","project/source/list","evidence/list","model/settings/read","workspace/ready","thread/read","thread/activity/list","hypothesis/link/list"].includes(call.method)).map(call=>call.method)).toEqual([]);
+ // A8: opening a project also reads its latest work (read-only thread queries), which hypotheses came from a trace row, and whether the trace is demo data for the conversation's synthetic-data line (read-only queries).
+ expect(fixture.calls.filter(call=>!["project/list","thread/list","project/source/list","evidence/list","model/settings/read","workspace/ready","thread/read","thread/activity/list","hypothesis/link/list","trace/read"].includes(call.method)).map(call=>call.method)).toEqual([]);
await act(async()=>{container.querySelector('[role="tab"][data-tab-id="records"]')?.click();});
expect(fixture.calls.some(call=>call.method==="receipt/verify")).toBe(false);
});
@@ -288,7 +288,7 @@ it("ordinary navigation only automatically calls the actual nonmutating query su
it("project-create retry preserves project ID and key without a globally shared policy binding",async()=>{
await mount();
await act(async()=>{container.querySelector("button.new-project-button")!.click();});
- await act(async()=>{Array.from(container.querySelectorAll(".project-home-list button")).find(button=>button.textContent==="새 프로젝트")!.click();});
+ // the left button now opens the create screen by itself (it used to need a second click on the list's button)
await act(async()=>{
const input=container.querySelector('input[placeholder="연구할 문제 또는 프로젝트 이름"]')!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,"value")!.set!.call(input,"second project");
diff --git a/apps/web/src/components/ProjectArchiveRestore.test.tsx b/apps/web/src/components/ProjectArchiveRestore.test.tsx
new file mode 100644
index 0000000..afcbdc9
--- /dev/null
+++ b/apps/web/src/components/ProjectArchiveRestore.test.tsx
@@ -0,0 +1,115 @@
+// @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 { saveWorkspaceContext } from "../api/research";
+import { LiveProjectWorkspace } from "./LiveProjectWorkspace";
+
+const WORKSPACE = `workspace:${"a".repeat(32)}`;
+const project = (id: string, name: string, lifecycle: string, revision: number) =>
+ ({ project_id: id, name, overlay: "test", lifecycle, revision, cutoff_at: "2026-09-26T00:00:00Z" });
+const fixture = vi.hoisted(() => ({ projects: [] as Record[], calls: [] as { method: string; input: Record }[] }));
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Record) => {
+ fixture.calls.push({ method, input });
+ let value: unknown = {};
+ if (method === "workspace/ready") value = { ready: true, execution_ready: true, workspace_readable: true, setup_complete: true, workspace_id: WORKSPACE,
+ deployment_mode: "LOCAL", model_connected: true, setup: { internet_consent: "ALLOWED" } };
+ if (method === "project/list") value = { projects: fixture.projects.filter(item => input.include_archived === true || item.lifecycle !== "ARCHIVED_READ_ONLY") };
+ if (method === "project/restore") {
+ const target = fixture.projects.find(item => item.project_id === input.project_id)!;
+ Object.assign(target, { lifecycle: "DRAFT", revision: Number(input.expected_revision) + 1 });
+ value = target;
+ }
+ if (method === "thread/list") value = { threads: [{ project_id: input.project_id, thread_id: "t", updated_at: "2026-09-20T00:00:00Z", problem: "stored work", execution_state: "IDLE", lifecycle: "OPEN" }] };
+ if (method === "thread/read") value = { project_id: input.project_id, thread_id: input.thread_id, problem: "stored work", lifecycle: "OPEN", execution_state: "IDLE", current_object_ids: [], operation_state: "SUCCEEDED", current_result: null };
+ if (method === "thread/activity/list") value = { conversation: { turns: [], next_before_epoch: null, history_limited: false } };
+ if (method === "project/source/list") value = { artifacts: [] };
+ if (method === "evidence/list") value = { evidence: [] };
+ if (method === "model/settings/read") value = { settings_digest: null, selection: { provider: null, model: null, reasoning_effort: null }, effective_settings: null, availability: "UNAVAILABLE", reason_code: "MODEL_CAPABILITY_UNKNOWN", model_options: [] };
+ if (method === "model/credential/list") value = { accounts: [] };
+ return { value, state: "SUCCEEDED", operation_id: "op" };
+ },
+}));
+
+let root: Root | undefined;
+let container: HTMLDivElement;
+let client: QueryClient;
+async function settle(check: () => boolean) {
+ for (let i = 0; i < 40; i++) {
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 15)); });
+ if (check()) return;
+ }
+ throw new Error(`did not settle: ${container.textContent?.slice(0, 260)}`);
+}
+const button = (label: string) => [...container.querySelectorAll("button")].find(item => item.textContent === label) as HTMLButtonElement | undefined;
+async function mount(selected: boolean) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ status: "ok", version: "synthetic" }))));
+ if (selected) saveWorkspaceContext("p", "t", { mode: "LOCAL", workspaceId: WORKSPACE });
+ container = document.createElement("div"); document.body.append(container);
+ root = createRoot(container);
+ client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ await act(async () => root!.render( ));
+ await settle(() => Boolean(container.querySelector(selected ? "#live-problem" : ".project-home")));
+}
+afterEach(async () => {
+ if (root) await act(async () => root!.unmount());
+ root = undefined; client?.clear(); container?.remove(); vi.restoreAllMocks(); localStorage.clear(); sessionStorage.clear(); vi.unstubAllGlobals();
+ fixture.projects = []; fixture.calls = [];
+});
+
+const newProject = () => container.querySelector("button.new-project-button") as HTMLButtonElement;
+const nameInput = () => [...container.querySelectorAll("label")].find(item => item.textContent?.startsWith("프로젝트 이름"));
+
+it("opens the create screen from the left button on the first screen, where it used to do nothing", async () => {
+ fixture.projects = [project("p", "old project", "ACTIVE", 1)];
+ await mount(false);
+ expect(nameInput()).toBeUndefined();
+ await act(async () => newProject().click());
+ await settle(() => nameInput() !== undefined);
+ // pressing it again after going back to the list opens it again
+ await act(async () => button("프로젝트 선택으로 돌아가기")!.click());
+ await settle(() => nameInput() === undefined);
+ await act(async () => newProject().click());
+ await settle(() => nameInput() !== undefined);
+});
+
+it("opens the create screen from the left button while a project is open", async () => {
+ fixture.projects = [project("p", "old project", "ACTIVE", 1)];
+ await mount(true);
+ expect(nameInput()).toBeUndefined();
+ await act(async () => newProject().click());
+ await settle(() => nameInput() !== undefined);
+ expect(container.querySelector("#live-problem")).toBeNull();
+});
+
+it("lists archived projects on demand and restores one, then shows it in the normal list", async () => {
+ fixture.projects = [project("p", "old project", "ACTIVE", 1), project("q", "archived demo", "ARCHIVED_READ_ONLY", 4)];
+ await mount(false);
+ expect(container.textContent).not.toContain("archived demo");
+ expect(fixture.calls.filter(call => call.method === "project/list").every(call => call.input.include_archived !== true)).toBe(true);
+ await act(async () => button("보관한 프로젝트 보기")!.click());
+ await settle(() => container.textContent?.includes("archived demo") === true);
+ expect(fixture.calls.some(call => call.method === "project/list" && call.input.include_archived === true)).toBe(true);
+ expect(container.querySelector(".archived-projects")!.textContent).not.toContain("old project");
+ await act(async () => button("되살리기")!.click());
+ await settle(() => container.querySelector(".archived-projects")?.textContent?.includes("보관한 프로젝트가 없습니다") === true);
+ expect(fixture.calls.filter(call => call.method === "project/restore")).toEqual([{ method: "project/restore", input: { project_id: "q", expected_revision: 4 } }]);
+ await settle(() => container.querySelector(".navigation-rail")?.textContent?.includes("archived demo") === true);
+});
+
+it("tells the user that archiving makes a project read-only and that it can be restored", async () => {
+ fixture.projects = [project("p", "old project", "ACTIVE", 1)];
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
+ await mount(false);
+ await act(async () => (container.querySelector('[aria-label="old project 프로젝트 메뉴"]') as HTMLButtonElement).click());
+ await act(async () => button("프로젝트 제거")!.click());
+ expect(confirm).toHaveBeenCalledOnce();
+ const message = String(confirm.mock.calls[0][0]);
+ expect(message).toContain("보관하면 읽기 전용이 되고 목록에서 숨겨집니다.");
+ expect(message).toContain("'보관한 프로젝트 보기'에서 되살릴 수 있습니다.");
+ expect(message).not.toContain("데이터는 보관되고");
+ expect(fixture.calls.some(call => call.method === "project/archive")).toBe(false);
+});
diff --git a/apps/web/src/components/ResearchEvidenceDetail.test.tsx b/apps/web/src/components/ResearchEvidenceDetail.test.tsx
index d21e4f8..62d5237 100644
--- a/apps/web/src/components/ResearchEvidenceDetail.test.tsx
+++ b/apps/web/src/components/ResearchEvidenceDetail.test.tsx
@@ -58,6 +58,7 @@ it("opens the synthetic partial answer's sources and loads 50/50/49 selected ent
expect(node.textContent).toContain(citations[0].exact_text);
expect(node.textContent).toContain("p.1 · line 1");
expect(node.textContent).toContain("확인된 원문 25개");
+ expect(node.textContent).toContain("‘확인된 원문’은 지금까지 불러온 근거 중 원문을 보여 줄 수 있는 것의 수입니다. 한 번에 일부만 불러오므로 선택된 근거 전체보다 적을 수 있고, 나머지는 ‘다음 근거 보기’로 불러옵니다.");
expect(node.textContent).toContain("직접 인용한 개수와 다를 수 있습니다");
expect(node.textContent).not.toContain("활성 근거 0개");
expect(vi.mocked(rpc).mock.calls.map(([method]) => method)).toEqual(Array(4).fill("thread/result/read"));
diff --git a/apps/web/src/components/ResearchFollowupSummary.tsx b/apps/web/src/components/ResearchFollowupSummary.tsx
index 5bff588..1e20951 100644
--- a/apps/web/src/components/ResearchFollowupSummary.tsx
+++ b/apps/web/src/components/ResearchFollowupSummary.tsx
@@ -37,7 +37,7 @@ function coverageIntent(status: CoverageMatrixRow["status"]) {
/** Server progress keys with a user wording; anything else that looks like an internal id is only counted. */
const progressTerms: Record = {
- "result:terminal": "답변이 최종 상태로 기록됐습니다",
+ "result:terminal": "조사 실행이 끝났습니다",
"result:checkpoint": "답변이 중간 점검 상태로 기록됐습니다",
requirements_recorded: "평가 조건이 기록됐습니다",
coverage_assessed: "조건별 충족 여부를 평가했습니다",
@@ -126,7 +126,7 @@ export function ResearchFollowupSummary({
{progress &&
검토 요약 {compactList(progress.progress_items, "기록된 진행 요약이 없습니다.")}
-
직접 근거 {evidenceRefs(rows, answerEvidenceCount)}{onOpenEvidence && 근거 원문 열기 }
+
이 요약에 연결된 근거 {evidenceRefs(rows, answerEvidenceCount)}{onOpenEvidence && 근거 원문 열기 }
바뀐 판단 {deltaLabel(delta)}
{delta?.reason_state === "UNKNOWN_REASON" && 변경 이유는 저장된 기록에서 확인되지 않았습니다.
}
필요한 다음 행동 {actionLabel(nextAction)}
{nextAction && 기술 정보 {nextAction.label} }{nextAction?.requires_permission && 권한 결정이 필요한 행동입니다.
}
}
@@ -138,9 +138,9 @@ export function ResearchFollowupSummary({
{currentnessLabel(progress.currentness)}
}
- {coverage && 0} label={`평가기준 ${rows.length}개`}>
- {coverage.availability === "UNAVAILABLE" ? 현재 권한으로 평가기준 행을 읽을 수 없습니다.
: rows.length === 0 ? 이 답변에 연결된 평가기준 행이 없습니다.
- :
+ {coverage &&
0} label={`답변 점검 항목 ${rows.length}개`}>
+ {coverage.availability === "UNAVAILABLE" ? 현재 권한으로 답변 점검 항목을 읽을 수 없습니다.
: rows.length === 0 ? 이 답변에 연결된 답변 점검 항목이 없습니다.
+ :
기준 기준 상태 판단
{rows.map(row => {
const text = criterionText(row.target, row.question);
diff --git a/apps/web/src/components/ResearchHold.test.tsx b/apps/web/src/components/ResearchHold.test.tsx
index 1b6ad3c..ac084a7 100644
--- a/apps/web/src/components/ResearchHold.test.tsx
+++ b/apps/web/src/components/ResearchHold.test.tsx
@@ -20,7 +20,7 @@ const render = (result: Record
, extra: Record
it("puts two hold lines inside the answer card for a real held IRIS answer", () => {
const html = render(excerpt, { coverageMatrix: coverage([row("SATISFIED"), row("SATISFIED"), row("UNRESOLVED"), row("NOT_ASSESSED"), row("NOT_APPLICABLE")]) });
expect(html).toContain("결론 상태: 판단 보류 ");
- expect(html).toContain("답할 수 있는 범위: 기준 충족 2/5 · 확인 필요 2");
+ expect(html).toContain("답할 수 있는 범위: 답변 점검 항목 5개 중 충족 2 · 확인 필요 2");
expect(html.indexOf("결론 상태")).toBeGreaterThan(html.indexOf("answer-text"));
expect(html.indexOf("결론 상태")).toBeLessThan(html.indexOf("answer-actions"));
});
@@ -28,7 +28,7 @@ it("puts two hold lines inside the answer card for a real held IRIS answer", ()
it("names the other answer statuses and a missing coverage read", () => {
expect(render({ answer: "a", answer_status: "ASSESSED_WITH_OPEN_CHECKS" })).toContain("부분 답변 ");
expect(render({ answer: "a", answer_status: "ASSESSED_FOR_REQUEST" })).toContain("답변 완료 ");
- expect(render({ answer: "a", answer_status: "PARTIAL_HOLD" })).toContain("답할 수 있는 범위: 평가기준 정보를 아직 읽지 못했습니다");
+ expect(render({ answer: "a", answer_status: "PARTIAL_HOLD" })).toContain("답할 수 있는 범위: 답변 점검 항목 정보를 아직 읽지 못했습니다");
expect(render({ answer: "a" })).not.toContain("결론 상태");
});
@@ -43,6 +43,22 @@ it("splits the criteria table into status and judgment so unresolved is not show
expect(visible(html)).not.toContain("RESEARCH_GAP");
});
+it("calls the answer's own checks 답변 점검 항목 so they are not mixed up with the trace table's criteria", () => {
+ const html = render({ answer: "a", answer_status: "PARTIAL_HOLD" }, { progressSummary: progress(), coverageMatrix: coverage([row("UNRESOLVED"), row("SATISFIED")]) });
+ const text = visible(html);
+ expect(html).toContain("답변 점검 항목 2개");
+ expect(html).toContain('aria-label="답변 점검 항목 충족 상태"');
+ expect(text).not.toContain("평가기준");
+ // The list stays folded when nothing is open, so only its title shows; the empty and unreadable wording sits inside it.
+ expect(render({ answer: "a" }, { progressSummary: progress(), coverageMatrix: coverage([]) })).toContain("답변 점검 항목 0개");
+});
+
+it("names the evidence linked to this summary without reusing the 직접 사용 가능 wording", () => {
+ const text = visible(render({ answer: "a" }, { progressSummary: progress(), coverageMatrix: coverage([row("SATISFIED", { evidence_refs: ["span:x"] })]) }));
+ expect(text).toContain("이 요약에 연결된 근거");
+ expect(text).not.toContain("직접 근거");
+});
+
it("says where the answer's sources are instead of claiming none were recorded", () => {
const html = render({ answer: "a", selected_evidence_count: 71 }, { progressSummary: progress(), coverageMatrix: coverage([row("SATISFIED")]) });
expect(html).toContain("이 검토 요약에 따로 연결된 근거는 없습니다. 답변 근거 71개는 '근거 원문'에서 볼 수 있습니다.");
@@ -63,7 +79,8 @@ it("words the terminal result marker and keeps other internal codes out of the v
"requirement:req-1:UNRESOLVED", "requirement:req-2:SATISFIED", "EVIDENCE_TOO_OLD", "RESULT_NOT_PRODUCED", "자료가 오래됐습니다",
]) });
const text = visible(html);
- expect(text).toContain("답변이 최종 상태로 기록됐습니다");
+ expect(text).toContain("조사 실행이 끝났습니다");
+ expect(text).not.toContain("최종 상태로 기록");
expect(text).toContain("평가 조건이 기록됐습니다");
expect(text).toContain("아직 답변이 만들어지지 않았습니다");
expect(text).toContain("자료가 오래됐습니다");
diff --git a/apps/web/src/components/ResultEvidencePanel.tsx b/apps/web/src/components/ResultEvidencePanel.tsx
index 04b1beb..0a39cd6 100644
--- a/apps/web/src/components/ResultEvidencePanel.tsx
+++ b/apps/web/src/components/ResultEvidencePanel.tsx
@@ -45,6 +45,7 @@ export function ResultEvidencePanel({ selection, result, focusSpanId, citations
이 답변에 선택된 근거 {first.selected_count}개
연구에 선택된 원문 목록입니다. 답변 본문에서 직접 인용한 개수와 다를 수 있습니다.
{first.selected_count === 0 ? 이 저장 버전에 선택된 근거가 없습니다.
: <>
+ ‘확인된 원문’은 지금까지 불러온 근거 중 원문을 보여 줄 수 있는 것의 수입니다. 한 번에 일부만 불러오므로 선택된 근거 전체보다 적을 수 있고, 나머지는 ‘다음 근거 보기’로 불러옵니다.
{focusSpanId && !focusLoaded && !query.hasNextPage && 이 근거를 목록에서 찾지 못했습니다. 답변이 인용한 원문이 이 저장 버전의 선택 근거에 없습니다.
}
{focusSpanId && unavailable.some(item => item.span_id === focusSpanId) && 이 근거는 원문을 표시할 수 없습니다. 아래 안내를 확인하세요.
}
{available.length > 0 && }
diff --git a/apps/web/src/components/ResultScreen.test.tsx b/apps/web/src/components/ResultScreen.test.tsx
index c36b5e3..4070086 100644
--- a/apps/web/src/components/ResultScreen.test.tsx
+++ b/apps/web/src/components/ResultScreen.test.tsx
@@ -55,7 +55,7 @@ it("counts a value no rule covers as unclassified instead of guessing", () => {
it("shows criteria fulfilled over the total, then the evidence-use groups, each criterion with its own fulfilment state", () => {
const html = render(held, { coverageMatrix: coverage(iris) });
- expect(html).toContain("기준 충족 0/7 · 확인 필요 7");
+ expect(html).toContain("답변 점검 항목 7개 중 충족 0 · 확인 필요 7");
expect(html).not.toContain("근거 있음");
expect(html).toContain("직접 사용 가능 1");
expect(html).toContain("조건부·참고용 5");
@@ -72,8 +72,8 @@ it("puts the first-screen lines in the agreed order", () => {
const at = (text: string) => html.indexOf(text);
expect(at("결론 상태")).toBeGreaterThan(-1);
expect(at("결론 상태")).toBeLessThan(at("근거 용도"));
- expect(at("근거 용도")).toBeLessThan(at("기준 충족"));
- expect(at("기준 충족")).toBeLessThan(at("반대 근거 탐색"));
+ expect(at("근거 용도")).toBeLessThan(at("답변 점검 항목"));
+ expect(at("답변 점검 항목")).toBeLessThan(at("반대 근거 탐색"));
expect(at("반대 근거 탐색")).toBeLessThan(at("다음 확보"));
});
@@ -91,6 +91,22 @@ it("shows refutation 0 only for a finished search", () => {
expect(html).not.toContain("반박 -");
});
+it("counts the counter-evidence of every hypothesis on the answer line, searched or not", () => {
+ const withCounter = (searchedFirst: boolean) => {
+ const copy = structuredClone(held) as { portfolio: { hypotheses: Record[] } };
+ copy.portfolio.hypotheses[0].counterevidence_refs = ["span:c1", "span:c2", "span:c3"];
+ copy.portfolio.hypotheses[1].counterevidence_refs = ["span:c4"];
+ if (searchedFirst) copy.portfolio.hypotheses[0].critical_review = { terminal: "UNRESOLVED_NO_RESULTS" };
+ return copy as unknown as Record;
+ };
+ const none = render(withCounter(false), { coverageMatrix: coverage(iris) });
+ expect(none).toContain("반대 근거 탐색: 안 함 · 반박 4");
+ expect(none).not.toContain("반박 -");
+ const some = render(withCounter(true), { coverageMatrix: coverage(iris) });
+ const total = (held as unknown as { portfolio: { hypotheses: unknown[] } }).portfolio.hypotheses.length;
+ expect(some).toContain(`반대 근거 탐색: 1/${total}개 가설 · 반박 4`); // the count covers every hypothesis, the N/M part only the searched ones
+});
+
it("picks the Korean next-step text from the action type and keeps the English server label out of view", () => {
const html = render(held, { coverageMatrix: coverage(iris), progressSummary: progress(action("REVIEW_GAPS", "Review unresolved evidence gaps")) });
const visible = html.replace(//g, "");
diff --git a/apps/web/src/components/SyntheticDataNotice.tsx b/apps/web/src/components/SyntheticDataNotice.tsx
new file mode 100644
index 0000000..6b37467
--- /dev/null
+++ b/apps/web/src/components/SyntheticDataNotice.tsx
@@ -0,0 +1,17 @@
+import { Callout } from "@blueprintjs/core";
+import { useQuery } from "@tanstack/react-query";
+import { readTrace, traceKey } from "../api/trace";
+import { isSyntheticTrace } from "./traceRows";
+
+export const SYNTHETIC_LINE = "합성 데이터입니다. 실제 측정값이 아니며 공학 용도로 쓸 수 없습니다.";
+
+/**
+ * A thin line above the conversation when the project's trace table was written as demo data. It reads the trace with the
+ * same query the trace page uses (one existing read, shared cache); no table, an unreadable table, or no marker shows nothing.
+ */
+export function SyntheticDataNotice({ projectId }: { projectId: string }) {
+ const query = useQuery({ queryKey: traceKey(projectId), enabled: Boolean(projectId), retry: false, staleTime: 30_000, queryFn: ({ signal }) => readTrace(projectId, signal) });
+ const view = query.data;
+ if (!view || !Array.isArray(view.items) || !isSyntheticTrace(view)) return null;
+ return {SYNTHETIC_LINE} ;
+}
diff --git a/apps/web/src/components/hypothesisView.ts b/apps/web/src/components/hypothesisView.ts
index 3bc2435..8ea1c03 100644
--- a/apps/web/src/components/hypothesisView.ts
+++ b/apps/web/src/components/hypothesisView.ts
@@ -62,13 +62,17 @@ export function hypothesisRows(result: Record): HypothesisRow[]
});
}
-/** One first-screen line about the counter-evidence search; "-" (not 0) while no search was recorded. */
+/**
+ * One first-screen line about the counter-evidence search. The count covers the counter sources of every hypothesis, searched or not;
+ * "-" (not 0) only when no search was recorded and no counter source exists. The "N/M" part counts only the searched hypotheses.
+ */
export function counterSearchLine(result: Record): string | null {
const rows = hypothesisRows(result);
if (!rows.length) return null;
const searched = rows.filter(row => row.counterTerminal);
- if (!searched.length) return "반대 근거 탐색: 안 함 · 반박 -";
- return `반대 근거 탐색: ${searched.length}/${rows.length}개 가설 · 반박 ${searched.reduce((sum, row) => sum + row.counter.length, 0)}`;
+ const total = rows.reduce((sum, row) => sum + row.counter.length, 0);
+ if (!searched.length) return `반대 근거 탐색: 안 함 · 반박 ${total > 0 ? total : "-"}`;
+ return `반대 근거 탐색: ${searched.length}/${rows.length}개 가설 · 반박 ${total}`;
}
/** A source the review cited for this hypothesis and judged "not supporting" is shown as not applicable; a source never looked at stays unevaluated. */
diff --git a/apps/web/src/components/statusLabels.ts b/apps/web/src/components/statusLabels.ts
index f74c968..20e9a64 100644
--- a/apps/web/src/components/statusLabels.ts
+++ b/apps/web/src/components/statusLabels.ts
@@ -71,7 +71,7 @@ export function actionStateLabel(value: string | null | undefined): string | nul
const coverageTerms: Record = {
RESEARCH_GAP: "조사 공백", APPLIED: "판단 반영됨", NOT_ASSESSED: "미평가", INCONCLUSIVE: "결론을 내지 못함",
QUALIFIES: "조건부로 해당", SUPPORTS: "뒷받침", CONTRADICTS: "충돌", "answer:HOLD": "답변을 보류시킨 요인",
- APPLICABLE: "적용됨", COVERAGE_UNAVAILABLE: "평가기준 정보 없음", ASSESSMENT_UNAVAILABLE: "평가 결과 없음",
+ APPLICABLE: "적용됨", COVERAGE_UNAVAILABLE: "답변 점검 항목 정보 없음", ASSESSMENT_UNAVAILABLE: "평가 결과 없음",
INSUFFICIENT: "근거 부족", REFUTES: "반박", IRRELEVANT: "관련 없음",
};
export function coverageTermLabel(value: string): string { return coverageTerms[value] ?? value; }
diff --git a/apps/web/src/workstation.css b/apps/web/src/workstation.css
index 94e8c0d..a96b5c9 100644
--- a/apps/web/src/workstation.css
+++ b/apps/web/src/workstation.css
@@ -341,3 +341,5 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli
.trace-evidence-dialog .bp6-dialog-body{max-height:calc(100vh - 230px);overflow-y:auto}
.trace-evidence-dialog .evidence-item.focused{border-color:#8abbff;background:rgba(76,144,240,.16);box-shadow:0 0 0 2px rgba(76,144,240,.45)}
.trace-import-dialog{width:min(720px,94vw)}
+.conversation-synthetic-notice{flex:none;margin:0 0 8px;padding:4px 10px;min-height:0;background:#3a2a10 !important;font-size:12px}
+.archived-project-row{display:flex;align-items:center;justify-content:space-between;gap:6px;font-size:12px}
diff --git a/config/atomicity-paths.json b/config/atomicity-paths.json
index 4a65918..82a7446 100644
--- a/config/atomicity-paths.json
+++ b/config/atomicity-paths.json
@@ -2,7 +2,7 @@
"schema_version": "1.0.0",
"purpose": "DEVELOPMENT_VERIFICATION_LEDGER_NOT_RUNTIME_POLICY",
"baseline_head": "f0262732a0e79235f3839c3393f459b90bd53783",
- "catalog_sha256": "9c8e14688ccd2bc92bc2487213c7dd4fbe2cd625572a0673f1f2282a7b29e95a",
+ "catalog_sha256": "8a74465cef7448dd9315aec0d496cfba39685e6f5e693b059655cf079689e5e4",
"public_entries": [
{
"name": "action/audit/read",
@@ -7486,6 +7486,32 @@
],
"mutation_observed_in_code": false
},
+ {
+ "name": "project/restore",
+ "namespace": "project",
+ "surface": "COMMAND",
+ "canonical_owner": "PROJECT_GOVERNANCE",
+ "policy": "project:versioned-policy",
+ "normal_entrypoint": "project/create",
+ "behavioral_evidence": [
+ "A01"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "project/restore",
+ "actual_callable": "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore",
+ "source": "src/thoth/application/commands/project_restore.py",
+ "line": 32,
+ "callable_sha256": "16298c9b366b9835610c72ffe4c352bcffdf211262b65cefc6651274b6e6179d",
+ "entry_kind": "public",
+ "path_id": "rpc:project/restore",
+ "owner_scope": "PROJECT_GOVERNANCE",
+ "phase_ids": [
+ "rpc:project/restore"
+ ],
+ "mutation_observed_in_code": true
+ },
{
"name": "project/review/list",
"namespace": "project",
@@ -35316,7 +35342,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 51,
+ "line": 59,
"binding": "handlers.audit_read"
}
],
@@ -35412,7 +35438,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 50,
+ "line": 58,
"binding": "handlers.list"
}
],
@@ -35508,7 +35534,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 54,
+ "line": 62,
"binding": "handlers.pause"
}
],
@@ -35666,7 +35692,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 49,
+ "line": 57,
"binding": "handlers.read"
}
],
@@ -35763,7 +35789,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 55,
+ "line": 63,
"binding": "handlers.resume"
}
],
@@ -35921,7 +35947,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 52,
+ "line": 60,
"binding": "handlers.start"
}
],
@@ -36080,7 +36106,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 56,
+ "line": 64,
"binding": "handlers.stop"
}
],
@@ -36238,7 +36264,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 53,
+ "line": 61,
"binding": "handlers.update"
}
],
@@ -48497,7 +48523,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 15,
+ "line": 22,
"binding": "handlers.activate"
}
],
@@ -48663,7 +48689,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 16,
+ "line": 23,
"binding": "handlers.archive"
}
],
@@ -48768,7 +48794,7 @@
},
{
"source": "src/thoth/apps/management_routes.py",
- "line": 12,
+ "line": 19,
"binding": "handlers.create"
}
],
@@ -48938,7 +48964,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 27,
+ "line": 35,
"binding": "handlers.cutoff_impact"
}
],
@@ -49045,7 +49071,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 28,
+ "line": 36,
"binding": "handlers.cutoff_update"
}
],
@@ -49225,7 +49251,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 17,
+ "line": 24,
"binding": "handlers.archive"
}
],
@@ -49324,7 +49350,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 13,
+ "line": 20,
"binding": "handlers.list"
}
],
@@ -49404,7 +49430,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 18,
+ "line": 26,
"binding": "handlers.metadata_update"
}
],
@@ -49570,7 +49596,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 19,
+ "line": 27,
"binding": "handlers.overlay_update"
}
],
@@ -49736,7 +49762,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 20,
+ "line": 28,
"binding": "handlers.policy_read"
}
],
@@ -49815,7 +49841,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 21,
+ "line": 29,
"binding": "handlers.policy_update"
}
],
@@ -49978,7 +50004,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 14,
+ "line": 21,
"binding": "handlers.read"
}
],
@@ -50058,7 +50084,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 26,
+ "line": 34,
"binding": "handlers.reference_import"
}
],
@@ -50228,7 +50254,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 25,
+ "line": 33,
"binding": "handlers.reference_list"
}
],
@@ -50293,6 +50319,105 @@
}
}
},
+ {
+ "path_id": "rpc:project/restore",
+ "owner": "PROJECT_GOVERNANCE",
+ "catalog_owner": "PROJECT_GOVERNANCE",
+ "entry_kind": "public",
+ "method_or_trigger": "project/restore",
+ "actual_callable": "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/management_routes.py",
+ "line": 25,
+ "binding": "restore.restore"
+ }
+ ],
+ "producer_refs": [
+ "thoth.adapters.storage.governance_history.SqliteGovernanceHistory._insert_record",
+ "thoth.adapters.storage.governance_history.SqliteGovernanceHistory.stage",
+ "thoth.adapters.storage.projects.SqliteProjectStore.update"
+ ],
+ "phase_id": [
+ "LIFECYCLE_PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "governance_heads",
+ "governance_revisions",
+ "projects"
+ ],
+ "head_selector": "Project revision CAS and governance history head for the selected project.",
+ "audit_receipt_refs": [
+ "governance_revisions"
+ ],
+ "expected_basis": "Expected Project revision and ARCHIVED_READ_ONLY lifecycle; a project that is not archived is returned unchanged. The lifecycle comes from the governance history before the archive, or the activation rule when the history cannot say.",
+ "uow_participants": [
+ "thoth.adapters.storage.governance_history.SqliteGovernanceHistory._insert_record",
+ "thoth.adapters.storage.governance_history.SqliteGovernanceHistory.stage",
+ "thoth.adapters.storage.projects.SqliteProjectStore.update"
+ ],
+ "irreversible_effect_boundary": "Project/lifecycle and governance history publish inside the short ledger transaction; operation claim/terminal is separate.",
+ "allowed_phase_deltas": [
+ "Accepted restore moves lifecycle and project revision together; stale expected revision or audit failure leaves the archived project state."
+ ],
+ "downstream_reader": [
+ "project/read",
+ "project/list",
+ "project/policy/read"
+ ],
+ "existing_tests": [
+ "tests/integration/test_project_restore_rpc.py"
+ ],
+ "fault_points": "Expected-revision conflict, not-archived and unreadable-history cases need distinct assertions.",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/adapters/storage/governance_history.py",
+ "src/thoth/adapters/storage/projects.py",
+ "src/thoth/application/commands/project_restore.py",
+ "src/thoth/apps/management_routes.py"
+ ],
+ "atomic_regions": [
+ {
+ "callable": "thoth.adapters.storage.sqlite.SqliteLedger.transaction",
+ "source": "src/thoth/adapters/storage/sqlite.py",
+ "line": 242,
+ "contexts": [
+ "SqliteAtomicUnitOfWork(self._engine, immediate=True).transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.adapters.storage.transaction.SqliteAtomicUnitOfWork.transaction",
+ "source": "src/thoth/adapters/storage/transaction.py",
+ "line": 77,
+ "contexts": [
+ "resource_use_transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore",
+ "source": "src/thoth/application/commands/project_restore.py",
+ "line": 32,
+ "contexts": [
+ "self._ledger.transaction()"
+ ]
+ }
+ ],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Listed source-level regions are reachable candidates; the whole RPC, external I/O and operation terminal are not certified as one transaction.",
+ "verification": {
+ "classification": "MUTATION",
+ "required_callables": [
+ "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore",
+ "thoth.adapters.storage.governance_history.SqliteGovernanceHistory._insert_record",
+ "thoth.adapters.storage.governance_history.SqliteGovernanceHistory.stage",
+ "thoth.adapters.storage.projects.SqliteProjectStore.update"
+ ],
+ "cases": [],
+ "status": "CURRENT_SOURCE_EXECUTION_EVIDENCE_PENDING"
+ }
+ },
{
"path_id": "rpc:project/review/list",
"owner": "PROJECT_GOVERNANCE",
@@ -50410,7 +50535,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 23,
+ "line": 31,
"binding": "handlers.role_assign"
}
],
@@ -50580,7 +50705,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 22,
+ "line": 30,
"binding": "handlers.role_list"
}
],
@@ -50655,7 +50780,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 24,
+ "line": 32,
"binding": "handlers.role_revoke"
}
],
@@ -59147,7 +59272,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 35,
+ "line": 43,
"binding": "handlers.activity_list"
},
{
@@ -59277,7 +59402,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 36,
+ "line": 44,
"binding": "handlers.checkpoint_list"
}
],
@@ -59360,7 +59485,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 37,
+ "line": 45,
"binding": "handlers.checkpoint_read"
}
],
@@ -59443,7 +59568,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 42,
+ "line": 50,
"binding": "handlers.fork"
}
],
@@ -61071,7 +61196,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 33,
+ "line": 41,
"binding": "handlers.list"
}
],
@@ -61149,7 +61274,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 43,
+ "line": 51,
"binding": "handlers.metadata_update"
}
],
@@ -61281,7 +61406,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 39,
+ "line": 47,
"binding": "handlers.pause"
},
{
@@ -61478,7 +61603,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 34,
+ "line": 42,
"binding": "handlers.read"
},
{
@@ -61918,7 +62043,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 40,
+ "line": 48,
"binding": "handlers.resume"
},
{
@@ -63453,7 +63578,7 @@
},
{
"source": "src/thoth/apps/management_routes.py",
- "line": 32,
+ "line": 40,
"binding": "handlers.start"
},
{
@@ -65027,7 +65152,7 @@
},
{
"source": "src/thoth/apps/management_routes.py",
- "line": 38,
+ "line": 46,
"binding": "handlers.steer"
},
{
@@ -66548,7 +66673,7 @@
"caller_refs": [
{
"source": "src/thoth/apps/management_routes.py",
- "line": 41,
+ "line": 49,
"binding": "handlers.stop"
},
{
diff --git a/docs/architecture/rpc-method-catalog.md b/docs/architecture/rpc-method-catalog.md
index 7af57ba..46ffde8 100644
--- a/docs/architecture/rpc-method-catalog.md
+++ b/docs/architecture/rpc-method-catalog.md
@@ -490,6 +490,7 @@ project/metadata/update
project/overlay/update
project/policy/update
project/reference/import
+project/restore
project/role/assign
project/role/revoke
project/source/connect
diff --git a/schemas/protocol/public-method-catalog.json b/schemas/protocol/public-method-catalog.json
index 6faa389..f05f6a4 100644
--- a/schemas/protocol/public-method-catalog.json
+++ b/schemas/protocol/public-method-catalog.json
@@ -1,9 +1,9 @@
{
"schema_version": "1.0.0",
"authority": "docs/architecture/rpc-method-catalog.md",
- "canonical_method_count": 362,
+ "canonical_method_count": 363,
"compatibility_alias_count": 6,
- "runtime_method_count": 368,
+ "runtime_method_count": 369,
"notification_count": 221,
"methods": [
{
@@ -4104,6 +4104,20 @@
"alias_target": null,
"runtime_status": "IMPLEMENTED"
},
+ {
+ "name": "project/restore",
+ "namespace": "project",
+ "surface": "COMMAND",
+ "canonical_owner": "PROJECT_GOVERNANCE",
+ "policy": "project:versioned-policy",
+ "normal_entrypoint": "project/create",
+ "behavioral_evidence": [
+ "A01"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
{
"name": "project/review/list",
"namespace": "project",
@@ -5532,5 +5546,5 @@
"thread/waitingForInput",
"thread/waitingForProtectedAction"
],
- "catalog_digest": "1c7d4799ca9b778a5ba023f66a79b60671e68ffc1e8f57ba295144a403a35798"
+ "catalog_digest": "ca56eaf9f686e31a5c58640745c1dacbb14978bc50efc47f92bc8755714aee91"
}
diff --git a/src/thoth/adapters/storage/projects.py b/src/thoth/adapters/storage/projects.py
index bda8cb1..603fd72 100644
--- a/src/thoth/adapters/storage/projects.py
+++ b/src/thoth/adapters/storage/projects.py
@@ -127,3 +127,19 @@ def update(self, project: Project, *, expected_revision: int) -> bool:
connection, "PROJECT", dict(after), before=dict(before)
)
return changed == 1
+
+ def prior_lifecycle(self, project_id: str) -> ProjectLifecycle | None:
+ try:
+ recorded = SqliteGovernanceHistory(self._engine).history(
+ project_id, "PROJECT", project_id
+ )
+ except ValueError:
+ return None
+ for revision in reversed(recorded):
+ try:
+ lifecycle = ProjectLifecycle(str(revision.projection.get("lifecycle")))
+ except ValueError:
+ return None
+ if lifecycle != ProjectLifecycle.ARCHIVED_READ_ONLY:
+ return lifecycle
+ return None
diff --git a/src/thoth/application/commands/__init__.py b/src/thoth/application/commands/__init__.py
index 10252dc..dbed89b 100644
--- a/src/thoth/application/commands/__init__.py
+++ b/src/thoth/application/commands/__init__.py
@@ -11,6 +11,7 @@
from thoth.application.commands.objects_full import DecisionObjectHandlers
from thoth.application.commands.operations import OperationCommandHandlers
from thoth.application.commands.outcomes_full import OutcomeHandlers
+from thoth.application.commands.project_restore import ProjectRestoreHandlers
from thoth.application.commands.projections import ProjectionQueryHandlers
from thoth.application.commands.projectpacks import ProjectPackCommandHandlers
from thoth.application.commands.projects import ProjectCommandHandlers
@@ -39,6 +40,7 @@
"OutcomeHandlers",
"ProjectCommandHandlers",
"ProjectPackCommandHandlers",
+ "ProjectRestoreHandlers",
"ProjectionQueryHandlers",
"ReceiptHandlers",
"RevisionCommandHandlers",
diff --git a/src/thoth/application/commands/project_restore.py b/src/thoth/application/commands/project_restore.py
new file mode 100644
index 0000000..85e7f85
--- /dev/null
+++ b/src/thoth/application/commands/project_restore.py
@@ -0,0 +1,76 @@
+"""Bring an archived project back to the lifecycle it had before it was archived."""
+
+from __future__ import annotations
+
+from pydantic import JsonValue
+
+from thoth.application.commands.projects import ProjectCommandHandlers, ProjectExpectedRevisionInput
+from thoth.domain.enums import ProjectLifecycle
+from thoth.domain.project import Project
+from thoth.ports.governance import GovernanceStorePort
+from thoth.ports.ledger import LedgerPort
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+
+
+class ProjectRestoreHandlers:
+ """project/restore: one lifecycle step and one revision, recorded like archive."""
+
+ def __init__(
+ self,
+ *,
+ store: ProjectStorePort,
+ governance: GovernanceStorePort,
+ ledger: LedgerPort,
+ projects: ProjectCommandHandlers,
+ ) -> None:
+ self._store = store
+ self._governance = governance
+ self._ledger = ledger
+ self._projects = projects
+
+ async def restore(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = ProjectExpectedRevisionInput.model_validate(value)
+ with self._ledger.transaction():
+ project = self._store.read(request.project_id)
+ if project is None:
+ raise RpcApplicationError(
+ RpcErrorCode.PROJECT_NOT_FOUND,
+ "project was not found",
+ data={"project_id": request.project_id},
+ )
+ if project.lifecycle == ProjectLifecycle.ARCHIVED_READ_ONLY:
+ if project.revision != request.expected_revision:
+ raise RpcApplicationError(
+ RpcErrorCode.DOMAIN_REJECTED,
+ "project revision does not match expected_revision",
+ data={
+ "expected_revision": request.expected_revision,
+ "current_revision": project.revision,
+ },
+ )
+ updated = project.model_copy(
+ update={
+ "lifecycle": self._lifecycle_before_archive(project),
+ "revision": project.revision + 1,
+ }
+ )
+ if not self._store.update(updated, expected_revision=request.expected_revision):
+ raise RpcApplicationError(
+ RpcErrorCode.DOMAIN_REJECTED,
+ "project revision changed concurrently",
+ data={"expected_revision": request.expected_revision},
+ )
+ return await self._projects.read({"project_id": request.project_id})
+
+ def _lifecycle_before_archive(self, project: Project) -> ProjectLifecycle:
+ """The recorded lifecycle before the archive; if the record cannot say, activation rules."""
+ recorded = self._store.prior_lifecycle(project.project_id)
+ if recorded is not None:
+ return recorded
+ active_source = any(
+ item.state == "ACTIVE"
+ for item in self._governance.list_source_bindings(project.project_id)
+ )
+ has_policy = self._governance.read_policy(project.project_id) is not None
+ return ProjectLifecycle.ACTIVE if active_source and has_policy else ProjectLifecycle.DRAFT
diff --git a/src/thoth/apps/management_routes.py b/src/thoth/apps/management_routes.py
index 39f9d9b..b1d270c 100644
--- a/src/thoth/apps/management_routes.py
+++ b/src/thoth/apps/management_routes.py
@@ -3,18 +3,26 @@
from thoth.application.commands import (
InvestigationQueryHandlers,
ProjectCommandHandlers,
+ ProjectRestoreHandlers,
ThreadCommandHandlers,
)
+from thoth.ports.store_bundle import StoreBundlePort
from thoth.protocol.registry import MethodRegistry
-def register_project_methods(registry: MethodRegistry, handlers: ProjectCommandHandlers) -> None:
+def register_project_methods(
+ registry: MethodRegistry, handlers: ProjectCommandHandlers, stores: StoreBundlePort
+) -> None:
+ restore = ProjectRestoreHandlers(
+ store=stores.projects, governance=stores.governance, ledger=stores.ledger, projects=handlers
+ )
registry.register("project/create", handlers.create)
registry.register("project/list", handlers.list)
registry.register("project/read", handlers.read)
registry.register("project/activate", handlers.activate)
registry.register("project/archive", handlers.archive)
registry.register("project/delete", handlers.archive)
+ registry.register("project/restore", restore.restore)
registry.register("project/metadata/update", handlers.metadata_update)
registry.register("project/overlay/update", handlers.overlay_update)
registry.register("project/policy/read", handlers.policy_read)
diff --git a/src/thoth/apps/runtime.py b/src/thoth/apps/runtime.py
index cd9a881..5e37584 100644
--- a/src/thoth/apps/runtime.py
+++ b/src/thoth/apps/runtime.py
@@ -570,7 +570,7 @@ def workspace_setup():
registry.register("field/export/build", field_measurement_handlers.export_build)
registry.register("projectpack/list", projectpack_handlers.list)
registry.register("projectpack/run", projectpack_handlers.run)
- register_project_methods(registry, project_handlers)
+ register_project_methods(registry, project_handlers, stores)
register_closure_and_export_methods(
registry, lifecycle_handlers, closure_handlers, export_handlers
)
diff --git a/src/thoth/ports/project.py b/src/thoth/ports/project.py
index 38a934c..c487f00 100644
--- a/src/thoth/ports/project.py
+++ b/src/thoth/ports/project.py
@@ -2,6 +2,7 @@
from typing import Protocol
+from thoth.domain.enums import ProjectLifecycle
from thoth.domain.project import Project
@@ -17,3 +18,7 @@ def read(self, project_id: str) -> Project | None: ...
def list(self) -> tuple[Project, ...]: ...
def update(self, project: Project, *, expected_revision: int) -> bool: ...
+
+ def prior_lifecycle(self, project_id: str) -> ProjectLifecycle | None:
+ """Lifecycle recorded before the archive; None if the record cannot say."""
+ ...
diff --git a/src/thoth/protocol/notification_catalog.py b/src/thoth/protocol/notification_catalog.py
index c04d985..00754f4 100644
--- a/src/thoth/protocol/notification_catalog.py
+++ b/src/thoth/protocol/notification_catalog.py
@@ -7,6 +7,7 @@
"project/activate": ("project/activated", "project/statusChanged"),
"project/archive": ("project/archived", "project/statusChanged"),
"project/delete": ("project/archived", "project/statusChanged"),
+ "project/restore": ("project/statusChanged",),
"project/metadata/update": ("project/metadata/updated",),
"project/source/connect": ("project/source/connected",),
"project/role/assign": ("project/role/assigned",),
diff --git a/src/thoth/protocol/registry.py b/src/thoth/protocol/registry.py
index 4ab76cb..46ae69e 100644
--- a/src/thoth/protocol/registry.py
+++ b/src/thoth/protocol/registry.py
@@ -56,6 +56,7 @@
"project/activate",
"project/archive",
"project/delete",
+ "project/restore",
"project/metadata/update",
"project/list",
"project/overlay/update",
diff --git a/tests/architecture/test_public_protocol_exact_parity.py b/tests/architecture/test_public_protocol_exact_parity.py
index ca64ee7..8ca1bda 100644
--- a/tests/architecture/test_public_protocol_exact_parity.py
+++ b/tests/architecture/test_public_protocol_exact_parity.py
@@ -17,11 +17,11 @@ def test_public_method_manifest_exactly_matches_runtime_and_notifications() -> N
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
methods = manifest["methods"]
by_name = {item["name"]: item for item in methods}
- assert len(methods) == len(by_name) == 368
+ assert len(methods) == len(by_name) == 369
assert set(by_name) == set(PUBLIC_METHODS)
- assert sum(item["canonical"] for item in methods) == 362
- assert manifest["runtime_method_count"] == 368
- assert manifest["canonical_method_count"] == 362
+ assert sum(item["canonical"] for item in methods) == 363
+ assert manifest["runtime_method_count"] == 369
+ assert manifest["canonical_method_count"] == 363
assert manifest["compatibility_alias_count"] == 6
assert {
f"project/source/scope/{name}" for name in ("read", "grant", "revoke", "assign", "update")
diff --git a/tests/fixtures/runtime_registration_events.json b/tests/fixtures/runtime_registration_events.json
index 8c8644e..f450e19 100644
--- a/tests/fixtures/runtime_registration_events.json
+++ b/tests/fixtures/runtime_registration_events.json
@@ -95,6 +95,11 @@
"project/delete",
"thoth.application.commands.projects.ProjectCommandHandlers.archive"
],
+ [
+ "register",
+ "project/restore",
+ "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore"
+ ],
[
"register",
"project/metadata/update",
@@ -2047,6 +2052,11 @@
"project/delete",
"thoth.application.commands.projects.ProjectCommandHandlers.archive"
],
+ [
+ "register",
+ "project/restore",
+ "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore"
+ ],
[
"register",
"project/metadata/update",
diff --git a/tests/integration/test_project_restore_rpc.py b/tests/integration/test_project_restore_rpc.py
new file mode 100644
index 0000000..8c01a86
--- /dev/null
+++ b/tests/integration/test_project_restore_rpc.py
@@ -0,0 +1,204 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from pathlib import Path
+
+import pytest
+from sqlalchemy import text
+from tests.integration.storage_coverage_helpers import (
+ domain_snapshot,
+ prepare_project,
+ request,
+ value,
+)
+
+from thoth.adapters.storage.governance import SqliteGovernanceStore
+from thoth.adapters.storage.projects import SqliteProjectStore
+from thoth.apps.runtime import AppRuntime
+from thoth.domain.enums import ProjectLifecycle
+from thoth.domain.governance import SourceBinding
+from thoth.protocol.jsonrpc import RpcErrorCode
+
+
+async def _archive(runtime: AppRuntime, project: str, key: str) -> dict[str, object]:
+ current = value(
+ await runtime.bus.query(request("project/read", key + "-read", {"project_id": project}))
+ )
+ return value(
+ await runtime.bus.dispatch(
+ request(
+ "project/archive",
+ key,
+ {"project_id": project, "expected_revision": current["revision"]},
+ )
+ )
+ )
+
+
+async def _restore(runtime: AppRuntime, project: str, key: str, revision: object):
+ return await runtime.bus.dispatch(
+ request("project/restore", key, {"project_id": project, "expected_revision": revision})
+ )
+
+
+def _no_record(self: SqliteProjectStore, project_id: str) -> None:
+ """A history that cannot say what the project was before the archive."""
+ return None
+
+
+def _move_to(runtime: AppRuntime, project: str, lifecycle: ProjectLifecycle) -> None:
+ """Move the project through the store, so the move lands in the governance history."""
+ store = SqliteProjectStore(runtime.ledger.engine)
+ current = store.read(project)
+ assert current is not None
+ moved = current.model_copy(update={"lifecycle": lifecycle, "revision": current.revision + 1})
+ assert store.update(moved, expected_revision=current.revision)
+
+
+def _lifecycles(runtime: AppRuntime, project: str) -> list[str]:
+ with runtime.ledger.engine.connect() as connection:
+ rows = connection.execute(
+ text(
+ "SELECT json_extract(content_json, '$.projection.lifecycle') "
+ "FROM governance_revisions "
+ "WHERE project_id=:project AND record_kind='PROJECT' ORDER BY revision"
+ ),
+ {"project": project},
+ ).scalars()
+ return [str(item) for item in rows]
+
+
+async def test_restore_brings_back_the_lifecycle_before_the_archive_and_keeps_the_project_usable(
+ tmp_path: Path,
+) -> None:
+ runtime, project = await prepare_project(tmp_path)
+ try:
+ archived = await _archive(runtime, project, "archive-1")
+ restored = value(await _restore(runtime, project, "restore-1", archived["revision"]))
+ assert restored["lifecycle"] == "DRAFT"
+ assert restored["revision"] == archived["revision"] + 1 # type: ignore[operator]
+ # the history shows the whole path, so the restore is visible as its own revision
+ assert _lifecycles(runtime, project) == ["DRAFT", "ARCHIVED_READ_ONLY", "DRAFT"]
+ listed = value(
+ await runtime.bus.query(
+ request("project/list", "list", {"project_id": "system:projects"})
+ )
+ )
+ assert [item["project_id"] for item in listed["projects"]] == [project]
+ renamed = value(
+ await runtime.bus.dispatch(
+ request(
+ "project/metadata/update",
+ "rename-after-restore",
+ {
+ "project_id": project,
+ "expected_revision": restored["revision"],
+ "name": "Back again",
+ },
+ )
+ )
+ )
+ assert renamed["name"] == "Back again"
+ finally:
+ runtime.close()
+
+
+@pytest.mark.parametrize("before", [ProjectLifecycle.ACTIVE, ProjectLifecycle.CLOSING])
+async def test_restore_returns_to_the_recorded_active_or_closing_state(
+ tmp_path: Path, before: ProjectLifecycle
+) -> None:
+ runtime, project = await prepare_project(tmp_path)
+ try:
+ _move_to(runtime, project, before)
+ archived = await _archive(runtime, project, "archive-1")
+ restored = value(await _restore(runtime, project, "restore-1", archived["revision"]))
+ assert restored["lifecycle"] == before.value
+ assert _lifecycles(runtime, project)[-3:] == [
+ before.value,
+ "ARCHIVED_READ_ONLY",
+ before.value,
+ ]
+ # archive again and restore again: the newest earlier lifecycle is used, not the first one
+ again = await _archive(runtime, project, "archive-2")
+ assert (
+ value(await _restore(runtime, project, "restore-2", again["revision"]))["lifecycle"]
+ == before.value
+ )
+ finally:
+ runtime.close()
+
+
+async def test_restore_without_a_readable_record_uses_the_activation_rule(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(SqliteProjectStore, "prior_lifecycle", _no_record)
+ runtime, project = await prepare_project(tmp_path)
+ try:
+ archived = await _archive(runtime, project, "archive-1")
+ # a policy exists but no active source is connected: draft
+ assert (
+ value(await _restore(runtime, project, "restore-1", archived["revision"]))["lifecycle"]
+ == "DRAFT"
+ )
+ moment = datetime(2026, 9, 5, tzinfo=UTC)
+ SqliteGovernanceStore(runtime.ledger.engine).put_source_binding(
+ SourceBinding(
+ binding_id="binding:1",
+ project_id=project,
+ artifact_id="artifact:1",
+ created_at=moment,
+ updated_at=moment,
+ )
+ )
+ again = await _archive(runtime, project, "archive-2")
+ assert (
+ value(await _restore(runtime, project, "restore-2", again["revision"]))["lifecycle"]
+ == "ACTIVE"
+ )
+ finally:
+ runtime.close()
+
+
+async def test_restore_of_a_project_that_is_not_archived_returns_it_unchanged(
+ tmp_path: Path,
+) -> None:
+ runtime, project = await prepare_project(tmp_path)
+ try:
+ before = domain_snapshot(runtime.ledger.engine)
+ current = value(
+ await runtime.bus.query(request("project/read", "read", {"project_id": project}))
+ )
+ same = value(await _restore(runtime, project, "restore-not-archived", 999))
+ assert same["lifecycle"] == "DRAFT" and same["revision"] == current["revision"]
+ assert domain_snapshot(runtime.ledger.engine) == before
+ finally:
+ runtime.close()
+
+
+async def test_restore_with_a_stale_revision_is_rejected_and_changes_nothing(
+ tmp_path: Path,
+) -> None:
+ runtime, project = await prepare_project(tmp_path)
+ try:
+ archived = await _archive(runtime, project, "archive-1")
+ before = domain_snapshot(runtime.ledger.engine)
+ rejected = await _restore(runtime, project, "restore-stale", int(archived["revision"]) - 1) # type: ignore[call-overload]
+ assert rejected.error is not None
+ assert rejected.error.message == "project revision does not match expected_revision"
+ assert domain_snapshot(runtime.ledger.engine) == before
+ readback = value(
+ await runtime.bus.query(request("project/read", "readback", {"project_id": project}))
+ )
+ assert readback["lifecycle"] == "ARCHIVED_READ_ONLY"
+ finally:
+ runtime.close()
+
+
+async def test_restore_of_an_unknown_project_is_not_found(tmp_path: Path) -> None:
+ runtime, _project = await prepare_project(tmp_path)
+ try:
+ missing = await _restore(runtime, "project:missing", "restore-missing", 0)
+ assert missing.error is not None
+ assert missing.error.code == RpcErrorCode.PROJECT_NOT_FOUND
+ finally:
+ runtime.close()