diff --git a/backend/alembic/versions/b045_blue_work_started_at.py b/backend/alembic/versions/b045_blue_work_started_at.py new file mode 100644 index 0000000..7c8e249 --- /dev/null +++ b/backend/alembic/versions/b045_blue_work_started_at.py @@ -0,0 +1,16 @@ +"""Add blue_work_started_at to tests table.""" +from alembic import op +import sqlalchemy as sa + +revision = "b045" +down_revision = "b044" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("tests", sa.Column("blue_work_started_at", sa.DateTime(), nullable=True)) + + +def downgrade(): + op.drop_column("tests", "blue_work_started_at") diff --git a/backend/app/models/test.py b/backend/app/models/test.py index 2388495..e200504 100644 --- a/backend/app/models/test.py +++ b/backend/app/models/test.py @@ -50,6 +50,7 @@ class Test(Base): # ── Phase timing fields (for automatic Tempo worklogs) ────────── red_started_at = Column(DateTime, nullable=True) blue_started_at = Column(DateTime, nullable=True) + blue_work_started_at = Column(DateTime, nullable=True) # when blue tech picks up (Tempo start) paused_at = Column(DateTime, nullable=True) red_paused_seconds = Column(Integer, default=0) blue_paused_seconds = Column(Integer, default=0) diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 9b4496a..41f67f1 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -11,6 +11,7 @@ PATCH /tests/{id}/red — Red Team updates (draft, red_executing) PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating) POST /tests/{id}/start-execution — draft → red_executing POST /tests/{id}/submit-red — red_executing → blue_evaluating +POST /tests/{id}/start-blue-work — blue tech picks up (sets Tempo timer) POST /tests/{id}/submit-blue — blue_evaluating → in_review POST /tests/{id}/validate-red — Red Lead validates POST /tests/{id}/validate-blue — Blue Lead validates @@ -62,6 +63,7 @@ from app.services.test_workflow_service import ( start_execution as wf_start_execution, submit_red_evidence as wf_submit_red, submit_blue_evidence as wf_submit_blue, + start_blue_work as wf_start_blue_work, validate_as_red_lead as wf_validate_red, validate_as_blue_lead as wf_validate_blue, reopen_test as wf_reopen, @@ -415,6 +417,26 @@ def submit_blue( return test +# --------------------------------------------------------------------------- +# POST /tests/{id}/start-blue-work — blue tech picks up test for evaluation +# --------------------------------------------------------------------------- + + +@router.post("/{test_id}/start-blue-work", response_model=TestOut) +def start_blue_work( + test_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("blue_tech", "blue_lead")), +): + """Blue tech picks up the test to start evaluating. Sets the Tempo timer start.""" + test = crud_get_test_or_raise(db, test_id) + with UnitOfWork(db) as uow: + test = wf_start_blue_work(db, test, current_user) + uow.commit() + db.refresh(test) + return test + + # --------------------------------------------------------------------------- # POST /tests/{id}/pause-timer — pause the active phase timer # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index c9369e8..e1b57f9 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -147,6 +147,7 @@ class TestOut(BaseModel): # Phase timing fields (for Tempo worklogs) red_started_at: datetime | None = None blue_started_at: datetime | None = None + blue_work_started_at: datetime | None = None paused_at: datetime | None = None red_paused_seconds: int = 0 blue_paused_seconds: int = 0 diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index dba1724..e3cdebc 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -11,10 +11,11 @@ all Tempo calls are silently skipped regardless of whether users have tokens. What goes to Tempo ------------------ -Only **red team execution** time is logged to Tempo. This reflects the time -the operator (red_tech) spends executing the attack technique — from the moment -they click "Start" to when they click "Done". Blue team evaluation time is -tracked internally in Aegis but is NOT sent to Tempo. +Both **red team execution** and **blue team evaluation** time are logged to +Tempo. Red team time runs from when the red_tech clicks "Start" to "Done". +Blue team time runs from when the blue_tech clicks "Start Evaluation" (sets +blue_work_started_at) to when they submit, so it reflects actual working time +rather than queue time. """ import logging @@ -28,10 +29,8 @@ from app.models.jira_link import JiraLink, JiraLinkEntityType logger = logging.getLogger(__name__) -# Only these activity types are forwarded to Tempo. -# "blue_team_evaluation" is intentionally excluded — it is tracked in Aegis -# but does not represent operator execution work. -_TEMPO_ACTIVITY_TYPES = {"red_team_execution"} +# Activity types forwarded to Tempo. +_TEMPO_ACTIVITY_TYPES = {"red_team_execution", "blue_team_evaluation"} def has_tempo_configured(user) -> bool: @@ -104,7 +103,7 @@ def auto_log_test_worklog( Returns the Tempo worklog response dict, or ``None`` if skipped. Completely non-fatal — errors are logged and swallowed. """ - # Only operator execution time goes to Tempo + # Only whitelisted activity types go to Tempo if activity_type not in _TEMPO_ACTIVITY_TYPES: logger.debug( "Skipping Tempo sync for activity_type=%s (not in whitelist)", activity_type @@ -152,19 +151,27 @@ def auto_log_test_worklog( return None try: - # Use red_started_at date as the worklog date so it matches when the + # Use the phase start timestamp as the worklog date so it matches when # work actually happened (not the submission timestamp). - work_date = ( - (test.red_started_at or getattr(test, "updated_at", None) or test.created_at) - .strftime("%Y-%m-%d") - ) + if activity_type == "blue_team_evaluation": + work_date = ( + (test.blue_work_started_at or test.blue_started_at or test.created_at) + .strftime("%Y-%m-%d") + ) + description = f"[Aegis] Blue Team evaluation: {test.name}" + else: + work_date = ( + (test.red_started_at or getattr(test, "updated_at", None) or test.created_at) + .strftime("%Y-%m-%d") + ) + description = f"[Aegis] Red Team execution: {test.name}" result = log_worklog( user=user, jira_issue_id=int(link.jira_issue_id), author_account_id=jira_account_id, date=work_date, time_spent_seconds=duration_seconds, - description=f"[Aegis] Red Team execution: {test.name}", + description=description, ) logger.info( "Tempo worklog created for test %s by user %s: %ds on %s", diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index 889a1e4..17ad5f3 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -187,11 +187,46 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test: return test +def start_blue_work(db: Session, test: Test, user: User) -> Test: + """Mark that a blue tech has picked up this test to start evaluating. + + Sets blue_work_started_at. Only valid in blue_evaluating state and + only if blue_work_started_at is not already set. + """ + if test.state != TestState.blue_evaluating: + raise InvalidOperationError( + f"Cannot start blue work in '{test.state.value}' state" + ) + if test.blue_work_started_at is not None: + raise InvalidOperationError("Blue work already started") + + test.blue_work_started_at = datetime.utcnow() + db.flush() + + log_action( + db, + user_id=user.id, + action="start_blue_work", + entity_type="test", + entity_id=test.id, + details={"test_name": test.name}, + ) + + try: + notify_test_state_change(db, test, "blue_work_started") + except Exception as e: + logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True) + + return test + + def submit_blue_evidence(db: Session, test: Test, user: User) -> Test: """Move from ``blue_evaluating`` → ``in_review``. Called by **blue_tech** once they have finished documenting detection. Stops the Blue Team timer and creates an automatic worklog. + Uses blue_work_started_at as the phase start for Tempo if available, + otherwise falls back to blue_started_at (queue-entry timestamp). """ now = datetime.utcnow() @@ -206,12 +241,14 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test: action_name="submit_blue_evidence", ) - # Create automatic worklog for Blue Team phase (subtract paused time) + # Create automatic worklog for Blue Team phase (subtract paused time). + # Use blue_work_started_at (actual pick-up time) when available so the + # Tempo worklog reflects real working time, not just queue time. _create_phase_worklog( db, test=test, user=user, - phase_started_at=test.blue_started_at, + phase_started_at=test.blue_work_started_at or test.blue_started_at, phase_ended_at=now, paused_seconds=(test.blue_paused_seconds or 0) + paused_extra, activity_type="blue_team_evaluation", @@ -612,6 +649,7 @@ def reopen_test(db: Session, test: Test, user: User) -> Test: # Clear phase timing fields test.red_started_at = None test.blue_started_at = None + test.blue_work_started_at = None test.paused_at = None test.red_paused_seconds = 0 test.blue_paused_seconds = 0 diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 0ad1340..39c0710 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -172,6 +172,12 @@ export async function submitBlueEvidence(testId: string): Promise { return data; } +/** Blue tech picks up the test to start evaluating — sets the Tempo timer start. */ +export async function startBlueWork(testId: string): Promise { + const { data } = await client.post(`/tests/${testId}/start-blue-work`); + return data; +} + // ── Lead Validation ──────────────────────────────────────────────── /** Red Lead approves/rejects the red side. */ diff --git a/frontend/src/components/test-detail/TestDetailHeader.tsx b/frontend/src/components/test-detail/TestDetailHeader.tsx index 22332da..42df425 100644 --- a/frontend/src/components/test-detail/TestDetailHeader.tsx +++ b/frontend/src/components/test-detail/TestDetailHeader.tsx @@ -51,6 +51,7 @@ interface TestDetailHeaderProps { onStartExecution: () => void; onSubmitRed: () => void; onSubmitBlue: () => void; + onStartBlueWork: () => void; onOpenValidateModal: (side: "red" | "blue") => void; onReopen: () => void; onPauseTimer: () => void; @@ -67,6 +68,7 @@ export default function TestDetailHeader({ onStartExecution, onSubmitRed, onSubmitBlue, + onStartBlueWork, onOpenValidateModal, onReopen, onPauseTimer, @@ -127,22 +129,38 @@ export default function TestDetailHeader({ ); } - // Blue Team in blue_evaluating -> Submit for Review + // Blue Team in blue_evaluating: + // - if not picked up yet: show "Start Evaluation" button + // - if already picked up: show "Submit for Review" button if ( test.state === "blue_evaluating" && (role === "blue_tech" || role === "blue_lead" || role === "admin") ) { - buttons.push( - , - ); + if (!test.blue_work_started_at) { + buttons.push( + , + ); + } else { + buttons.push( + , + ); + } } // Red Lead in in_review -> Validate Red @@ -264,10 +282,10 @@ export default function TestDetailHeader({ /> ); } - if (test.state === "blue_evaluating" && test.blue_started_at) { + if (test.state === "blue_evaluating" && test.blue_work_started_at) { return ( showToast(extractError(err), "error"), }); + const startBlueWorkMutation = useMutation({ + mutationFn: () => startBlueWork(testId!), + onSuccess: () => { + invalidateAll(); + showToast("Blue evaluation started", "success"); + }, + onError: (err: unknown) => showToast(extractError(err), "error"), + }); + const validateRedLeadMutation = useMutation({ mutationFn: (payload: { red_validation_status: "approved" | "rejected"; red_validation_notes?: string }) => validateAsRedLead(testId!, payload), @@ -302,6 +312,7 @@ export default function TestDetailPage() { startExecMutation.isPending || submitRedMutation.isPending || submitBlueMutation.isPending || + startBlueWorkMutation.isPending || reopenMutation.isPending; // ── Loading / Error states ───────────────────────────────────── @@ -370,6 +381,7 @@ export default function TestDetailPage() { onStartExecution={() => startExecMutation.mutate()} onSubmitRed={() => submitRedMutation.mutate()} onSubmitBlue={() => submitBlueMutation.mutate()} + onStartBlueWork={() => startBlueWorkMutation.mutate()} onOpenValidateModal={(side) => setValidationModal({ open: true, side })} onReopen={() => setConfirmReopen(true)} onPauseTimer={() => pauseTimerMutation.mutate()} diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 9175543..59d4ad1 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -90,6 +90,7 @@ export interface Test { // Phase timing fields (for automatic Tempo worklogs) red_started_at: string | null; blue_started_at: string | null; + blue_work_started_at: string | null; paused_at: string | null; red_paused_seconds: number; blue_paused_seconds: number;