shak3008 commited on
Commit
ca2b2b6
·
1 Parent(s): 35ee1ab

feat: proposal archive/restore governance, knowledge derived status, documents live polling, yellow archive UI

Browse files
backend/alembic/versions/add_archived_proposal_status.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add ARCHIVED to proposal_status enum
2
+
3
+ Adds the ARCHIVED value to the proposal_status PostgreSQL enum type.
4
+ ARCHIVED means "deferred / parked for later review" — the human is
5
+ explicitly saying "I don't want to decide this right now."
6
+
7
+ Revision ID: add_archived_proposal_status
8
+ Revises: repair_rejected_items
9
+ Create Date: 2026-08-17
10
+ """
11
+
12
+ from alembic import op
13
+
14
+
15
+ revision = "add_archived_proposal_status"
16
+ down_revision = "repair_rejected_items"
17
+ branch_labels = None
18
+ depends_on = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ op.execute(
23
+ "ALTER TYPE proposal_status "
24
+ "ADD VALUE IF NOT EXISTS 'ARCHIVED'"
25
+ )
26
+
27
+
28
+ def downgrade() -> None:
29
+ # PostgreSQL does not support removing a value directly
30
+ # from an enum type. A downgrade would require recreating
31
+ # the enum and migrating the column.
32
+ pass
backend/app/api/activity.py CHANGED
@@ -211,6 +211,17 @@ def list_activity(
211
  "proposal_type": p.proposal_type.value,
212
  },
213
  })
 
 
 
 
 
 
 
 
 
 
 
214
 
215
  # ------------------------------------------------------------------
216
  # Commit events
 
211
  "proposal_type": p.proposal_type.value,
212
  },
213
  })
214
+ if p.status == ProposalStatus.ARCHIVED and p.reviewed_at:
215
+ events.append({
216
+ "id": f"proposal-archived-{p.id}",
217
+ "type": "proposal_archived",
218
+ "message": f"Proposal archived: {p.summary[:80]}",
219
+ "timestamp": p.reviewed_at.isoformat(),
220
+ "metadata": {
221
+ "proposal_id": str(p.id),
222
+ "proposal_type": p.proposal_type.value,
223
+ },
224
+ })
225
 
226
  # ------------------------------------------------------------------
227
  # Commit events
backend/app/api/knowledge.py CHANGED
@@ -30,6 +30,21 @@ def _knowledge_response(item: KnowledgeItem) -> dict:
30
  "source_type": ev.source_type,
31
  })
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  return {
34
  "id": str(item.id),
35
  "workspace_id": str(item.workspace_id),
@@ -42,6 +57,7 @@ def _knowledge_response(item: KnowledgeItem) -> dict:
42
  "attributes": item.attributes,
43
  "confidence": item.confidence,
44
  "status": item.status.value,
 
45
  "evidence": evidence_list,
46
  "created_at": item.created_at,
47
  "updated_at": item.updated_at,
@@ -75,11 +91,40 @@ def list_knowledge(
75
  )
76
 
77
  if status:
78
- try:
79
- ks = KnowledgeStatus(status.upper())
80
- query = query.filter(KnowledgeItem.status == ks)
81
- except ValueError:
82
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  if type:
85
  try:
 
30
  "source_type": ev.source_type,
31
  })
32
 
33
+ # Include the most recent proposal info for governance context
34
+ proposal_info = None
35
+ if item.proposals:
36
+ # Get the most recent proposal (by created_at or just first)
37
+ latest_proposal = sorted(
38
+ item.proposals,
39
+ key=lambda p: p.created_at or "",
40
+ reverse=True,
41
+ )[0]
42
+ proposal_info = {
43
+ "id": str(latest_proposal.id),
44
+ "status": latest_proposal.status.value,
45
+ "proposal_type": latest_proposal.proposal_type.value,
46
+ }
47
+
48
  return {
49
  "id": str(item.id),
50
  "workspace_id": str(item.workspace_id),
 
57
  "attributes": item.attributes,
58
  "confidence": item.confidence,
59
  "status": item.status.value,
60
+ "proposal": proposal_info,
61
  "evidence": evidence_list,
62
  "created_at": item.created_at,
63
  "updated_at": item.updated_at,
 
91
  )
92
 
93
  if status:
94
+ status_upper = status.upper()
95
+
96
+ # Special case: "ARCHIVED" means knowledge items whose CREATE
97
+ # proposal has been archived (parked for later review).
98
+ # The knowledge item itself is still PENDING in the DB.
99
+ if status_upper == "ARCHIVED":
100
+ from app.models.proposal import Proposal, ProposalStatus, ProposalType
101
+ query = (
102
+ query
103
+ .join(Proposal, Proposal.knowledge_item_id == KnowledgeItem.id)
104
+ .filter(
105
+ Proposal.proposal_type == ProposalType.CREATE,
106
+ Proposal.status == ProposalStatus.ARCHIVED,
107
+ )
108
+ )
109
+ elif status_upper == "PENDING":
110
+ # PENDING means genuinely untouched — PENDING knowledge with
111
+ # a PENDING proposal (not archived, not decided).
112
+ from app.models.proposal import Proposal, ProposalStatus, ProposalType
113
+ query = (
114
+ query
115
+ .filter(KnowledgeItem.status == KnowledgeStatus.PENDING)
116
+ .join(Proposal, Proposal.knowledge_item_id == KnowledgeItem.id)
117
+ .filter(
118
+ Proposal.proposal_type == ProposalType.CREATE,
119
+ Proposal.status == ProposalStatus.PENDING,
120
+ )
121
+ )
122
+ else:
123
+ try:
124
+ ks = KnowledgeStatus(status_upper)
125
+ query = query.filter(KnowledgeItem.status == ks)
126
+ except ValueError:
127
+ pass
128
 
129
  if type:
130
  try:
backend/app/api/proposals.py CHANGED
@@ -86,4 +86,44 @@ def reject_proposal(
86
  comments=request.comments if request else None,
87
  )
88
 
89
- return review
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  comments=request.comments if request else None,
87
  )
88
 
89
+ return review
90
+
91
+
92
+ @router.post(
93
+ "/{proposal_id}/archive",
94
+ response_model=ProposalResponse,
95
+ )
96
+ def archive_proposal(
97
+ proposal_id: UUID,
98
+ request: ReviewRequest | None = None,
99
+ current_user: User = Depends(get_current_user),
100
+ db: Session = Depends(get_db),
101
+ ):
102
+ service = ProposalReviewService(db)
103
+
104
+ proposal = service.archive(
105
+ proposal_id=proposal_id,
106
+ user_id=current_user.id,
107
+ comments=request.comments if request else None,
108
+ )
109
+
110
+ return proposal
111
+
112
+
113
+ @router.post(
114
+ "/{proposal_id}/restore",
115
+ response_model=ProposalResponse,
116
+ )
117
+ def restore_proposal(
118
+ proposal_id: UUID,
119
+ current_user: User = Depends(get_current_user),
120
+ db: Session = Depends(get_db),
121
+ ):
122
+ service = ProposalReviewService(db)
123
+
124
+ proposal = service.restore(
125
+ proposal_id=proposal_id,
126
+ user_id=current_user.id,
127
+ )
128
+
129
+ return proposal
backend/app/models/proposal.py CHANGED
@@ -30,6 +30,7 @@ class ProposalStatus(str, enum.Enum):
30
  PENDING = "PENDING"
31
  APPROVED = "APPROVED"
32
  REJECTED = "REJECTED"
 
33
 
34
 
35
  class Proposal(Base):
 
30
  PENDING = "PENDING"
31
  APPROVED = "APPROVED"
32
  REJECTED = "REJECTED"
33
+ ARCHIVED = "ARCHIVED"
34
 
35
 
36
  class Proposal(Base):
backend/app/services/proposal_review_service.py CHANGED
@@ -115,6 +115,57 @@ class ProposalReviewService:
115
 
116
  return relevant
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  def _get_proposal_document_version_id(
119
  self,
120
  proposal: Proposal,
@@ -188,6 +239,16 @@ class ProposalReviewService:
188
  if pending:
189
  return
190
 
 
 
 
 
 
 
 
 
 
 
191
  from app.repositories.workflow_repository import (
192
  WorkflowRepository,
193
  )
@@ -212,6 +273,41 @@ class ProposalReviewService:
212
  finally:
213
  executor.close()
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  def approve(
216
  self,
217
  proposal_id: uuid.UUID,
@@ -298,6 +394,78 @@ class ProposalReviewService:
298
  self.db.rollback()
299
  raise
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  # ------------------------------------------------------------------
302
  # Rejection side-effects
303
  # ------------------------------------------------------------------
@@ -514,12 +682,47 @@ class ProposalReviewService:
514
  self,
515
  proposal: Proposal,
516
  ) -> None:
517
- if proposal.status != ProposalStatus.PENDING:
 
 
 
 
518
  raise HTTPException(
519
  status_code=409,
520
  detail="Proposal has already been reviewed.",
521
  )
522
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  def _verify_workspace_access(
524
  self,
525
  workspace_id: uuid.UUID,
 
115
 
116
  return relevant
117
 
118
+ def _get_archived_for_document_version(
119
+ self,
120
+ workspace_id: uuid.UUID,
121
+ document_version_id: uuid.UUID,
122
+ ) -> list[Proposal]:
123
+ """
124
+ Return ARCHIVED proposals associated with a specific document version.
125
+ Same logic as get_pending_for_document_version but for ARCHIVED status.
126
+ """
127
+ knowledge_items = (
128
+ self.db.query(KnowledgeItem.id)
129
+ .filter(
130
+ KnowledgeItem.workspace_id == workspace_id,
131
+ KnowledgeItem.document_version_id == document_version_id,
132
+ )
133
+ .all()
134
+ )
135
+
136
+ knowledge_item_ids = {
137
+ str(item_id)
138
+ for (item_id,) in knowledge_items
139
+ }
140
+
141
+ if not knowledge_item_ids:
142
+ return []
143
+
144
+ proposals = (
145
+ self.db.query(Proposal)
146
+ .filter(
147
+ Proposal.workspace_id == workspace_id,
148
+ Proposal.status == ProposalStatus.ARCHIVED,
149
+ )
150
+ .all()
151
+ )
152
+
153
+ relevant = []
154
+ for proposal in proposals:
155
+ if (
156
+ proposal.knowledge_item_id is not None
157
+ and str(proposal.knowledge_item_id) in knowledge_item_ids
158
+ ):
159
+ relevant.append(proposal)
160
+ continue
161
+
162
+ changes = proposal.proposed_changes or {}
163
+ source_id = changes.get("source_knowledge_item_id")
164
+ if source_id and str(source_id) in knowledge_item_ids:
165
+ relevant.append(proposal)
166
+
167
+ return relevant
168
+
169
  def _get_proposal_document_version_id(
170
  self,
171
  proposal: Proposal,
 
239
  if pending:
240
  return
241
 
242
+ # Also check for ARCHIVED proposals — they are unresolved
243
+ # (parked for later), so the workflow should not resume.
244
+ archived = self._get_archived_for_document_version(
245
+ proposal.workspace_id,
246
+ document_version_id,
247
+ )
248
+
249
+ if archived:
250
+ return
251
+
252
  from app.repositories.workflow_repository import (
253
  WorkflowRepository,
254
  )
 
273
  finally:
274
  executor.close()
275
 
276
+ def _pause_workflow_if_needed(
277
+ self,
278
+ proposal: Proposal,
279
+ ) -> None:
280
+ """
281
+ If a proposal is being restored and its workflow has already
282
+ moved past WAITING_FOR_REVIEW, revert the workflow back to
283
+ WAITING_FOR_REVIEW so the proposal appears in the review queue.
284
+ """
285
+ document_version_id = (
286
+ self._get_proposal_document_version_id(proposal)
287
+ )
288
+
289
+ if document_version_id is None:
290
+ return
291
+
292
+ from app.repositories.workflow_repository import (
293
+ WorkflowRepository,
294
+ )
295
+
296
+ workflow_repository = WorkflowRepository(self.db)
297
+
298
+ workflow = workflow_repository.get_by_document_version(
299
+ document_version_id
300
+ )
301
+
302
+ if workflow is None:
303
+ return
304
+
305
+ # Only revert COMPLETED workflows back to review.
306
+ # RUNNING/FAILED/CANCELLED should not be touched.
307
+ if workflow.status == WorkflowStatus.COMPLETED:
308
+ workflow.status = WorkflowStatus.WAITING_FOR_REVIEW
309
+ workflow.completed_at = None
310
+
311
  def approve(
312
  self,
313
  proposal_id: uuid.UUID,
 
394
  self.db.rollback()
395
  raise
396
 
397
+ # ------------------------------------------------------------------
398
+ # Archive / Restore
399
+ # ------------------------------------------------------------------
400
+
401
+ def archive(
402
+ self,
403
+ proposal_id: uuid.UUID,
404
+ user_id: uuid.UUID,
405
+ comments: str | None = None,
406
+ ) -> Proposal:
407
+ """
408
+ Park a proposal for later review.
409
+
410
+ Valid from PENDING or ARCHIVED (idempotent).
411
+ Does NOT mutate the underlying knowledge item.
412
+ """
413
+ proposal = self._get_proposal(
414
+ proposal_id,
415
+ user_id,
416
+ )
417
+
418
+ self._ensure_archivable(proposal)
419
+
420
+ try:
421
+ proposal.status = ProposalStatus.ARCHIVED
422
+ proposal.reviewed_at = datetime.now(timezone.utc)
423
+
424
+ self.db.commit()
425
+ self.db.refresh(proposal)
426
+
427
+ return proposal
428
+ except Exception:
429
+ self.db.rollback()
430
+ raise
431
+
432
+ def restore(
433
+ self,
434
+ proposal_id: uuid.UUID,
435
+ user_id: uuid.UUID,
436
+ ) -> Proposal:
437
+ """
438
+ Restore an archived proposal back to PENDING for review.
439
+
440
+ Valid only from ARCHIVED.
441
+ If the workflow has already completed/resumed, revert it
442
+ to WAITING_FOR_REVIEW so the proposal appears in the
443
+ review queue again.
444
+ """
445
+ proposal = self._get_proposal(
446
+ proposal_id,
447
+ user_id,
448
+ )
449
+
450
+ self._ensure_archived(proposal)
451
+
452
+ try:
453
+ proposal.status = ProposalStatus.PENDING
454
+ proposal.reviewed_at = None
455
+
456
+ # Revert the workflow to WAITING_FOR_REVIEW if it has
457
+ # moved past review (e.g., completed because all proposals
458
+ # were archived/resolved).
459
+ self._pause_workflow_if_needed(proposal)
460
+
461
+ self.db.commit()
462
+ self.db.refresh(proposal)
463
+
464
+ return proposal
465
+ except Exception:
466
+ self.db.rollback()
467
+ raise
468
+
469
  # ------------------------------------------------------------------
470
  # Rejection side-effects
471
  # ------------------------------------------------------------------
 
682
  self,
683
  proposal: Proposal,
684
  ) -> None:
685
+ """Allow approve/reject from PENDING or ARCHIVED states."""
686
+ if proposal.status not in (
687
+ ProposalStatus.PENDING,
688
+ ProposalStatus.ARCHIVED,
689
+ ):
690
  raise HTTPException(
691
  status_code=409,
692
  detail="Proposal has already been reviewed.",
693
  )
694
 
695
+ def _ensure_archivable(
696
+ self,
697
+ proposal: Proposal,
698
+ ) -> None:
699
+ """Allow archiving from PENDING only. Already-ARCHIVED is idempotent."""
700
+ if proposal.status == ProposalStatus.ARCHIVED:
701
+ return # idempotent
702
+ if proposal.status != ProposalStatus.PENDING:
703
+ raise HTTPException(
704
+ status_code=409,
705
+ detail=(
706
+ f"Cannot archive a proposal with status "
707
+ f"'{proposal.status.value}'. "
708
+ "Only PENDING proposals can be archived."
709
+ ),
710
+ )
711
+
712
+ def _ensure_archived(
713
+ self,
714
+ proposal: Proposal,
715
+ ) -> None:
716
+ if proposal.status != ProposalStatus.ARCHIVED:
717
+ raise HTTPException(
718
+ status_code=409,
719
+ detail=(
720
+ f"Cannot restore a proposal with status "
721
+ f"'{proposal.status.value}'. "
722
+ "Only ARCHIVED proposals can be restored."
723
+ ),
724
+ )
725
+
726
  def _verify_workspace_access(
727
  self,
728
  workspace_id: uuid.UUID,
backend/tests/test_proposal_archive.py ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 2 Proposal Archive/Restore Lifecycle Tests
3
+ =================================================
4
+
5
+ Tests the ARCHIVED proposal state and its transitions.
6
+
7
+ ISOLATION: Creates a dedicated test workspace, user, and all
8
+ associated records. Never uses existing workspaces.
9
+
10
+ Covers:
11
+ - CREATE + ARCHIVE → proposal ARCHIVED, knowledge stays PENDING
12
+ - ARCHIVED CREATE + RESTORE → proposal PENDING again
13
+ - ARCHIVED CREATE + APPROVE → knowledge becomes ACTIVE
14
+ - ARCHIVED CREATE + REJECT → proposal REJECTED, knowledge REJECTED
15
+ - UPDATE + ARCHIVE → knowledge unchanged
16
+ - ARCHIVED UPDATE + RESTORE → proposal PENDING again
17
+ - ARCHIVED UPDATE + APPROVE → knowledge updated
18
+ - ARCHIVED UPDATE + REJECT → knowledge unchanged
19
+ - Invalid: APPROVED → ARCHIVE (409)
20
+ - Invalid: REJECTED → ARCHIVE (409)
21
+ - Invalid: PENDING → RESTORE (409)
22
+ - Invalid: REJECTED → RESTORE (409)
23
+ - Idempotent: ARCHIVED → ARCHIVE (no error)
24
+ - Sentinel: pre-existing data unchanged
25
+ """
26
+ import sys
27
+ import os
28
+ import uuid
29
+ from datetime import datetime, timezone
30
+
31
+ import pytest
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
34
+
35
+ from app.database.database import SessionLocal
36
+ from app.models.commit import Commit
37
+ from app.models.document import Document
38
+ from app.models.document_version import DocumentVersion, DocumentVersionStatus
39
+ from app.models.knowledge_item import KnowledgeItem, KnowledgeStatus, KnowledgeType
40
+ from app.models.proposal import Proposal, ProposalStatus, ProposalType
41
+ from app.models.review import Review
42
+ from app.models.user import User
43
+ from app.models.workspace import Workspace
44
+ from app.services.proposal_review_service import ProposalReviewService
45
+
46
+
47
+ _TEST_RUN_ID = uuid.uuid4().hex[:8]
48
+
49
+
50
+ # ------------------------------------------------------------------
51
+ # Fixtures
52
+ # ------------------------------------------------------------------
53
+
54
+ @pytest.fixture(scope="module")
55
+ def db():
56
+ session = SessionLocal()
57
+ yield session
58
+ session.close()
59
+
60
+
61
+ @pytest.fixture(scope="module")
62
+ def isolated_env(db):
63
+ """Fully isolated test environment."""
64
+ test_user = User(
65
+ username=f"archive_test_user_{_TEST_RUN_ID}",
66
+ email=f"archive_{_TEST_RUN_ID}@test.docweave.local",
67
+ hashed_password="not_a_real_hash",
68
+ role="operator",
69
+ )
70
+ db.add(test_user)
71
+ db.flush()
72
+
73
+ test_workspace = Workspace(
74
+ name=f"archive_test_workspace_{_TEST_RUN_ID}",
75
+ description="Isolated workspace for archive lifecycle tests",
76
+ created_by=test_user.id,
77
+ )
78
+ db.add(test_workspace)
79
+ db.flush()
80
+
81
+ test_doc = Document(
82
+ workspace_id=test_workspace.id,
83
+ title=f"archive_test_doc_{_TEST_RUN_ID}",
84
+ document_type="GENERAL",
85
+ )
86
+ db.add(test_doc)
87
+ db.flush()
88
+
89
+ test_version = DocumentVersion(
90
+ document_id=test_doc.id,
91
+ version_number=1,
92
+ filename=f"archive_test_{_TEST_RUN_ID}.txt",
93
+ file_type=".txt",
94
+ checksum=f"archive_{_TEST_RUN_ID}",
95
+ storage_path=f"/tmp/archive_test_{_TEST_RUN_ID}.txt",
96
+ status=DocumentVersionStatus.PROCESSED,
97
+ uploaded_by=test_user.id,
98
+ )
99
+ db.add(test_version)
100
+ db.commit()
101
+
102
+ yield {
103
+ "user": test_user,
104
+ "workspace": test_workspace,
105
+ "doc": test_doc,
106
+ "version": test_version,
107
+ }
108
+
109
+ # Cleanup
110
+ try:
111
+ db.query(Commit).filter(
112
+ Commit.workspace_id == test_workspace.id
113
+ ).delete(synchronize_session=False)
114
+ proposal_ids_subq = db.query(Proposal.id).filter(
115
+ Proposal.workspace_id == test_workspace.id
116
+ )
117
+ db.query(Review).filter(
118
+ Review.proposal_id.in_(proposal_ids_subq)
119
+ ).delete(synchronize_session=False)
120
+ db.query(Proposal).filter(
121
+ Proposal.workspace_id == test_workspace.id
122
+ ).delete(synchronize_session=False)
123
+ db.query(KnowledgeItem).filter(
124
+ KnowledgeItem.workspace_id == test_workspace.id
125
+ ).delete(synchronize_session=False)
126
+ db.query(DocumentVersion).filter(
127
+ DocumentVersion.id == test_version.id
128
+ ).delete(synchronize_session=False)
129
+ db.query(Document).filter(
130
+ Document.id == test_doc.id
131
+ ).delete(synchronize_session=False)
132
+ db.query(Workspace).filter(
133
+ Workspace.id == test_workspace.id
134
+ ).delete(synchronize_session=False)
135
+ db.query(User).filter(
136
+ User.id == test_user.id
137
+ ).delete(synchronize_session=False)
138
+ db.commit()
139
+ except Exception:
140
+ db.rollback()
141
+
142
+
143
+ @pytest.fixture()
144
+ def test_db():
145
+ session = SessionLocal()
146
+ yield session
147
+ session.close()
148
+
149
+
150
+ # ------------------------------------------------------------------
151
+ # Helpers
152
+ # ------------------------------------------------------------------
153
+
154
+ def _create_item(db, workspace, version, status=KnowledgeStatus.PENDING):
155
+ item = KnowledgeItem(
156
+ workspace_id=workspace.id,
157
+ document_version_id=version.id,
158
+ type=KnowledgeType.CLAIM,
159
+ title=f"Archive Test {uuid.uuid4().hex[:8]}",
160
+ value="Test value for archive lifecycle",
161
+ summary="Test summary",
162
+ confidence=0.9,
163
+ status=status,
164
+ )
165
+ db.add(item)
166
+ db.commit()
167
+ db.refresh(item)
168
+ return item
169
+
170
+
171
+ def _create_proposal(db, workspace, item, proposal_type, proposed_changes=None):
172
+ proposal = Proposal(
173
+ workspace_id=workspace.id,
174
+ knowledge_item_id=item.id if item else None,
175
+ proposal_type=proposal_type,
176
+ status=ProposalStatus.PENDING,
177
+ summary=f"Archive test {proposal_type.value} {uuid.uuid4().hex[:6]}",
178
+ rationale="Testing archive lifecycle",
179
+ proposed_changes=proposed_changes or {"title": "test", "value": "test"},
180
+ )
181
+ db.add(proposal)
182
+ db.commit()
183
+ db.refresh(proposal)
184
+ return proposal
185
+
186
+
187
+ # ------------------------------------------------------------------
188
+ # CREATE + ARCHIVE
189
+ # ------------------------------------------------------------------
190
+
191
+ def test_create_archive(test_db, isolated_env):
192
+ """Archiving a CREATE proposal parks it; knowledge stays PENDING."""
193
+ env = isolated_env
194
+ item = _create_item(test_db, env["workspace"], env["version"])
195
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
196
+
197
+ service = ProposalReviewService(test_db)
198
+ result = service.archive(
199
+ proposal_id=proposal.id,
200
+ user_id=env["user"].id,
201
+ )
202
+
203
+ test_db.refresh(item)
204
+ test_db.refresh(proposal)
205
+
206
+ assert proposal.status == ProposalStatus.ARCHIVED
207
+ assert proposal.reviewed_at is not None
208
+ assert item.status == KnowledgeStatus.PENDING # NOT changed
209
+
210
+
211
+ # ------------------------------------------------------------------
212
+ # ARCHIVED CREATE + RESTORE
213
+ # ------------------------------------------------------------------
214
+
215
+ def test_archived_create_restore(test_db, isolated_env):
216
+ """Restoring an archived CREATE proposal returns it to PENDING."""
217
+ env = isolated_env
218
+ item = _create_item(test_db, env["workspace"], env["version"])
219
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
220
+
221
+ service = ProposalReviewService(test_db)
222
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
223
+
224
+ test_db.refresh(proposal)
225
+ assert proposal.status == ProposalStatus.ARCHIVED
226
+
227
+ service.restore(proposal_id=proposal.id, user_id=env["user"].id)
228
+
229
+ test_db.refresh(proposal)
230
+ assert proposal.status == ProposalStatus.PENDING
231
+ assert proposal.reviewed_at is None
232
+
233
+
234
+ # ------------------------------------------------------------------
235
+ # ARCHIVED CREATE + APPROVE
236
+ # ------------------------------------------------------------------
237
+
238
+ def test_archived_create_approve(test_db, isolated_env):
239
+ """Approving directly from ARCHIVED promotes knowledge to ACTIVE."""
240
+ env = isolated_env
241
+ item = _create_item(test_db, env["workspace"], env["version"])
242
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
243
+
244
+ service = ProposalReviewService(test_db)
245
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
246
+
247
+ commit = service.approve(
248
+ proposal_id=proposal.id,
249
+ user_id=env["user"].id,
250
+ comments="Approved from archived",
251
+ )
252
+
253
+ test_db.refresh(item)
254
+ test_db.refresh(proposal)
255
+
256
+ assert proposal.status == ProposalStatus.APPROVED
257
+ assert item.status == KnowledgeStatus.ACTIVE
258
+ assert commit is not None
259
+
260
+
261
+ # ------------------------------------------------------------------
262
+ # ARCHIVED CREATE + REJECT
263
+ # ------------------------------------------------------------------
264
+
265
+ def test_archived_create_reject(test_db, isolated_env):
266
+ """Rejecting directly from ARCHIVED marks proposal REJECTED, knowledge REJECTED."""
267
+ env = isolated_env
268
+ item = _create_item(test_db, env["workspace"], env["version"])
269
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
270
+
271
+ service = ProposalReviewService(test_db)
272
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
273
+
274
+ review = service.reject(
275
+ proposal_id=proposal.id,
276
+ user_id=env["user"].id,
277
+ comments="Rejected from archived",
278
+ )
279
+
280
+ test_db.refresh(item)
281
+ test_db.refresh(proposal)
282
+
283
+ assert proposal.status == ProposalStatus.REJECTED
284
+ assert item.status == KnowledgeStatus.REJECTED
285
+ assert review is not None
286
+
287
+
288
+ # ------------------------------------------------------------------
289
+ # UPDATE + ARCHIVE
290
+ # ------------------------------------------------------------------
291
+
292
+ def test_update_archive(test_db, isolated_env):
293
+ """Archiving an UPDATE proposal leaves existing knowledge unchanged."""
294
+ env = isolated_env
295
+ target = _create_item(test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE)
296
+ source = _create_item(test_db, env["workspace"], env["version"])
297
+ original_value = target.value
298
+
299
+ proposal = _create_proposal(
300
+ test_db, env["workspace"], target, ProposalType.UPDATE,
301
+ proposed_changes={
302
+ "existing": {"value": target.value},
303
+ "proposed": {"value": "Should not be applied yet"},
304
+ "source_knowledge_item_id": str(source.id),
305
+ },
306
+ )
307
+
308
+ service = ProposalReviewService(test_db)
309
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
310
+
311
+ test_db.refresh(target)
312
+ test_db.refresh(proposal)
313
+
314
+ assert proposal.status == ProposalStatus.ARCHIVED
315
+ assert target.status == KnowledgeStatus.ACTIVE
316
+ assert target.value == original_value
317
+
318
+
319
+ # ------------------------------------------------------------------
320
+ # ARCHIVED UPDATE + RESTORE
321
+ # ------------------------------------------------------------------
322
+
323
+ def test_archived_update_restore(test_db, isolated_env):
324
+ """Restoring an archived UPDATE returns it to PENDING."""
325
+ env = isolated_env
326
+ target = _create_item(test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE)
327
+ proposal = _create_proposal(
328
+ test_db, env["workspace"], target, ProposalType.UPDATE,
329
+ proposed_changes={
330
+ "existing": {"value": target.value},
331
+ "proposed": {"value": "Maybe later"},
332
+ "source_knowledge_item_id": str(uuid.uuid4()),
333
+ },
334
+ )
335
+
336
+ service = ProposalReviewService(test_db)
337
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
338
+ service.restore(proposal_id=proposal.id, user_id=env["user"].id)
339
+
340
+ test_db.refresh(proposal)
341
+ assert proposal.status == ProposalStatus.PENDING
342
+
343
+
344
+ # ------------------------------------------------------------------
345
+ # ARCHIVED UPDATE + APPROVE
346
+ # ------------------------------------------------------------------
347
+
348
+ def test_archived_update_approve(test_db, isolated_env):
349
+ """Approving directly from ARCHIVED applies the update."""
350
+ env = isolated_env
351
+ target = _create_item(test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE)
352
+ source = _create_item(test_db, env["workspace"], env["version"])
353
+
354
+ proposal = _create_proposal(
355
+ test_db, env["workspace"], target, ProposalType.UPDATE,
356
+ proposed_changes={
357
+ "existing": {"value": target.value},
358
+ "proposed": {"value": "Applied from archived", "confidence": 0.99},
359
+ "source_knowledge_item_id": str(source.id),
360
+ },
361
+ )
362
+
363
+ service = ProposalReviewService(test_db)
364
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
365
+
366
+ commit = service.approve(
367
+ proposal_id=proposal.id,
368
+ user_id=env["user"].id,
369
+ )
370
+
371
+ test_db.refresh(target)
372
+ test_db.refresh(source)
373
+
374
+ assert target.value == "Applied from archived"
375
+ assert target.confidence == 0.99
376
+ assert source.status == KnowledgeStatus.SUPERSEDED
377
+ assert commit is not None
378
+
379
+
380
+ # ------------------------------------------------------------------
381
+ # ARCHIVED UPDATE + REJECT
382
+ # ------------------------------------------------------------------
383
+
384
+ def test_archived_update_reject(test_db, isolated_env):
385
+ """Rejecting from ARCHIVED leaves existing knowledge unchanged."""
386
+ env = isolated_env
387
+ target = _create_item(test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE)
388
+ original_value = target.value
389
+
390
+ proposal = _create_proposal(
391
+ test_db, env["workspace"], target, ProposalType.UPDATE,
392
+ proposed_changes={
393
+ "existing": {"value": target.value},
394
+ "proposed": {"value": "Should never apply"},
395
+ },
396
+ )
397
+
398
+ service = ProposalReviewService(test_db)
399
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
400
+ service.reject(proposal_id=proposal.id, user_id=env["user"].id)
401
+
402
+ test_db.refresh(target)
403
+ assert target.status == KnowledgeStatus.ACTIVE
404
+ assert target.value == original_value
405
+
406
+
407
+ # ------------------------------------------------------------------
408
+ # Invalid: APPROVED → ARCHIVE (409)
409
+ # ------------------------------------------------------------------
410
+
411
+ def test_cannot_archive_approved(test_db, isolated_env):
412
+ """Cannot archive an already-approved proposal."""
413
+ env = isolated_env
414
+ item = _create_item(test_db, env["workspace"], env["version"])
415
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
416
+
417
+ service = ProposalReviewService(test_db)
418
+ service.approve(proposal_id=proposal.id, user_id=env["user"].id)
419
+
420
+ from fastapi import HTTPException
421
+ with pytest.raises(HTTPException) as exc_info:
422
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
423
+ assert exc_info.value.status_code == 409
424
+
425
+
426
+ # ------------------------------------------------------------------
427
+ # Invalid: REJECTED → ARCHIVE (409)
428
+ # ------------------------------------------------------------------
429
+
430
+ def test_cannot_archive_rejected(test_db, isolated_env):
431
+ """Cannot archive an already-rejected proposal."""
432
+ env = isolated_env
433
+ item = _create_item(test_db, env["workspace"], env["version"])
434
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
435
+
436
+ service = ProposalReviewService(test_db)
437
+ service.reject(proposal_id=proposal.id, user_id=env["user"].id)
438
+
439
+ from fastapi import HTTPException
440
+ with pytest.raises(HTTPException) as exc_info:
441
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
442
+ assert exc_info.value.status_code == 409
443
+
444
+
445
+ # ------------------------------------------------------------------
446
+ # Invalid: PENDING → RESTORE (409)
447
+ # ------------------------------------------------------------------
448
+
449
+ def test_cannot_restore_pending(test_db, isolated_env):
450
+ """Cannot restore a PENDING proposal (it's not archived)."""
451
+ env = isolated_env
452
+ item = _create_item(test_db, env["workspace"], env["version"])
453
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
454
+
455
+ service = ProposalReviewService(test_db)
456
+
457
+ from fastapi import HTTPException
458
+ with pytest.raises(HTTPException) as exc_info:
459
+ service.restore(proposal_id=proposal.id, user_id=env["user"].id)
460
+ assert exc_info.value.status_code == 409
461
+
462
+
463
+ # ------------------------------------------------------------------
464
+ # Invalid: REJECTED → RESTORE (409)
465
+ # ------------------------------------------------------------------
466
+
467
+ def test_cannot_restore_rejected(test_db, isolated_env):
468
+ """Cannot restore a REJECTED proposal."""
469
+ env = isolated_env
470
+ item = _create_item(test_db, env["workspace"], env["version"])
471
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
472
+
473
+ service = ProposalReviewService(test_db)
474
+ service.reject(proposal_id=proposal.id, user_id=env["user"].id)
475
+
476
+ from fastapi import HTTPException
477
+ with pytest.raises(HTTPException) as exc_info:
478
+ service.restore(proposal_id=proposal.id, user_id=env["user"].id)
479
+ assert exc_info.value.status_code == 409
480
+
481
+
482
+ # ------------------------------------------------------------------
483
+ # Idempotent: ARCHIVED → ARCHIVE (no error)
484
+ # ------------------------------------------------------------------
485
+
486
+ def test_archive_idempotent(test_db, isolated_env):
487
+ """Archiving an already-archived proposal is idempotent."""
488
+ env = isolated_env
489
+ item = _create_item(test_db, env["workspace"], env["version"])
490
+ proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE)
491
+
492
+ service = ProposalReviewService(test_db)
493
+ service.archive(proposal_id=proposal.id, user_id=env["user"].id)
494
+
495
+ # Second archive should not raise
496
+ result = service.archive(proposal_id=proposal.id, user_id=env["user"].id)
497
+
498
+ test_db.refresh(proposal)
499
+ assert proposal.status == ProposalStatus.ARCHIVED
500
+
501
+
502
+ # ------------------------------------------------------------------
503
+ # Sentinel: pre-existing data unchanged
504
+ # ------------------------------------------------------------------
505
+
506
+ def test_sentinel_no_real_data_modified(test_db, isolated_env):
507
+ """Verify no KnowledgeItem outside our test workspace was modified."""
508
+ env = isolated_env
509
+ from sqlalchemy import text
510
+
511
+ result = test_db.execute(text("""
512
+ SELECT COUNT(*) FROM knowledge_items
513
+ WHERE workspace_id != :ws_id
514
+ AND updated_at > :threshold
515
+ """), {
516
+ "ws_id": str(env["workspace"].id),
517
+ "threshold": env["workspace"].created_at,
518
+ })
519
+
520
+ modified_count = result.scalar()
521
+ assert modified_count == 0, (
522
+ f"{modified_count} knowledge items outside the test workspace were "
523
+ f"modified during this test run. Test isolation is broken."
524
+ )
frontend/src/api/proposals.js CHANGED
@@ -28,3 +28,19 @@ export async function approveProposal(proposalId, comments = null) {
28
  export async function rejectProposal(proposalId, comments = null) {
29
  return apiRequest(`/proposals/${proposalId}/reject`, "POST", { comments });
30
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  export async function rejectProposal(proposalId, comments = null) {
29
  return apiRequest(`/proposals/${proposalId}/reject`, "POST", { comments });
30
  }
31
+
32
+ /**
33
+ * POST /proposals/{proposal_id}/archive
34
+ * → ProposalResponse
35
+ */
36
+ export async function archiveProposal(proposalId, comments = null) {
37
+ return apiRequest(`/proposals/${proposalId}/archive`, "POST", { comments });
38
+ }
39
+
40
+ /**
41
+ * POST /proposals/{proposal_id}/restore
42
+ * → ProposalResponse
43
+ */
44
+ export async function restoreProposal(proposalId) {
45
+ return apiRequest(`/proposals/${proposalId}/restore`, "POST");
46
+ }
frontend/src/components/proposals/ProposalCard.jsx CHANGED
@@ -11,11 +11,16 @@ const TYPE_TONE = {
11
  SPLIT: "info",
12
  };
13
 
 
 
 
 
 
 
 
14
  // Confirmed field sets per proposal_type, from real API responses:
15
  // - UPDATE: { existing: {...}, proposed: {...}, source_knowledge_item_id }
16
  // - CREATE: { type, title, value, summary, attributes, confidence } (flat)
17
- // Anything outside these known keys, or any other proposal_type
18
- // (DELETE/MERGE/SPLIT — shape not yet observed), falls back to raw JSON.
19
  const COMPARE_FIELDS = [
20
  { key: "value", label: "Value" },
21
  { key: "summary", label: "Summary" },
@@ -139,16 +144,22 @@ function ProposedChanges({ proposalType, changes }) {
139
  }
140
 
141
  /**
142
- * One PENDING proposal with Approve/Reject actions.
143
- * proposal shape matches ProposalResponse (app/schemas/proposal.py) exactly:
144
- * id, workspace_id, knowledge_item_id, proposal_type, status, summary,
145
- * rationale, proposed_changes, created_at, reviewed_at.
 
 
146
  */
147
- export function ProposalCard({ proposal, onApprove, onReject, busy }) {
148
  const [comments, setComments] = useState("");
149
  const [showComments, setShowComments] = useState(false);
150
  const [showDetails, setShowDetails] = useState(false);
151
 
 
 
 
 
152
  return (
153
  <Card>
154
  <CardBody>
@@ -156,6 +167,11 @@ export function ProposalCard({ proposal, onApprove, onReject, busy }) {
156
  <Badge tone={TYPE_TONE[proposal.proposal_type] || "default"}>
157
  {PROPOSAL_TYPE_LABELS[proposal.proposal_type] || proposal.proposal_type}
158
  </Badge>
 
 
 
 
 
159
  <span className="dw-proposal-card__timestamp">
160
  {new Date(proposal.created_at).toLocaleString()}
161
  </span>
@@ -181,7 +197,7 @@ export function ProposalCard({ proposal, onApprove, onReject, busy }) {
181
  />
182
  )}
183
 
184
- {showComments && (
185
  <textarea
186
  className="dw-proposal-card__textarea"
187
  value={comments}
@@ -191,35 +207,80 @@ export function ProposalCard({ proposal, onApprove, onReject, busy }) {
191
  />
192
  )}
193
 
194
- <div className="dw-proposal-card__actions">
195
- <Button
196
- size="sm"
197
- variant="primary"
198
- loading={busy === "approve"}
199
- disabled={Boolean(busy)}
200
- onClick={() => onApprove(proposal.id, comments || null)}
201
- >
202
- Approve
203
- </Button>
204
- <Button
205
- size="sm"
206
- variant="danger"
207
- loading={busy === "reject"}
208
- disabled={Boolean(busy)}
209
- onClick={() => onReject(proposal.id, comments || null)}
210
- >
211
- Reject
212
- </Button>
213
- <Button
214
- size="sm"
215
- variant="ghost"
216
- disabled={Boolean(busy)}
217
- onClick={() => setShowComments((v) => !v)}
218
- >
219
- {showComments ? "Hide comment" : "Add comment"}
220
- </Button>
221
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  </CardBody>
223
  </Card>
224
  );
225
- }
 
11
  SPLIT: "info",
12
  };
13
 
14
+ const STATUS_TONE = {
15
+ PENDING: "default",
16
+ APPROVED: "success",
17
+ REJECTED: "danger",
18
+ ARCHIVED: "info",
19
+ };
20
+
21
  // Confirmed field sets per proposal_type, from real API responses:
22
  // - UPDATE: { existing: {...}, proposed: {...}, source_knowledge_item_id }
23
  // - CREATE: { type, title, value, summary, attributes, confidence } (flat)
 
 
24
  const COMPARE_FIELDS = [
25
  { key: "value", label: "Value" },
26
  { key: "summary", label: "Summary" },
 
144
  }
145
 
146
  /**
147
+ * Proposal card with context-appropriate actions:
148
+ *
149
+ * PENDING: Approve / Reject / Archive
150
+ * ARCHIVED: Restore (returns to PENDING for review)
151
+ * APPROVED: Read-only history
152
+ * REJECTED: Read-only history
153
  */
154
+ export function ProposalCard({ proposal, onApprove, onReject, onArchive, onRestore, busy }) {
155
  const [comments, setComments] = useState("");
156
  const [showComments, setShowComments] = useState(false);
157
  const [showDetails, setShowDetails] = useState(false);
158
 
159
+ const isPending = proposal.status === "PENDING";
160
+ const isArchived = proposal.status === "ARCHIVED";
161
+ const isReviewable = isPending || isArchived;
162
+
163
  return (
164
  <Card>
165
  <CardBody>
 
167
  <Badge tone={TYPE_TONE[proposal.proposal_type] || "default"}>
168
  {PROPOSAL_TYPE_LABELS[proposal.proposal_type] || proposal.proposal_type}
169
  </Badge>
170
+ {!isPending && (
171
+ <Badge tone={STATUS_TONE[proposal.status] || "default"}>
172
+ {proposal.status}
173
+ </Badge>
174
+ )}
175
  <span className="dw-proposal-card__timestamp">
176
  {new Date(proposal.created_at).toLocaleString()}
177
  </span>
 
197
  />
198
  )}
199
 
200
+ {isReviewable && showComments && (
201
  <textarea
202
  className="dw-proposal-card__textarea"
203
  value={comments}
 
207
  />
208
  )}
209
 
210
+ {/* PENDING: Approve / Reject / Archive */}
211
+ {isPending && (
212
+ <div className="dw-proposal-card__actions">
213
+ <Button
214
+ size="sm"
215
+ variant="primary"
216
+ loading={busy === "approve"}
217
+ disabled={Boolean(busy)}
218
+ onClick={() => onApprove?.(proposal.id, comments || null)}
219
+ >
220
+ Approve
221
+ </Button>
222
+ <Button
223
+ size="sm"
224
+ variant="danger"
225
+ loading={busy === "reject"}
226
+ disabled={Boolean(busy)}
227
+ onClick={() => onReject?.(proposal.id, comments || null)}
228
+ >
229
+ Reject
230
+ </Button>
231
+ <Button
232
+ size="sm"
233
+ variant="warning"
234
+ loading={busy === "archive"}
235
+ disabled={Boolean(busy)}
236
+ onClick={() => onArchive?.(proposal.id, comments || null)}
237
+ >
238
+ Archive
239
+ </Button>
240
+ <Button
241
+ size="sm"
242
+ variant="ghost"
243
+ disabled={Boolean(busy)}
244
+ onClick={() => setShowComments((v) => !v)}
245
+ >
246
+ {showComments ? "Hide comment" : "Add comment"}
247
+ </Button>
248
+ </div>
249
+ )}
250
+
251
+ {/* ARCHIVED: Restore */}
252
+ {isArchived && (
253
+ <div className="dw-proposal-card__actions">
254
+ <Button
255
+ size="sm"
256
+ variant="primary"
257
+ loading={busy === "restore"}
258
+ disabled={Boolean(busy)}
259
+ onClick={() => onRestore?.(proposal.id)}
260
+ >
261
+ Restore to review
262
+ </Button>
263
+ <Button
264
+ size="sm"
265
+ variant="primary"
266
+ loading={busy === "approve"}
267
+ disabled={Boolean(busy)}
268
+ onClick={() => onApprove?.(proposal.id, comments || null)}
269
+ >
270
+ Approve
271
+ </Button>
272
+ <Button
273
+ size="sm"
274
+ variant="danger"
275
+ loading={busy === "reject"}
276
+ disabled={Boolean(busy)}
277
+ onClick={() => onReject?.(proposal.id, comments || null)}
278
+ >
279
+ Reject
280
+ </Button>
281
+ </div>
282
+ )}
283
  </CardBody>
284
  </Card>
285
  );
286
+ }
frontend/src/components/proposals/ProposalReviewList.jsx CHANGED
@@ -1,12 +1,12 @@
1
  import { useCallback, useEffect, useState } from "react";
2
- import { listPendingProposals, approveProposal, rejectProposal } from "../../api/proposals";
3
  import { ProposalCard } from "./ProposalCard";
4
  import { LoadingState, EmptyState } from "../ui";
5
 
6
  /**
7
  * Fetches and renders PENDING proposals for a workspace, with real
8
- * approve/reject actions. If documentVersionId is provided, results
9
- * are scoped to that document version.
10
  *
11
  * State is never cached locally: every mount and every decision refetches
12
  * from the API, so a refresh always reflects real backend state.
@@ -50,8 +50,12 @@ export function ProposalReviewList({ workspaceId, documentVersionId, onDecision
50
  try {
51
  if (action === "approve") {
52
  await approveProposal(proposalId, comments);
53
- } else {
54
  await rejectProposal(proposalId, comments);
 
 
 
 
55
  }
56
  onDecision?.();
57
  // Refresh in background to sync with server
@@ -92,6 +96,8 @@ export function ProposalReviewList({ workspaceId, documentVersionId, onDecision
92
  busy={busyId === proposal.id ? busyAction : null}
93
  onApprove={(id, c) => handleDecision("approve", id, c)}
94
  onReject={(id, c) => handleDecision("reject", id, c)}
 
 
95
  />
96
  ))}
97
  </div>
 
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { listPendingProposals, approveProposal, rejectProposal, archiveProposal, restoreProposal } from "../../api/proposals";
3
  import { ProposalCard } from "./ProposalCard";
4
  import { LoadingState, EmptyState } from "../ui";
5
 
6
  /**
7
  * Fetches and renders PENDING proposals for a workspace, with real
8
+ * approve/reject/archive actions. If documentVersionId is provided,
9
+ * results are scoped to that document version.
10
  *
11
  * State is never cached locally: every mount and every decision refetches
12
  * from the API, so a refresh always reflects real backend state.
 
50
  try {
51
  if (action === "approve") {
52
  await approveProposal(proposalId, comments);
53
+ } else if (action === "reject") {
54
  await rejectProposal(proposalId, comments);
55
+ } else if (action === "archive") {
56
+ await archiveProposal(proposalId, comments);
57
+ } else if (action === "restore") {
58
+ await restoreProposal(proposalId);
59
  }
60
  onDecision?.();
61
  // Refresh in background to sync with server
 
96
  busy={busyId === proposal.id ? busyAction : null}
97
  onApprove={(id, c) => handleDecision("approve", id, c)}
98
  onReject={(id, c) => handleDecision("reject", id, c)}
99
+ onArchive={(id, c) => handleDecision("archive", id, c)}
100
+ onRestore={(id) => handleDecision("restore", id, null)}
101
  />
102
  ))}
103
  </div>
frontend/src/components/ui/Button.css CHANGED
@@ -72,6 +72,16 @@
72
  color: #ffffff;
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
75
  .dw-btn__icon {
76
  display: inline-flex;
77
  align-items: center;
 
72
  color: #ffffff;
73
  }
74
 
75
+ .dw-btn--warning {
76
+ background: rgba(234, 179, 8, 0.15);
77
+ border-color: transparent;
78
+ color: #eab308;
79
+ }
80
+ .dw-btn--warning:hover:not(:disabled) {
81
+ background: #eab308;
82
+ color: #000000;
83
+ }
84
+
85
  .dw-btn__icon {
86
  display: inline-flex;
87
  align-items: center;
frontend/src/components/ui/Button.jsx CHANGED
@@ -3,7 +3,7 @@ import { Spinner } from "./Spinner";
3
  import "./Button.css";
4
 
5
  /**
6
- * variant: "primary" | "secondary" | "ghost" | "danger"
7
  * size: "sm" | "md"
8
  */
9
  export const Button = forwardRef(function Button(
 
3
  import "./Button.css";
4
 
5
  /**
6
+ * variant: "primary" | "secondary" | "ghost" | "danger" | "warning"
7
  * size: "sm" | "md"
8
  */
9
  export const Button = forwardRef(function Button(
frontend/src/pages/Activity.jsx CHANGED
@@ -13,6 +13,7 @@ const TYPE_LABELS = {
13
  proposal_created: "Proposal Created",
14
  proposal_approved: "Proposal Approved",
15
  proposal_rejected: "Proposal Rejected",
 
16
  commit_created: "Commit Created",
17
  };
18
 
@@ -24,6 +25,7 @@ const TYPE_TONE = {
24
  proposal_created: "info",
25
  proposal_approved: "success",
26
  proposal_rejected: "danger",
 
27
  commit_created: "success",
28
  };
29
 
 
13
  proposal_created: "Proposal Created",
14
  proposal_approved: "Proposal Approved",
15
  proposal_rejected: "Proposal Rejected",
16
+ proposal_archived: "Proposal Archived",
17
  commit_created: "Commit Created",
18
  };
19
 
 
25
  proposal_created: "info",
26
  proposal_approved: "success",
27
  proposal_rejected: "danger",
28
+ proposal_archived: "info",
29
  commit_created: "success",
30
  };
31
 
frontend/src/pages/Dashboard.jsx CHANGED
@@ -24,6 +24,7 @@ const EVENT_ICONS = {
24
  proposal_created: "📝",
25
  proposal_approved: "✓",
26
  proposal_rejected: "✗",
 
27
  commit_created: "💾",
28
  };
29
 
 
24
  proposal_created: "📝",
25
  proposal_approved: "✓",
26
  proposal_rejected: "✗",
27
+ proposal_archived: "📦",
28
  commit_created: "💾",
29
  };
30
 
frontend/src/pages/Documents.jsx CHANGED
@@ -7,25 +7,55 @@ import { WORKFLOW_STATUS_LABELS } from "../utils/labels";
7
  import "./Documents.css";
8
 
9
  const STATUS_TONE = {
10
- PENDING: "default", RUNNING: "accent", WAITING_FOR_REVIEW: "warning",
11
- COMPLETED: "success", FAILED: "danger", CANCELLED: "info",
 
 
 
 
12
  };
13
 
 
 
 
14
  function FileIcon({ filename }) {
15
  const ext = (filename || "").split(".").pop()?.toLowerCase() || "";
16
- const map = { pdf: "pdf", docx: "doc", doc: "doc", txt: "txt", csv: "csv", xlsx: "xls", xls: "xls", png: "img", jpg: "img", jpeg: "img" };
 
 
 
 
 
 
 
 
 
 
 
17
  const type = map[ext] || "txt";
18
- return <span className={`dw-table__file-icon dw-table__file-icon--${type}`}>{ext.slice(0, 3)}</span>;
 
 
 
 
 
19
  }
20
 
21
  function ConfidenceInline({ value }) {
22
- if (value == null) return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
 
 
 
23
  const pct = Math.round(value * 100);
24
  const cls = pct >= 85 ? "high" : pct >= 60 ? "med" : "low";
 
25
  return (
26
  <span className="dw-table__confidence">
27
  <span className="dw-table__confidence-bar">
28
- <span className={`dw-table__confidence-fill dw-table__confidence-fill--${cls}`} style={{ width: `${pct}%` }} />
 
 
 
29
  </span>
30
  <span>{pct}%</span>
31
  </span>
@@ -36,6 +66,8 @@ export default function Documents() {
36
  const { workspace, loading: wsLoading, error: wsError } = useWorkspace();
37
  const navigate = useNavigate();
38
  const fileInputRef = useRef(null);
 
 
39
  const [uploading, setUploading] = useState(false);
40
  const [documents, setDocuments] = useState([]);
41
  const [docsLoading, setDocsLoading] = useState(true);
@@ -44,47 +76,163 @@ export default function Documents() {
44
  const [search, setSearch] = useState("");
45
 
46
  const loadDocuments = useCallback(async () => {
47
- if (!workspace) return;
48
- setDocsLoading(true);
49
  try {
50
  const result = await listDocuments(workspace.id);
51
- if (Array.isArray(result)) setDocuments(result);
52
- } catch { /* silent */ } finally { setDocsLoading(false); }
 
 
 
 
 
 
 
 
53
  }, [workspace]);
54
 
55
- useEffect(() => { loadDocuments(); }, [loadDocuments]);
 
 
 
 
56
 
57
- const handleUpload = useCallback(async (e) => {
58
- const files = Array.from(e.target.files || []);
59
- if (!files.length || !workspace) return;
60
- setUploading(true); setUploadError(null);
61
- try {
62
- const result = await uploadDocuments(workspace.id, files);
63
- if (result?.detail) { setUploadError(result.detail); return; }
64
- await loadDocuments();
65
- } catch { setUploadError("Upload failed."); }
66
- finally { setUploading(false); e.target.value = ""; }
67
- }, [workspace, loadDocuments]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  const handleDelete = (e, doc) => {
70
  e.stopPropagation();
71
  e.preventDefault();
72
- // Optimistically remove from UI immediately
 
73
  setDocuments((prev) => prev.filter((d) => d.id !== doc.id));
74
- deleteDocument(doc.id).catch(() => loadDocuments()); // revert on failure
 
75
  };
76
 
77
  const handleRetry = (e, doc) => {
78
  e.stopPropagation();
 
79
  retryDocument(doc.id).then(loadDocuments);
80
  };
81
 
82
- // Filter + search
83
  let filtered = documents;
84
- if (filter !== "all") filtered = filtered.filter((d) => d.workflow_status === filter);
 
 
 
 
 
 
85
  if (search.trim()) {
86
  const q = search.toLowerCase();
87
- filtered = filtered.filter((d) => d.filename?.toLowerCase().includes(q));
 
 
 
88
  }
89
 
90
  const FILTERS = [
@@ -97,7 +245,9 @@ export default function Documents() {
97
 
98
  const columns = [
99
  {
100
- key: "filename", label: "Document", sortable: true,
 
 
101
  render: (val, row) => (
102
  <span className="dw-table__filename-cell">
103
  <FileIcon filename={val} />
@@ -106,33 +256,99 @@ export default function Documents() {
106
  ),
107
  },
108
  {
109
- key: "document_type", label: "Type", sortable: true, width: "90px",
110
- render: (val) => <span style={{ fontSize: "var(--text-xs)", color: "var(--color-text-muted)" }}>{val || "—"}</span>,
 
 
 
 
 
 
 
 
 
 
 
 
111
  },
112
  {
113
- key: "uploaded_at", label: "Uploaded", sortable: true, width: "160px",
114
- render: (val) => <span style={{ fontSize: "var(--text-xs)" }}>{val ? new Date(val).toLocaleString() : "—"}</span>,
 
 
 
 
 
 
 
115
  },
116
  {
117
- key: "workflow_status", label: "Status", sortable: true, width: "120px",
118
- render: (val) => val ? <Badge tone={STATUS_TONE[val] || "default"}>{WORKFLOW_STATUS_LABELS[val] || val}</Badge> : <span style={{ color: "var(--color-text-muted)" }}>—</span>,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  },
120
  {
121
- key: "actions", label: "", width: "100px", align: "right",
 
 
 
122
  render: (_, row) => (
123
  <span className="dw-table__actions">
124
- <button className="dw-table__action-btn dw-table__action-btn--retry" title="Retry" onClick={(e) => handleRetry(e, row)}>↻</button>
 
 
 
 
 
 
 
125
  {row.workflow_id && (
126
- <button className="dw-table__action-btn" title="View workflow" onClick={(e) => { e.stopPropagation(); navigate(`/workflows/${row.workflow_id}`); }}>→</button>
 
 
 
 
 
 
 
 
 
127
  )}
128
- <button className="dw-table__action-btn dw-table__action-btn--danger" title="Delete" onClick={(e) => handleDelete(e, row)}>×</button>
 
 
 
 
 
 
 
129
  </span>
130
  ),
131
  },
132
  ];
133
 
134
- if (wsLoading) return <LoadingState label="Loading..." />;
135
- if (wsError) return <EmptyState title="Error" description={wsError} />;
 
 
 
 
 
136
 
137
  return (
138
  <div className="dw-page">
@@ -140,16 +356,39 @@ export default function Documents() {
140
  <div className="dw-docs__header-row">
141
  <div>
142
  <h1>Documents</h1>
143
- <p>Your source files. Upload documents to extract and govern knowledge.</p>
 
 
 
144
  </div>
 
145
  <div>
146
- <input ref={fileInputRef} type="file" multiple onChange={handleUpload} style={{ display: "none" }} disabled={uploading} />
147
- <Button loading={uploading} onClick={() => fileInputRef.current?.click()}>+ Upload</Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  </div>
149
  </div>
150
  </header>
151
 
152
- {uploadError && <div className="dw-docs__error">{uploadError}</div>}
 
 
 
 
153
 
154
  {/* Controls row */}
155
  <div className="dw-docs__controls">
@@ -159,17 +398,29 @@ export default function Documents() {
159
  value={search}
160
  onChange={(e) => setSearch(e.target.value)}
161
  />
 
162
  <div className="dw-docs__filters">
163
  {FILTERS.map((f) => (
164
  <button
165
  key={f.key}
166
- className={`dw-docs__filter ${filter === f.key ? "dw-docs__filter--active" : ""}`}
 
 
 
 
167
  onClick={() => setFilter(f.key)}
168
  >
169
  {f.label}
 
170
  {f.key !== "all" && (
171
  <span className="dw-docs__filter-count">
172
- {documents.filter((d) => d.workflow_status === f.key).length}
 
 
 
 
 
 
173
  </span>
174
  )}
175
  </button>
@@ -182,14 +433,29 @@ export default function Documents() {
182
  <div className="dw-docs__pills">
183
  {filter !== "all" && (
184
  <span className="dw-docs__pill">
185
- {FILTERS.find((f) => f.key === filter)?.label}
186
- <button onClick={() => setFilter("all")}>×</button>
 
 
 
 
 
 
 
 
 
187
  </span>
188
  )}
 
189
  {search && (
190
  <span className="dw-docs__pill">
191
  "{search}"
192
- <button onClick={() => setSearch("")}>×</button>
 
 
 
 
 
193
  </span>
194
  )}
195
  </div>
@@ -201,10 +467,13 @@ export default function Documents() {
201
  <DataTable
202
  columns={columns}
203
  data={filtered}
204
- onRowClick={(row) => row.workflow_id && navigate(`/workflows/${row.workflow_id}`)}
 
 
 
205
  emptyMessage="No documents found."
206
  />
207
  )}
208
  </div>
209
  );
210
- }
 
7
  import "./Documents.css";
8
 
9
  const STATUS_TONE = {
10
+ PENDING: "default",
11
+ RUNNING: "accent",
12
+ WAITING_FOR_REVIEW: "warning",
13
+ COMPLETED: "success",
14
+ FAILED: "danger",
15
+ CANCELLED: "info",
16
  };
17
 
18
+ const ACTIVE_STATUSES = new Set(["PENDING", "RUNNING"]);
19
+ const POLL_INTERVAL = 3000;
20
+
21
  function FileIcon({ filename }) {
22
  const ext = (filename || "").split(".").pop()?.toLowerCase() || "";
23
+ const map = {
24
+ pdf: "pdf",
25
+ docx: "doc",
26
+ doc: "doc",
27
+ txt: "txt",
28
+ csv: "csv",
29
+ xlsx: "xls",
30
+ xls: "xls",
31
+ png: "img",
32
+ jpg: "img",
33
+ jpeg: "img",
34
+ };
35
  const type = map[ext] || "txt";
36
+
37
+ return (
38
+ <span className={`dw-table__file-icon dw-table__file-icon--${type}`}>
39
+ {ext.slice(0, 3)}
40
+ </span>
41
+ );
42
  }
43
 
44
  function ConfidenceInline({ value }) {
45
+ if (value == null) {
46
+ return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
47
+ }
48
+
49
  const pct = Math.round(value * 100);
50
  const cls = pct >= 85 ? "high" : pct >= 60 ? "med" : "low";
51
+
52
  return (
53
  <span className="dw-table__confidence">
54
  <span className="dw-table__confidence-bar">
55
+ <span
56
+ className={`dw-table__confidence-fill dw-table__confidence-fill--${cls}`}
57
+ style={{ width: `${pct}%` }}
58
+ />
59
  </span>
60
  <span>{pct}%</span>
61
  </span>
 
66
  const { workspace, loading: wsLoading, error: wsError } = useWorkspace();
67
  const navigate = useNavigate();
68
  const fileInputRef = useRef(null);
69
+ const pollTimerRef = useRef(null);
70
+
71
  const [uploading, setUploading] = useState(false);
72
  const [documents, setDocuments] = useState([]);
73
  const [docsLoading, setDocsLoading] = useState(true);
 
76
  const [search, setSearch] = useState("");
77
 
78
  const loadDocuments = useCallback(async () => {
79
+ if (!workspace) return [];
80
+
81
  try {
82
  const result = await listDocuments(workspace.id);
83
+
84
+ if (Array.isArray(result)) {
85
+ setDocuments(result);
86
+ return result;
87
+ }
88
+ } catch {
89
+ // Silent refresh failure. The next poll/load can recover.
90
+ }
91
+
92
+ return [];
93
  }, [workspace]);
94
 
95
+ /*
96
+ * Initial document load.
97
+ */
98
+ useEffect(() => {
99
+ if (!workspace) return;
100
 
101
+ let cancelled = false;
102
+
103
+ const initialLoad = async () => {
104
+ setDocsLoading(true);
105
+
106
+ try {
107
+ const result = await listDocuments(workspace.id);
108
+
109
+ if (!cancelled && Array.isArray(result)) {
110
+ setDocuments(result);
111
+ }
112
+ } catch {
113
+ // Silent initial-load failure.
114
+ } finally {
115
+ if (!cancelled) {
116
+ setDocsLoading(false);
117
+ }
118
+ }
119
+ };
120
+
121
+ initialLoad();
122
+
123
+ return () => {
124
+ cancelled = true;
125
+ };
126
+ }, [workspace]);
127
+
128
+ /*
129
+ * Poll while any document is actively processing.
130
+ *
131
+ * This keeps the Documents page synchronized with workflow state:
132
+ *
133
+ * PENDING/RUNNING
134
+ * ↓
135
+ * WAITING_FOR_REVIEW / COMPLETED / FAILED
136
+ *
137
+ * Polling stops automatically once there are no active documents.
138
+ */
139
+ useEffect(() => {
140
+ if (!workspace) return;
141
+
142
+ const hasActiveDocuments = documents.some((doc) =>
143
+ ACTIVE_STATUSES.has(doc.workflow_status)
144
+ );
145
+
146
+ if (!hasActiveDocuments) {
147
+ if (pollTimerRef.current) {
148
+ clearTimeout(pollTimerRef.current);
149
+ pollTimerRef.current = null;
150
+ }
151
+ return;
152
+ }
153
+
154
+ pollTimerRef.current = setTimeout(async () => {
155
+ const latest = await loadDocuments();
156
+
157
+ /*
158
+ * The next render will decide whether another poll is necessary
159
+ * based on the updated document statuses.
160
+ */
161
+ if (!latest.some((doc) => ACTIVE_STATUSES.has(doc.workflow_status))) {
162
+ if (pollTimerRef.current) {
163
+ clearTimeout(pollTimerRef.current);
164
+ pollTimerRef.current = null;
165
+ }
166
+ }
167
+ }, POLL_INTERVAL);
168
+
169
+ return () => {
170
+ if (pollTimerRef.current) {
171
+ clearTimeout(pollTimerRef.current);
172
+ pollTimerRef.current = null;
173
+ }
174
+ };
175
+ }, [workspace, documents, loadDocuments]);
176
+
177
+ const handleUpload = useCallback(
178
+ async (e) => {
179
+ const files = Array.from(e.target.files || []);
180
+
181
+ if (!files.length || !workspace) return;
182
+
183
+ setUploading(true);
184
+ setUploadError(null);
185
+
186
+ try {
187
+ const result = await uploadDocuments(workspace.id, files);
188
+
189
+ if (result?.detail) {
190
+ setUploadError(result.detail);
191
+ return;
192
+ }
193
+
194
+ await loadDocuments();
195
+ } catch {
196
+ setUploadError("Upload failed.");
197
+ } finally {
198
+ setUploading(false);
199
+ e.target.value = "";
200
+ }
201
+ },
202
+ [workspace, loadDocuments]
203
+ );
204
 
205
  const handleDelete = (e, doc) => {
206
  e.stopPropagation();
207
  e.preventDefault();
208
+
209
+ // Optimistically remove from UI immediately.
210
  setDocuments((prev) => prev.filter((d) => d.id !== doc.id));
211
+
212
+ deleteDocument(doc.id).catch(() => loadDocuments());
213
  };
214
 
215
  const handleRetry = (e, doc) => {
216
  e.stopPropagation();
217
+
218
  retryDocument(doc.id).then(loadDocuments);
219
  };
220
 
221
+ // Filter + search.
222
  let filtered = documents;
223
+
224
+ if (filter !== "all") {
225
+ filtered = filtered.filter(
226
+ (d) => d.workflow_status === filter
227
+ );
228
+ }
229
+
230
  if (search.trim()) {
231
  const q = search.toLowerCase();
232
+
233
+ filtered = filtered.filter((d) =>
234
+ d.filename?.toLowerCase().includes(q)
235
+ );
236
  }
237
 
238
  const FILTERS = [
 
245
 
246
  const columns = [
247
  {
248
+ key: "filename",
249
+ label: "Document",
250
+ sortable: true,
251
  render: (val, row) => (
252
  <span className="dw-table__filename-cell">
253
  <FileIcon filename={val} />
 
256
  ),
257
  },
258
  {
259
+ key: "document_type",
260
+ label: "Type",
261
+ sortable: true,
262
+ width: "90px",
263
+ render: (val) => (
264
+ <span
265
+ style={{
266
+ fontSize: "var(--text-xs)",
267
+ color: "var(--color-text-muted)",
268
+ }}
269
+ >
270
+ {val || "—"}
271
+ </span>
272
+ ),
273
  },
274
  {
275
+ key: "uploaded_at",
276
+ label: "Uploaded",
277
+ sortable: true,
278
+ width: "160px",
279
+ render: (val) => (
280
+ <span style={{ fontSize: "var(--text-xs)" }}>
281
+ {val ? new Date(val).toLocaleString() : "—"}
282
+ </span>
283
+ ),
284
  },
285
  {
286
+ key: "workflow_status",
287
+ label: "Status",
288
+ sortable: true,
289
+ width: "120px",
290
+ render: (val) =>
291
+ val ? (
292
+ <Badge tone={STATUS_TONE[val] || "default"}>
293
+ {WORKFLOW_STATUS_LABELS[val] || val}
294
+ </Badge>
295
+ ) : (
296
+ <span
297
+ style={{
298
+ color: "var(--color-text-muted)",
299
+ }}
300
+ >
301
+
302
+ </span>
303
+ ),
304
  },
305
  {
306
+ key: "actions",
307
+ label: "",
308
+ width: "100px",
309
+ align: "right",
310
  render: (_, row) => (
311
  <span className="dw-table__actions">
312
+ <button
313
+ className="dw-table__action-btn dw-table__action-btn--retry"
314
+ title="Retry"
315
+ onClick={(e) => handleRetry(e, row)}
316
+ >
317
+
318
+ </button>
319
+
320
  {row.workflow_id && (
321
+ <button
322
+ className="dw-table__action-btn"
323
+ title="View workflow"
324
+ onClick={(e) => {
325
+ e.stopPropagation();
326
+ navigate(`/workflows/${row.workflow_id}`);
327
+ }}
328
+ >
329
+
330
+ </button>
331
  )}
332
+
333
+ <button
334
+ className="dw-table__action-btn dw-table__action-btn--danger"
335
+ title="Delete"
336
+ onClick={(e) => handleDelete(e, row)}
337
+ >
338
+ ×
339
+ </button>
340
  </span>
341
  ),
342
  },
343
  ];
344
 
345
+ if (wsLoading) {
346
+ return <LoadingState label="Loading..." />;
347
+ }
348
+
349
+ if (wsError) {
350
+ return <EmptyState title="Error" description={wsError} />;
351
+ }
352
 
353
  return (
354
  <div className="dw-page">
 
356
  <div className="dw-docs__header-row">
357
  <div>
358
  <h1>Documents</h1>
359
+ <p>
360
+ Your source files. Upload documents to extract and
361
+ govern knowledge.
362
+ </p>
363
  </div>
364
+
365
  <div>
366
+ <input
367
+ ref={fileInputRef}
368
+ type="file"
369
+ multiple
370
+ onChange={handleUpload}
371
+ style={{ display: "none" }}
372
+ disabled={uploading}
373
+ />
374
+
375
+ <Button
376
+ loading={uploading}
377
+ onClick={() =>
378
+ fileInputRef.current?.click()
379
+ }
380
+ >
381
+ + Upload
382
+ </Button>
383
  </div>
384
  </div>
385
  </header>
386
 
387
+ {uploadError && (
388
+ <div className="dw-docs__error">
389
+ {uploadError}
390
+ </div>
391
+ )}
392
 
393
  {/* Controls row */}
394
  <div className="dw-docs__controls">
 
398
  value={search}
399
  onChange={(e) => setSearch(e.target.value)}
400
  />
401
+
402
  <div className="dw-docs__filters">
403
  {FILTERS.map((f) => (
404
  <button
405
  key={f.key}
406
+ className={`dw-docs__filter ${
407
+ filter === f.key
408
+ ? "dw-docs__filter--active"
409
+ : ""
410
+ }`}
411
  onClick={() => setFilter(f.key)}
412
  >
413
  {f.label}
414
+
415
  {f.key !== "all" && (
416
  <span className="dw-docs__filter-count">
417
+ {
418
+ documents.filter(
419
+ (d) =>
420
+ d.workflow_status ===
421
+ f.key
422
+ ).length
423
+ }
424
  </span>
425
  )}
426
  </button>
 
433
  <div className="dw-docs__pills">
434
  {filter !== "all" && (
435
  <span className="dw-docs__pill">
436
+ {
437
+ FILTERS.find(
438
+ (f) => f.key === filter
439
+ )?.label
440
+ }
441
+
442
+ <button
443
+ onClick={() => setFilter("all")}
444
+ >
445
+ ×
446
+ </button>
447
  </span>
448
  )}
449
+
450
  {search && (
451
  <span className="dw-docs__pill">
452
  "{search}"
453
+
454
+ <button
455
+ onClick={() => setSearch("")}
456
+ >
457
+ ×
458
+ </button>
459
  </span>
460
  )}
461
  </div>
 
467
  <DataTable
468
  columns={columns}
469
  data={filtered}
470
+ onRowClick={(row) =>
471
+ row.workflow_id &&
472
+ navigate(`/workflows/${row.workflow_id}`)
473
+ }
474
  emptyMessage="No documents found."
475
  />
476
  )}
477
  </div>
478
  );
479
+ }
frontend/src/pages/Knowledge.jsx CHANGED
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useState } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { useWorkspace } from "../hooks/useWorkspace";
4
  import { listKnowledge } from "../api/knowledge";
 
5
  import {
6
  Card,
7
  CardBody,
8
  Badge,
 
9
  LoadingState,
10
  EmptyState,
11
  Glossary,
@@ -32,7 +34,7 @@ const STATUS_TONE = {
32
  PENDING: "default",
33
  CONFLICTED: "warning",
34
  SUPERSEDED: "info",
35
- ARCHIVED: "info",
36
  REJECTED: "danger",
37
  };
38
 
@@ -144,6 +146,22 @@ export default function Knowledge() {
144
  }
145
  }, [workspace, typeFilter, statusFilter]);
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  useEffect(() => {
148
  load();
149
  }, [load]);
@@ -212,7 +230,15 @@ export default function Knowledge() {
212
  />
213
  ) : (
214
  <div className="dw-knowledge__list">
215
- {items.map((item) => (
 
 
 
 
 
 
 
 
216
  <Card
217
  key={item.id}
218
  interactive
@@ -234,12 +260,12 @@ export default function Knowledge() {
234
 
235
  <Badge
236
  tone={
237
- STATUS_TONE[item.status] ||
238
  "default"
239
  }
240
  >
241
- {KNOWLEDGE_STATUS_LABELS[item.status] ||
242
- item.status}
243
  </Badge>
244
 
245
  <ConfidenceBar
@@ -278,9 +304,25 @@ export default function Knowledge() {
278
  </span>
279
  )}
280
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  </CardBody>
282
  </Card>
283
- ))}
 
284
  </div>
285
  )}
286
  </div>
 
2
  import { useNavigate } from "react-router-dom";
3
  import { useWorkspace } from "../hooks/useWorkspace";
4
  import { listKnowledge } from "../api/knowledge";
5
+ import { restoreProposal } from "../api/proposals";
6
  import {
7
  Card,
8
  CardBody,
9
  Badge,
10
+ Button,
11
  LoadingState,
12
  EmptyState,
13
  Glossary,
 
34
  PENDING: "default",
35
  CONFLICTED: "warning",
36
  SUPERSEDED: "info",
37
+ ARCHIVED: "warning",
38
  REJECTED: "danger",
39
  };
40
 
 
146
  }
147
  }, [workspace, typeFilter, statusFilter]);
148
 
149
+ const handleRestore = useCallback(async (proposalId, itemId) => {
150
+ // Optimistically remove from list immediately
151
+ setItems((prev) => prev.filter((i) => i.id !== itemId));
152
+
153
+ try {
154
+ await restoreProposal(proposalId);
155
+ } catch {
156
+ // On failure, reload to restore correct state
157
+ const result = await listKnowledge(workspace.id, {
158
+ type: typeFilter || undefined,
159
+ status: statusFilter || undefined,
160
+ });
161
+ if (Array.isArray(result)) setItems(result);
162
+ }
163
+ }, [workspace, typeFilter, statusFilter]);
164
+
165
  useEffect(() => {
166
  load();
167
  }, [load]);
 
230
  />
231
  ) : (
232
  <div className="dw-knowledge__list">
233
+ {items.map((item) => {
234
+ // Derive display status from proposal state
235
+ const displayStatus =
236
+ item.proposal?.status === "ARCHIVED"
237
+ ? "ARCHIVED"
238
+ : item.status;
239
+ const isArchived = item.proposal?.status === "ARCHIVED";
240
+
241
+ return (
242
  <Card
243
  key={item.id}
244
  interactive
 
260
 
261
  <Badge
262
  tone={
263
+ STATUS_TONE[displayStatus] ||
264
  "default"
265
  }
266
  >
267
+ {KNOWLEDGE_STATUS_LABELS[displayStatus] ||
268
+ displayStatus}
269
  </Badge>
270
 
271
  <ConfidenceBar
 
304
  </span>
305
  )}
306
  </div>
307
+
308
+ {isArchived && item.proposal?.id && (
309
+ <div className="dw-knowledge__item-actions">
310
+ <Button
311
+ size="sm"
312
+ variant="warning"
313
+ onClick={(e) => {
314
+ e.stopPropagation();
315
+ handleRestore(item.proposal.id, item.id);
316
+ }}
317
+ >
318
+ Restore to review
319
+ </Button>
320
+ </div>
321
+ )}
322
  </CardBody>
323
  </Card>
324
+ );
325
+ })}
326
  </div>
327
  )}
328
  </div>
frontend/src/pages/KnowledgeDetail.jsx CHANGED
@@ -19,7 +19,7 @@ const STATUS_TONE = {
19
  PENDING: "default",
20
  CONFLICTED: "warning",
21
  SUPERSEDED: "info",
22
- ARCHIVED: "info",
23
  REJECTED: "danger",
24
  };
25
 
 
19
  PENDING: "default",
20
  CONFLICTED: "warning",
21
  SUPERSEDED: "info",
22
+ ARCHIVED: "warning",
23
  REJECTED: "danger",
24
  };
25
 
frontend/src/utils/labels.js CHANGED
@@ -94,6 +94,7 @@ export const GLOSSARY = {
94
  { term: "Review Requested", description: "The system paused and asked for human input." },
95
  { term: "Proposal Approved", description: "A human accepted a proposed knowledge change." },
96
  { term: "Proposal Rejected", description: "A human declined a proposed change." },
 
97
  { term: "Commit Created", description: "An approved change was written to the knowledge register." },
98
  ],
99
  settings: [
 
94
  { term: "Review Requested", description: "The system paused and asked for human input." },
95
  { term: "Proposal Approved", description: "A human accepted a proposed knowledge change." },
96
  { term: "Proposal Rejected", description: "A human declined a proposed change." },
97
+ { term: "Proposal Archived", description: "A proposal was deferred for later review." },
98
  { term: "Commit Created", description: "An approved change was written to the knowledge register." },
99
  ],
100
  settings: [