Skip to content

chore: record status transitions and surface per-stage durations#230

Open
dan2k3k4 wants to merge 1 commit into
devfrom
chore/stage-duration-metrics
Open

chore: record status transitions and surface per-stage durations#230
dan2k3k4 wants to merge 1 commit into
devfrom
chore/stage-duration-metrics

Conversation

@dan2k3k4

@dan2k3k4 dan2k3k4 commented Jul 17, 2026

Copy link
Copy Markdown
Member

What

PR 5 of 6 executing the advisory plan set — plan 011, promoted from the audit's parked items by maintainer decision during the plan grilling: "I would like to see how long each stage takes when going from pre-warm (healthy unclaimed) to healthy claimed, or brand new fresh to healthy claim... time in unclaimed state could also be interesting."

Changes

  • Transitions tablepolydock_app_instance_status_transitions: one immutable row per status change (from_status nullable for creation rows, to_status, created_at), FK cascade-delete with the instance, composite index. ~15–20 rows per instance lifetime.
  • Recorder — a new auto-discovered listener on the existing PolydockAppInstanceStatusChanged event (it already carries previousStatus — no model/state-machine changes). Synchronous single INSERT so row ordering keeps duration math honest. Verified alongside the two existing listeners via event:list.
  • Duration helpers on PolydockAppInstance: secondsBetweenStatuses(), secondsUnclaimedBeforeClaim() (pool wait: UNCLAIMED → claim start), secondsFromCreationToClaimed() (fresh → healthy-claimed, anchored on the instance's own created_at). Null-safe for instances that predate recording.
  • Admin surface — the instance view gets a collapsed Stage Timings section: the two headline durations + the full transition list with per-step deltas (humanized). Hidden entirely for pre-feature instances.

Notes

Testing

  • php artisan test — 456 passed (5 new)
  • ./vendor/bin/phpstan analyse — no errors; Pint clean

Greptile Summary

This PR adds an immutable polydock_app_instance_status_transitions table to record every status change, wires an event listener to populate it on both instance-creation and update events, exposes two duration helpers on PolydockAppInstance, and adds a collapsed "Stage Timings" section to the admin infolist that shows per-step deltas.

  • Migration creates a single-table schema with from_status nullable (creation row), a FK cascade-delete, and a composite index on (polydock_app_instance_id, created_at).
  • Listener RecordPolydockAppInstanceStatusTransition handles both PolydockAppInstanceStatusChanged and PolydockAppInstanceCreatedWithNewStatus via a union type hint, writing one INSERT per event synchronously.
  • Duration helpers (secondsBetweenStatuses, secondsUnclaimedBeforeClaim, secondsFromCreationToClaimed) scan the in-memory collection and return null for pre-feature instances.
  • Admin UI shows the two headline durations and a full transition list with humanised deltas, hidden for instances with no recorded transitions.

Confidence Score: 5/5

Safe to merge — the new table, listener, helpers, and admin UI are all self-contained and additive, with cascade-delete protecting referential integrity and null-safe paths for pre-feature instances.

The migration is reversible and index-appropriate. The listener correctly distinguishes creation rows from update rows, and the in-memory collection scans in the duration helpers are bounded to ~15-20 rows per instance. The admin section hides itself for instances that predate recording. No data is mutated and no existing behaviour is altered.

app/Filament/Admin/Resources/PolydockAppInstanceResource.php makes two round-trips to the transitions table per page load (exists + full select); app/Listeners/RecordPolydockAppInstanceStatusTransition.php is absent from EventServiceProvider::$listen and relies on auto-discovery.

Important Files Changed

Filename Overview
app/Listeners/RecordPolydockAppInstanceStatusTransition.php New listener using a union type hint to handle both creation and status-changed events; fires a single synchronous INSERT — logic is correct and null-safe. Not listed in EventServiceProvider::$listen (relies on auto-discovery, inconsistent with other listeners).
app/Models/PolydockAppInstance.php Adds statusTransitions relationship (ordered by created_at, id) and three duration helpers; all correctly scan the in-memory collection and are null-safe for pre-feature instances.
app/Models/PolydockAppInstanceStatusTransition.php New immutable model with UPDATED_AT disabled, correct enum casts, and a BelongsTo back to the instance — straightforward and correct.
database/migrations/2026_07_17_140000_create_polydock_app_instance_status_transitions_table.php Clean migration: FK with cascade-delete, nullable from_status, composite index on (instance_id, created_at), and a correct down() that drops the table.
app/Filament/Admin/Resources/PolydockAppInstanceResource.php Adds a collapsed Stage Timings section with two headline durations and a per-step transition list. The visible() callback fires a redundant EXISTS query that is repeated when the schema callback loads the full collection.
tests/Feature/Models/StatusTransitionTimingTest.php Five focused feature tests covering creation row, status-change recording, pool-wait duration, new-to-claimed duration, null returns for unobserved statuses, and cascade delete — all correct and well-structured.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant M as PolydockAppInstance (Model)
    participant E1 as PolydockAppInstanceCreatedWithNewStatus
    participant E2 as PolydockAppInstanceStatusChanged
    participant L as RecordPolydockAppInstanceStatusTransition
    participant DB as polydock_app_instance_status_transitions
    participant UI as Admin Infolist

    M->>M: "save() [status=NEW]"
    M->>E1: event(PolydockAppInstanceCreatedWithNewStatus)
    E1->>L: handle(event) [auto-discovered]
    L->>DB: "INSERT (from_status=NULL, to_status=NEW)"

    M->>M: setStatus(X).save()
    M->>E2: event(PolydockAppInstanceStatusChanged, previousStatus)
    E2->>L: handle(event) [auto-discovered]
    L->>DB: "INSERT (from_status=prev, to_status=X)"

    UI->>M: "statusTransitions()->exists()"
    DB-->>UI: true
    UI->>M: statusTransitions (collection)
    DB-->>UI: ordered rows
    UI->>M: secondsFromCreationToClaimed()
    M-->>UI: "int|null (from cached collection)"
    UI->>M: secondsUnclaimedBeforeClaim()
    M-->>UI: "int|null (from cached collection)"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant M as PolydockAppInstance (Model)
    participant E1 as PolydockAppInstanceCreatedWithNewStatus
    participant E2 as PolydockAppInstanceStatusChanged
    participant L as RecordPolydockAppInstanceStatusTransition
    participant DB as polydock_app_instance_status_transitions
    participant UI as Admin Infolist

    M->>M: "save() [status=NEW]"
    M->>E1: event(PolydockAppInstanceCreatedWithNewStatus)
    E1->>L: handle(event) [auto-discovered]
    L->>DB: "INSERT (from_status=NULL, to_status=NEW)"

    M->>M: setStatus(X).save()
    M->>E2: event(PolydockAppInstanceStatusChanged, previousStatus)
    E2->>L: handle(event) [auto-discovered]
    L->>DB: "INSERT (from_status=prev, to_status=X)"

    UI->>M: "statusTransitions()->exists()"
    DB-->>UI: true
    UI->>M: statusTransitions (collection)
    DB-->>UI: ordered rows
    UI->>M: secondsFromCreationToClaimed()
    M-->>UI: "int|null (from cached collection)"
    UI->>M: secondsUnclaimedBeforeClaim()
    M-->>UI: "int|null (from cached collection)"
Loading

Reviews (2): Last reviewed commit: "chore: record status transitions and sur..." | Re-trigger Greptile

Implements plan 011 (promoted from the audit's parked DX-04 by maintainer
decision): "how long does each stage take — pre-warm to claimed, fresh to
claimed, and time sitting unclaimed" was previously unanswerable because
the activity log deliberately does not record status and no transition
history existed.

- New polydock_app_instance_status_transitions table: one immutable row
  per status change (from/to/created_at), cascade-deleted with the
  instance, indexed on (instance, created_at).
- New RecordPolydockAppInstanceStatusTransition listener on the existing
  PolydockAppInstanceStatusChanged event (which already carries
  previousStatus). Synchronous by design — a single INSERT whose ordering
  makes duration math trustworthy.
- PolydockAppInstance gains statusTransitions() plus three helpers:
  secondsBetweenStatuses(), secondsUnclaimedBeforeClaim() (pool wait),
  and secondsFromCreationToClaimed(). All return null for instances that
  predate recording.
- The instance view page gains a collapsed "Stage Timings" section:
  headline durations (New → Claimed, pool wait) plus every transition
  with its timestamp and delta; hidden when no transitions exist.
- 5 tests: recording, pool-wait math under time travel, creation→claimed,
  null for unobserved statuses, cascade delete.
@dan2k3k4
dan2k3k4 force-pushed the chore/stage-duration-metrics branch from c7f11ab to 8667216 Compare July 17, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant