feat: proposal archive/restore governance, knowledge derived status, documents live polling, yellow archive UI
Browse files- backend/alembic/versions/add_archived_proposal_status.py +32 -0
- backend/app/api/activity.py +11 -0
- backend/app/api/knowledge.py +50 -5
- backend/app/api/proposals.py +41 -1
- backend/app/models/proposal.py +1 -0
- backend/app/services/proposal_review_service.py +204 -1
- backend/tests/test_proposal_archive.py +524 -0
- frontend/src/api/proposals.js +16 -0
- frontend/src/components/proposals/ProposalCard.jsx +98 -37
- frontend/src/components/proposals/ProposalReviewList.jsx +10 -4
- frontend/src/components/ui/Button.css +10 -0
- frontend/src/components/ui/Button.jsx +1 -1
- frontend/src/pages/Activity.jsx +2 -0
- frontend/src/pages/Dashboard.jsx +1 -0
- frontend/src/pages/Documents.jsx +320 -51
- frontend/src/pages/Knowledge.jsx +48 -6
- frontend/src/pages/KnowledgeDetail.jsx +1 -1
- frontend/src/utils/labels.js +1 -0
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 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
*
|
| 143 |
-
*
|
| 144 |
-
*
|
| 145 |
-
*
|
|
|
|
|
|
|
| 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 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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",
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
};
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
function FileIcon({ filename }) {
|
| 15 |
const ext = (filename || "").split(".").pop()?.toLowerCase() || "";
|
| 16 |
-
const map = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
const type = map[ext] || "txt";
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
function ConfidenceInline({ value }) {
|
| 22 |
-
if (value == null)
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 49 |
try {
|
| 50 |
const result = await listDocuments(workspace.id);
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}, [workspace]);
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
const handleDelete = (e, doc) => {
|
| 70 |
e.stopPropagation();
|
| 71 |
e.preventDefault();
|
| 72 |
-
|
|
|
|
| 73 |
setDocuments((prev) => prev.filter((d) => d.id !== doc.id));
|
| 74 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
if (search.trim()) {
|
| 86 |
const q = search.toLowerCase();
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
const FILTERS = [
|
|
@@ -97,7 +245,9 @@ export default function Documents() {
|
|
| 97 |
|
| 98 |
const columns = [
|
| 99 |
{
|
| 100 |
-
key: "filename",
|
|
|
|
|
|
|
| 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",
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
},
|
| 112 |
{
|
| 113 |
-
key: "uploaded_at",
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
},
|
| 116 |
{
|
| 117 |
-
key: "workflow_status",
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
},
|
| 120 |
{
|
| 121 |
-
key: "actions",
|
|
|
|
|
|
|
|
|
|
| 122 |
render: (_, row) => (
|
| 123 |
<span className="dw-table__actions">
|
| 124 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
{row.workflow_id && (
|
| 126 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
)}
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
</span>
|
| 130 |
),
|
| 131 |
},
|
| 132 |
];
|
| 133 |
|
| 134 |
-
if (wsLoading)
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
|
|
|
|
|
|
|
|
| 144 |
</div>
|
|
|
|
| 145 |
<div>
|
| 146 |
-
<input
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
</header>
|
| 151 |
|
| 152 |
-
{uploadError &&
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ${
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
onClick={() => setFilter(f.key)}
|
| 168 |
>
|
| 169 |
{f.label}
|
|
|
|
| 170 |
{f.key !== "all" && (
|
| 171 |
<span className="dw-docs__filter-count">
|
| 172 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
</span>
|
| 188 |
)}
|
|
|
|
| 189 |
{search && (
|
| 190 |
<span className="dw-docs__pill">
|
| 191 |
"{search}"
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
</span>
|
| 194 |
)}
|
| 195 |
</div>
|
|
@@ -201,10 +467,13 @@ export default function Documents() {
|
|
| 201 |
<DataTable
|
| 202 |
columns={columns}
|
| 203 |
data={filtered}
|
| 204 |
-
onRowClick={(row) =>
|
|
|
|
|
|
|
|
|
|
| 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: "
|
| 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[
|
| 238 |
"default"
|
| 239 |
}
|
| 240 |
>
|
| 241 |
-
{KNOWLEDGE_STATUS_LABELS[
|
| 242 |
-
|
| 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: "
|
| 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: [
|