Skip to content

Commit e673154

Browse files
authored
feat(web-ui): task → PRD requirement traceability badges (#468)
Closes #468 Adds requirement_ids linkage between tasks and PROOF9 requirements for full traceability. - Task dataclass: requirement_ids field, persisted as JSON in SQLite with migration guard - API: TaskResponse model updated, update_requirement_ids() function added - Frontend: useRequirementsLookup SWR hook (memoized Map), TaskCard badges, TaskDetailModal section - Tests: 8 new v2 tests including real migration simulation
1 parent 39c1f8b commit e673154

File tree

12 files changed

+320
-11
lines changed

12 files changed

+320
-11
lines changed

codeframe/core/tasks.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class Task:
6060
lineage: list[str] = field(default_factory=list)
6161
is_leaf: bool = True
6262
hierarchical_id: Optional[str] = None
63+
requirement_ids: list[str] = field(default_factory=list)
6364

6465

6566
def create(
@@ -77,6 +78,7 @@ def create(
7778
lineage: Optional[list[str]] = None,
7879
is_leaf: bool = True,
7980
hierarchical_id: Optional[str] = None,
81+
requirement_ids: Optional[list[str]] = None,
8082
) -> Task:
8183
"""Create a new task.
8284
@@ -95,6 +97,7 @@ def create(
9597
lineage: Optional list of ancestor descriptions
9698
is_leaf: Whether this is a leaf/executable task (default True)
9799
hierarchical_id: Optional display ID like "1.2.3"
100+
requirement_ids: Optional list of PROOF9 requirement IDs this task implements
98101
99102
Returns:
100103
Created Task
@@ -103,16 +106,17 @@ def create(
103106
now = _utc_now().isoformat()
104107
depends_on_list = depends_on or []
105108
lineage_list = lineage or []
109+
requirement_ids_list = requirement_ids or []
106110

107111
conn = get_db_connection(workspace)
108112
try:
109113
cursor = conn.cursor()
110114
cursor.execute(
111115
"""
112-
INSERT INTO tasks (id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, parent_id, lineage, is_leaf, hierarchical_id, created_at, updated_at)
113-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
116+
INSERT INTO tasks (id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, parent_id, lineage, is_leaf, hierarchical_id, created_at, updated_at, requirement_ids)
117+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
114118
""",
115-
(task_id, workspace.id, prd_id, title, description, status.value, priority, json.dumps(depends_on_list), estimated_hours, complexity_score, uncertainty_level, parent_id, json.dumps(lineage_list), 1 if is_leaf else 0, hierarchical_id, now, now),
119+
(task_id, workspace.id, prd_id, title, description, status.value, priority, json.dumps(depends_on_list), estimated_hours, complexity_score, uncertainty_level, parent_id, json.dumps(lineage_list), 1 if is_leaf else 0, hierarchical_id, now, now, json.dumps(requirement_ids_list)),
116120
)
117121
conn.commit()
118122
finally:
@@ -134,6 +138,7 @@ def create(
134138
lineage=lineage_list,
135139
is_leaf=is_leaf,
136140
hierarchical_id=hierarchical_id,
141+
requirement_ids=requirement_ids_list,
137142
created_at=datetime.fromisoformat(now),
138143
updated_at=datetime.fromisoformat(now),
139144
)
@@ -154,7 +159,7 @@ def get(workspace: Workspace, task_id: str) -> Optional[Task]:
154159

155160
cursor.execute(
156161
"""
157-
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id
162+
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids
158163
FROM tasks
159164
WHERE workspace_id = ? AND id = ?
160165
""",
@@ -190,7 +195,7 @@ def list_tasks(
190195
if status:
191196
cursor.execute(
192197
"""
193-
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id
198+
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids
194199
FROM tasks
195200
WHERE workspace_id = ? AND status = ?
196201
ORDER BY priority ASC, created_at ASC
@@ -201,7 +206,7 @@ def list_tasks(
201206
else:
202207
cursor.execute(
203208
"""
204-
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id
209+
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids
205210
FROM tasks
206211
WHERE workspace_id = ?
207212
ORDER BY priority ASC, created_at ASC
@@ -415,6 +420,49 @@ def update_depends_on(
415420
return task
416421

417422

423+
def update_requirement_ids(
424+
workspace: Workspace,
425+
task_id: str,
426+
requirement_ids: list[str],
427+
) -> Task:
428+
"""Update a task's linked PROOF9 requirement IDs.
429+
430+
Args:
431+
workspace: Target workspace
432+
task_id: Task to update
433+
requirement_ids: List of PROOF9 requirement IDs this task implements
434+
435+
Returns:
436+
Updated Task
437+
438+
Raises:
439+
ValueError: If task not found
440+
"""
441+
task = get(workspace, task_id)
442+
if not task:
443+
raise ValueError(f"Task not found: {task_id}")
444+
445+
now = _utc_now().isoformat()
446+
447+
conn = get_db_connection(workspace)
448+
cursor = conn.cursor()
449+
cursor.execute(
450+
"""
451+
UPDATE tasks
452+
SET requirement_ids = ?, updated_at = ?
453+
WHERE workspace_id = ? AND id = ?
454+
""",
455+
(json.dumps(requirement_ids), now, workspace.id, task_id),
456+
)
457+
conn.commit()
458+
conn.close()
459+
460+
task.requirement_ids = requirement_ids
461+
task.updated_at = datetime.fromisoformat(now)
462+
463+
return task
464+
465+
418466
def get_dependents(workspace: Workspace, task_id: str) -> list[Task]:
419467
"""Get all tasks that depend on the given task.
420468
@@ -705,7 +753,7 @@ def _row_to_task(row: tuple) -> Task:
705753
Row columns: id, workspace_id, prd_id, title, description, status, priority,
706754
depends_on, estimated_hours, complexity_score, uncertainty_level,
707755
created_at, updated_at, github_issue_number, parent_id, lineage,
708-
is_leaf, hierarchical_id
756+
is_leaf, hierarchical_id, requirement_ids
709757
"""
710758
# Parse depends_on from JSON string (default to empty list if null)
711759
depends_on_raw = row[7]
@@ -719,6 +767,10 @@ def _row_to_task(row: tuple) -> Task:
719767
is_leaf_raw = row[16] if len(row) > 16 else 1
720768
is_leaf = bool(is_leaf_raw) if is_leaf_raw is not None else True
721769

770+
# Parse requirement_ids from JSON string (default to empty list if null)
771+
requirement_ids_raw = row[18] if len(row) > 18 else None
772+
requirement_ids = json.loads(requirement_ids_raw) if requirement_ids_raw else []
773+
722774
return Task(
723775
id=row[0],
724776
workspace_id=row[1],
@@ -738,4 +790,5 @@ def _row_to_task(row: tuple) -> Task:
738790
lineage=lineage,
739791
is_leaf=is_leaf,
740792
hierarchical_id=row[17] if len(row) > 17 else None,
793+
requirement_ids=requirement_ids,
741794
)

codeframe/core/workspace.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ def _init_database(db_path: Path) -> None:
147147
cursor.execute("ALTER TABLE tasks ADD COLUMN is_leaf INTEGER DEFAULT 1")
148148
if "hierarchical_id" not in columns:
149149
cursor.execute("ALTER TABLE tasks ADD COLUMN hierarchical_id TEXT")
150+
if "requirement_ids" not in columns:
151+
cursor.execute("ALTER TABLE tasks ADD COLUMN requirement_ids TEXT DEFAULT '[]'")
150152

151153
# Append-only event log
152154
cursor.execute("""
@@ -489,6 +491,9 @@ def _ensure_schema_upgrades(db_path: Path) -> None:
489491
if "hierarchical_id" not in task_columns:
490492
cursor.execute("ALTER TABLE tasks ADD COLUMN hierarchical_id TEXT")
491493
conn.commit()
494+
if "requirement_ids" not in task_columns:
495+
cursor.execute("ALTER TABLE tasks ADD COLUMN requirement_ids TEXT DEFAULT '[]'")
496+
conn.commit()
492497
if "github_issue_number" not in task_columns:
493498
cursor.execute("ALTER TABLE tasks ADD COLUMN github_issue_number INTEGER")
494499
conn.commit()

codeframe/ui/routers/tasks_v2.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ class TaskResponse(BaseModel):
141141
status: str
142142
priority: int
143143
depends_on: list[str] = []
144+
requirement_ids: list[str] = []
144145
estimated_hours: Optional[float] = None
145146
created_at: Optional[str] = None
146147
updated_at: Optional[str] = None
@@ -216,6 +217,7 @@ async def list_tasks(
216217
status=t.status.value,
217218
priority=t.priority,
218219
depends_on=t.depends_on,
220+
requirement_ids=t.requirement_ids,
219221
estimated_hours=t.estimated_hours,
220222
created_at=t.created_at.isoformat() if t.created_at else None,
221223
updated_at=t.updated_at.isoformat() if t.updated_at else None,
@@ -260,6 +262,7 @@ async def get_task(
260262
status=task.status.value,
261263
priority=task.priority,
262264
depends_on=task.depends_on,
265+
requirement_ids=task.requirement_ids,
263266
estimated_hours=task.estimated_hours,
264267
created_at=task.created_at.isoformat() if task.created_at else None,
265268
updated_at=task.updated_at.isoformat() if task.updated_at else None,
@@ -330,6 +333,7 @@ async def update_task(
330333
status=task.status.value,
331334
priority=task.priority,
332335
depends_on=task.depends_on,
336+
requirement_ids=task.requirement_ids,
333337
estimated_hours=task.estimated_hours,
334338
created_at=task.created_at.isoformat() if task.created_at else None,
335339
updated_at=task.updated_at.isoformat() if task.updated_at else None,
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for task requirement_ids field (issue #468).
2+
3+
Tests that tasks can be linked to PROOF9 requirement IDs for traceability.
4+
"""
5+
6+
import sqlite3
7+
8+
import pytest
9+
10+
from codeframe.core import tasks
11+
from codeframe.core.workspace import create_or_load_workspace
12+
13+
pytestmark = pytest.mark.v2
14+
15+
16+
@pytest.fixture
17+
def workspace(tmp_path):
18+
"""Create a test workspace."""
19+
return create_or_load_workspace(tmp_path)
20+
21+
22+
class TestTaskRequirementIdsField:
23+
"""Test the requirement_ids field on Task model."""
24+
25+
def test_task_has_empty_requirement_ids_by_default(self, workspace):
26+
"""New tasks should have empty requirement_ids list."""
27+
task = tasks.create(workspace, title="Test task")
28+
assert task.requirement_ids == []
29+
30+
def test_task_created_with_requirement_ids(self, workspace):
31+
"""Tasks can be created with requirement_ids."""
32+
req_ids = ["REQ-001", "REQ-002"]
33+
task = tasks.create(workspace, title="Task with reqs", requirement_ids=req_ids)
34+
assert task.requirement_ids == req_ids
35+
36+
def test_task_get_includes_requirement_ids(self, workspace):
37+
"""Getting a task should include its requirement_ids."""
38+
req_ids = ["REQ-042"]
39+
task = tasks.create(workspace, title="Task", requirement_ids=req_ids)
40+
retrieved = tasks.get(workspace, task.id)
41+
assert retrieved.requirement_ids == req_ids
42+
43+
def test_task_list_includes_requirement_ids(self, workspace):
44+
"""Listing tasks should include requirement_ids."""
45+
t1 = tasks.create(workspace, title="No reqs")
46+
t2 = tasks.create(workspace, title="With reqs", requirement_ids=["REQ-007"])
47+
48+
all_tasks = tasks.list_tasks(workspace)
49+
task_map = {t.id: t for t in all_tasks}
50+
51+
assert task_map[t1.id].requirement_ids == []
52+
assert task_map[t2.id].requirement_ids == ["REQ-007"]
53+
54+
def test_task_requirement_ids_persisted_across_get(self, workspace):
55+
"""requirement_ids should survive a round-trip to the database."""
56+
req_ids = ["REQ-001", "REQ-002", "REQ-003"]
57+
task = tasks.create(workspace, title="Multi-req task", requirement_ids=req_ids)
58+
fetched = tasks.get(workspace, task.id)
59+
assert fetched.requirement_ids == req_ids
60+
61+
def test_update_requirement_ids(self, workspace):
62+
"""requirement_ids can be updated on an existing task."""
63+
task = tasks.create(workspace, title="Task")
64+
assert task.requirement_ids == []
65+
66+
updated = tasks.update_requirement_ids(workspace, task.id, ["REQ-099"])
67+
assert updated.requirement_ids == ["REQ-099"]
68+
69+
fetched = tasks.get(workspace, task.id)
70+
assert fetched.requirement_ids == ["REQ-099"]
71+
72+
def test_update_requirement_ids_to_empty(self, workspace):
73+
"""requirement_ids can be cleared."""
74+
task = tasks.create(workspace, title="Task", requirement_ids=["REQ-001"])
75+
updated = tasks.update_requirement_ids(workspace, task.id, [])
76+
assert updated.requirement_ids == []
77+
78+
def test_task_without_requirement_ids_in_existing_db(self, tmp_path):
79+
"""Migration guard adds requirement_ids column to pre-migration databases.
80+
81+
Simulates a workspace that was created before the requirement_ids column
82+
was added, then verifies that opening it triggers the migration guard
83+
and tasks can be read back with requirement_ids == [].
84+
"""
85+
# Step 1: Create a workspace and inject a "pre-migration" tasks table
86+
# by directly dropping the requirement_ids column from the DB.
87+
ws = create_or_load_workspace(tmp_path)
88+
db_path = ws.db_path
89+
90+
conn = sqlite3.connect(db_path)
91+
# Insert a task using the old schema (without requirement_ids)
92+
import uuid
93+
from datetime import datetime, timezone
94+
task_id = str(uuid.uuid4())
95+
now = datetime.now(timezone.utc).isoformat()
96+
conn.execute(
97+
"""
98+
INSERT INTO tasks (id, workspace_id, prd_id, title, description, status,
99+
priority, depends_on, created_at, updated_at)
100+
VALUES (?, ?, NULL, ?, '', 'BACKLOG', 0, '[]', ?, ?)
101+
""",
102+
(task_id, ws.id, "Legacy task", now, now),
103+
)
104+
# Simulate the column not existing by removing it (SQLite workaround)
105+
conn.execute("ALTER TABLE tasks RENAME TO tasks_old")
106+
conn.execute("""
107+
CREATE TABLE tasks (
108+
id TEXT PRIMARY KEY,
109+
workspace_id TEXT NOT NULL,
110+
prd_id TEXT,
111+
title TEXT NOT NULL,
112+
description TEXT,
113+
status TEXT NOT NULL DEFAULT 'BACKLOG',
114+
priority INTEGER DEFAULT 0,
115+
depends_on TEXT DEFAULT '[]',
116+
created_at TEXT NOT NULL,
117+
updated_at TEXT NOT NULL
118+
)
119+
""")
120+
conn.execute("""
121+
INSERT INTO tasks (id, workspace_id, prd_id, title, description, status,
122+
priority, depends_on, created_at, updated_at)
123+
SELECT id, workspace_id, prd_id, title, description, status,
124+
priority, depends_on, created_at, updated_at
125+
FROM tasks_old
126+
""")
127+
conn.execute("DROP TABLE tasks_old")
128+
conn.commit()
129+
conn.close()
130+
131+
# Step 2: Re-open workspace — this triggers _ensure_schema_upgrades()
132+
# which should add the requirement_ids column.
133+
ws2 = create_or_load_workspace(tmp_path)
134+
135+
# Step 3: Verify the legacy task reads back with requirement_ids == []
136+
fetched = tasks.get(ws2, task_id)
137+
assert fetched is not None
138+
assert hasattr(fetched, "requirement_ids")
139+
assert fetched.requirement_ids == []

web-ui/src/components/tasks/TaskBoardContent.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useMemo } from 'react';
44
import { TaskColumn } from './TaskColumn';
5-
import type { Task, TaskStatus } from '@/types';
5+
import type { Task, TaskStatus, ProofRequirement } from '@/types';
66

77
/** Column display order matches the task lifecycle. */
88
const COLUMN_ORDER: TaskStatus[] = [
@@ -27,6 +27,7 @@ interface TaskBoardContentProps {
2727
onSelectAll?: (taskIds: string[]) => void;
2828
onDeselectAll?: (taskIds: string[]) => void;
2929
loadingTaskIds?: Set<string>;
30+
requirementsMap?: Map<string, ProofRequirement>;
3031
}
3132

3233
export function TaskBoardContent({
@@ -42,6 +43,7 @@ export function TaskBoardContent({
4243
onSelectAll,
4344
onDeselectAll,
4445
loadingTaskIds,
46+
requirementsMap,
4547
}: TaskBoardContentProps) {
4648
/** Group flat task array into per-status buckets. */
4749
const tasksByStatus = useMemo(() => {
@@ -75,6 +77,7 @@ export function TaskBoardContent({
7577
onSelectAll={onSelectAll}
7678
onDeselectAll={onDeselectAll}
7779
loadingTaskIds={loadingTaskIds}
80+
requirementsMap={requirementsMap}
7881
/>
7982
))}
8083
</div>

0 commit comments

Comments
 (0)