Skip to content

Commit b6a0d3c

Browse files
andhusclaude
andauthored
Add commit_hash to event metadata for traceability (#101)
## Summary - Store git commit hash in `event_metadata` on all task and build lifecycle events (start, complete, fail, suspend, resume, cancel, etc.) - This is critical for **resumed builds** where the executing code may be at a different commit than the original build's `commit_hash` - Surface commit hash in the UI: Task Detail view shows the commit from the status-determining event, and the Event Log table shows commit per event ## Changes **SDK (`_api_registry.py`)** - New `_get_event_params()` helper that includes `commit_hash` (from `get_git_commit_hash()`) on all event API calls - Applied to all sync and async task/build event methods **API (`routes/builds.py`, `schemas.py`, `services/status.py`)** - All task and build event endpoints accept optional `commit_hash` query param - `_create_task_event` helper stores `commit_hash` in `event_metadata` - `_build_event_metadata` helper for build events (merges with existing `triggered_by_user_id`) - `get_all_task_global_statuses` extracts `commit_hash` from status-determining events - `TaskWithStatusResponse` includes new `commit_hash` field **UI (`TaskDetail.tsx`, `types/task.ts`)** - Task Detail: shows "Commit" field with the commit from the completion/status event - Event Log: new "Commit" column showing per-event commit hash - TypeScript types updated Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 21672f6 commit b6a0d3c

8 files changed

Lines changed: 341 additions & 82 deletions

File tree

app/stardag-api/src/stardag_api/routes/builds.py

Lines changed: 75 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ async def _create_task_event(
132132
db: AsyncSession,
133133
auth: SdkAuth,
134134
error_message: str | None = None,
135+
commit_hash: str | None = None,
136+
extra_metadata: dict | None = None,
135137
) -> TaskEventResponse:
136138
"""Create a task event and return slim response."""
137139
# Limit checks
@@ -144,11 +146,21 @@ async def _create_task_event(
144146

145147
_, db_task = await _get_build_and_task(build_id, task_id, db, auth)
146148

149+
# Build event_metadata from commit_hash and any extra metadata
150+
event_metadata: dict | None = None
151+
if commit_hash or extra_metadata:
152+
event_metadata = {}
153+
if commit_hash:
154+
event_metadata["commit_hash"] = commit_hash
155+
if extra_metadata:
156+
event_metadata.update(extra_metadata)
157+
147158
event = Event(
148159
build_id=build_id,
149160
task_id=db_task.id,
150161
event_type=event_type,
151162
error_message=error_message,
163+
event_metadata=event_metadata,
152164
)
153165
db.add(event)
154166
await db.commit()
@@ -342,17 +354,32 @@ async def get_build(
342354
)
343355

344356

357+
def _build_event_metadata(
358+
commit_hash: str | None = None,
359+
triggered_by_user_id: str | None = None,
360+
) -> dict | None:
361+
"""Build event_metadata dict from optional fields."""
362+
metadata: dict = {}
363+
if commit_hash:
364+
metadata["commit_hash"] = commit_hash
365+
if triggered_by_user_id:
366+
metadata["triggered_by_user_id"] = triggered_by_user_id
367+
return metadata or None
368+
369+
345370
@router.post("/{build_id}/complete", response_model=BuildResponse)
346371
async def complete_build(
347372
build_id: UUID,
348373
db: Annotated[AsyncSession, Depends(get_db)],
349374
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
350375
triggered_by_user_id: str | None = None,
376+
commit_hash: str | None = None,
351377
):
352378
"""Mark a build as completed.
353379
354380
Args:
355381
triggered_by_user_id: Optional user ID if this is a manual override from UI.
382+
commit_hash: Optional git commit hash of the code that ran this build.
356383
"""
357384
# Limit checks
358385
_raise_if_limit_exceeded(check_rate_limit(auth.workspace_id, limits_settings))
@@ -372,16 +399,11 @@ async def complete_build(
372399
status_code=403, detail="Build does not belong to this environment"
373400
)
374401

375-
# Store user ID in metadata if this was user-triggered
376-
event_metadata = (
377-
{"triggered_by_user_id": triggered_by_user_id} if triggered_by_user_id else None
378-
)
379-
380402
event = Event(
381403
build_id=build_id,
382404
task_id=None,
383405
event_type=EventType.BUILD_COMPLETED,
384-
event_metadata=event_metadata,
406+
event_metadata=_build_event_metadata(commit_hash, triggered_by_user_id),
385407
)
386408
db.add(event)
387409
await db.commit()
@@ -416,12 +438,14 @@ async def fail_build(
416438
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
417439
error_message: str | None = None,
418440
triggered_by_user_id: str | None = None,
441+
commit_hash: str | None = None,
419442
):
420443
"""Mark a build as failed.
421444
422445
Args:
423446
error_message: Optional error message.
424447
triggered_by_user_id: Optional user ID if this is a manual override from UI.
448+
commit_hash: Optional git commit hash of the code that ran this build.
425449
"""
426450
# Limit checks
427451
_raise_if_limit_exceeded(check_rate_limit(auth.workspace_id, limits_settings))
@@ -441,17 +465,12 @@ async def fail_build(
441465
status_code=403, detail="Build does not belong to this environment"
442466
)
443467

444-
# Store user ID in metadata if this was user-triggered
445-
event_metadata = (
446-
{"triggered_by_user_id": triggered_by_user_id} if triggered_by_user_id else None
447-
)
448-
449468
event = Event(
450469
build_id=build_id,
451470
task_id=None,
452471
event_type=EventType.BUILD_FAILED,
453472
error_message=error_message,
454-
event_metadata=event_metadata,
473+
event_metadata=_build_event_metadata(commit_hash, triggered_by_user_id),
455474
)
456475
db.add(event)
457476
await db.commit()
@@ -485,11 +504,13 @@ async def cancel_build(
485504
db: Annotated[AsyncSession, Depends(get_db)],
486505
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
487506
triggered_by_user_id: str | None = None,
507+
commit_hash: str | None = None,
488508
):
489509
"""Cancel a build.
490510
491511
Args:
492512
triggered_by_user_id: Optional user ID if this is a manual override from UI.
513+
commit_hash: Optional git commit hash of the code that ran this build.
493514
"""
494515
# Limit checks
495516
_raise_if_limit_exceeded(check_rate_limit(auth.workspace_id, limits_settings))
@@ -509,16 +530,11 @@ async def cancel_build(
509530
status_code=403, detail="Build does not belong to this environment"
510531
)
511532

512-
# Store user ID in metadata if this was user-triggered
513-
event_metadata = (
514-
{"triggered_by_user_id": triggered_by_user_id} if triggered_by_user_id else None
515-
)
516-
517533
event = Event(
518534
build_id=build_id,
519535
task_id=None,
520536
event_type=EventType.BUILD_CANCELLED,
521-
event_metadata=event_metadata,
537+
event_metadata=_build_event_metadata(commit_hash, triggered_by_user_id),
522538
)
523539
db.add(event)
524540
await db.commit()
@@ -552,6 +568,7 @@ async def exit_early(
552568
db: Annotated[AsyncSession, Depends(get_db)],
553569
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
554570
reason: str | None = None,
571+
commit_hash: str | None = None,
555572
):
556573
"""Mark a build as exited early (all remaining tasks running in other builds)."""
557574
# Limit checks
@@ -577,6 +594,7 @@ async def exit_early(
577594
task_id=None,
578595
event_type=EventType.BUILD_EXIT_EARLY,
579596
error_message=reason, # Reuse error_message field for the reason
597+
event_metadata=_build_event_metadata(commit_hash),
580598
)
581599
db.add(event)
582600
await db.commit()
@@ -787,9 +805,12 @@ async def start_task(
787805
task_id: str,
788806
db: Annotated[AsyncSession, Depends(get_db)],
789807
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
808+
commit_hash: str | None = None,
790809
):
791810
"""Mark a task as started within a build."""
792-
return await _create_task_event(build_id, task_id, EventType.TASK_STARTED, db, auth)
811+
return await _create_task_event(
812+
build_id, task_id, EventType.TASK_STARTED, db, auth, commit_hash=commit_hash
813+
)
793814

794815

795816
@router.post("/{build_id}/tasks/{task_id}/complete", response_model=TaskEventResponse)
@@ -798,10 +819,11 @@ async def complete_task(
798819
task_id: str,
799820
db: Annotated[AsyncSession, Depends(get_db)],
800821
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
822+
commit_hash: str | None = None,
801823
):
802824
"""Mark a task as completed within a build."""
803825
return await _create_task_event(
804-
build_id, task_id, EventType.TASK_COMPLETED, db, auth
826+
build_id, task_id, EventType.TASK_COMPLETED, db, auth, commit_hash=commit_hash
805827
)
806828

807829

@@ -812,10 +834,17 @@ async def fail_task(
812834
db: Annotated[AsyncSession, Depends(get_db)],
813835
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
814836
error_message: str | None = None,
837+
commit_hash: str | None = None,
815838
):
816839
"""Mark a task as failed within a build."""
817840
return await _create_task_event(
818-
build_id, task_id, EventType.TASK_FAILED, db, auth, error_message
841+
build_id,
842+
task_id,
843+
EventType.TASK_FAILED,
844+
db,
845+
auth,
846+
error_message,
847+
commit_hash=commit_hash,
819848
)
820849

821850

@@ -825,10 +854,11 @@ async def suspend_task(
825854
task_id: str,
826855
db: Annotated[AsyncSession, Depends(get_db)],
827856
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
857+
commit_hash: str | None = None,
828858
):
829859
"""Mark a task as suspended (waiting for dynamic dependencies)."""
830860
return await _create_task_event(
831-
build_id, task_id, EventType.TASK_SUSPENDED, db, auth
861+
build_id, task_id, EventType.TASK_SUSPENDED, db, auth, commit_hash=commit_hash
832862
)
833863

834864

@@ -838,9 +868,12 @@ async def resume_task(
838868
task_id: str,
839869
db: Annotated[AsyncSession, Depends(get_db)],
840870
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
871+
commit_hash: str | None = None,
841872
):
842873
"""Mark a task as resumed (dynamic dependencies completed)."""
843-
return await _create_task_event(build_id, task_id, EventType.TASK_RESUMED, db, auth)
874+
return await _create_task_event(
875+
build_id, task_id, EventType.TASK_RESUMED, db, auth, commit_hash=commit_hash
876+
)
844877

845878

846879
@router.post("/{build_id}/tasks/{task_id}/cancel", response_model=TaskEventResponse)
@@ -849,10 +882,11 @@ async def cancel_task(
849882
task_id: str,
850883
db: Annotated[AsyncSession, Depends(get_db)],
851884
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
885+
commit_hash: str | None = None,
852886
):
853887
"""Cancel a task within a build."""
854888
return await _create_task_event(
855-
build_id, task_id, EventType.TASK_CANCELLED, db, auth
889+
build_id, task_id, EventType.TASK_CANCELLED, db, auth, commit_hash=commit_hash
856890
)
857891

858892

@@ -865,35 +899,19 @@ async def task_waiting_for_lock(
865899
db: Annotated[AsyncSession, Depends(get_db)],
866900
auth: Annotated[SdkAuth, Depends(require_sdk_auth)],
867901
lock_owner: str | None = None,
902+
commit_hash: str | None = None,
868903
):
869904
"""Record that a task is waiting for a global lock held by another build."""
870-
# Limit checks
871-
_raise_if_limit_exceeded(check_rate_limit(auth.workspace_id, limits_settings))
872-
_raise_if_limit_exceeded(
873-
await check_entity_creation_limit(
874-
db, auth.workspace_id, "events", limits_settings
875-
)
876-
)
877-
878-
_, db_task = await _get_build_and_task(build_id, task_id, db, auth)
879-
880-
# Store lock owner info in event_metadata if provided
881-
event_metadata = {"lock_owner": lock_owner} if lock_owner else None
882-
883-
event = Event(
884-
build_id=build_id,
885-
task_id=db_task.id,
886-
event_type=EventType.TASK_WAITING_FOR_LOCK,
887-
event_metadata=event_metadata,
905+
extra_metadata = {"lock_owner": lock_owner} if lock_owner else None
906+
return await _create_task_event(
907+
build_id,
908+
task_id,
909+
EventType.TASK_WAITING_FOR_LOCK,
910+
db,
911+
auth,
912+
commit_hash=commit_hash,
913+
extra_metadata=extra_metadata,
888914
)
889-
db.add(event)
890-
await db.commit()
891-
892-
record_entity_created(auth.workspace_id, "events")
893-
894-
status, _, _, _ = await get_task_status_in_build(db, build_id, db_task.id)
895-
896-
return TaskEventResponse(task_id=db_task.task_id, status=status)
897915

898916

899917
@router.post(
@@ -1080,7 +1098,10 @@ async def list_tasks_in_build(
10801098
error_message,
10811099
status_build_id,
10821100
waiting_for_lock,
1083-
) = statuses.get(task.id, (TaskStatus.PENDING, None, None, None, None, False))
1101+
commit_hash,
1102+
) = statuses.get(
1103+
task.id, (TaskStatus.PENDING, None, None, None, None, False, None)
1104+
)
10841105
responses.append(
10851106
TaskWithStatusResponse(
10861107
id=task.id,
@@ -1099,6 +1120,7 @@ async def list_tasks_in_build(
10991120
artifact_count=artifact_counts.get(task.id, 0),
11001121
waiting_for_lock=waiting_for_lock,
11011122
status_build_id=status_build_id,
1123+
commit_hash=commit_hash,
11021124
)
11031125
)
11041126

@@ -1223,8 +1245,8 @@ async def get_build_graph(
12231245
if task.is_phantom:
12241246
status = TaskStatus.UNREGISTERED
12251247
else:
1226-
status, _, _, _, _, _ = statuses.get(
1227-
task.id, (TaskStatus.PENDING, None, None, None, None, False)
1248+
status, _, _, _, _, _, _ = statuses.get(
1249+
task.id, (TaskStatus.PENDING, None, None, None, None, False, None)
12281250
)
12291251
nodes.append(
12301252
TaskNode(

app/stardag-api/src/stardag_api/routes/search.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,9 +572,9 @@ def matches_status_filters(task_id: UUID) -> bool:
572572

573573
# Build final status map using global status but latest build for context
574574
for task_id in task_ids:
575-
# Global status: (status, started_at, completed_at, error_message, status_build_id, waiting_for_lock)
575+
# Global status: (status, started_at, completed_at, error_message, status_build_id, waiting_for_lock, commit_hash)
576576
global_status = global_statuses.get(
577-
task_id, (TaskStatus.PENDING, None, None, None, None, False)
577+
task_id, (TaskStatus.PENDING, None, None, None, None, False, None)
578578
)
579579
latest_build = task_to_latest_build.get(task_id)
580580

app/stardag-api/src/stardag_api/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ class TaskWithStatusResponse(TaskResponse):
172172
waiting_for_lock: bool = False
173173
# Build where the status-determining event occurred (for cross-build indicators)
174174
status_build_id: UUID | None = None
175+
# Git commit hash from the event that determined the current status
176+
commit_hash: str | None = None
175177

176178

177179
class TaskEventResponse(BaseModel):

0 commit comments

Comments
 (0)