diff --git a/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.test.tsx b/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.test.tsx index 3b813ba9a7d3..0dbf9126a357 100644 --- a/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.test.tsx +++ b/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.test.tsx @@ -13,6 +13,8 @@ const config: SignalScoutConfigApi = { description: 'General scout', scout_origin: 'canonical', enabled: true, + status: 'active', + pause_reason: null, emit: true, run_interval_minutes: 1440, run_cron_schedule: '0 9 * * *', diff --git a/frontend/src/scenes/inbox/logics/scoutCreateModalLogic.test.ts b/frontend/src/scenes/inbox/logics/scoutCreateModalLogic.test.ts index 70aef9c13b52..7537bab63d72 100644 --- a/frontend/src/scenes/inbox/logics/scoutCreateModalLogic.test.ts +++ b/frontend/src/scenes/inbox/logics/scoutCreateModalLogic.test.ts @@ -33,6 +33,8 @@ const CREATED_SCOUT: SignalScoutCreateResponseApi = { description: 'Investigates recurring checkout failures.', scout_origin: 'custom', enabled: false, + status: 'paused_by_user', + pause_reason: null, emit: false, run_interval_minutes: 60, run_cron_schedule: null, diff --git a/frontend/src/scenes/inbox/logics/scoutFleetLogic.test.ts b/frontend/src/scenes/inbox/logics/scoutFleetLogic.test.ts index e43993f5b291..8d35bc5fb29f 100644 --- a/frontend/src/scenes/inbox/logics/scoutFleetLogic.test.ts +++ b/frontend/src/scenes/inbox/logics/scoutFleetLogic.test.ts @@ -38,6 +38,8 @@ const BASE_CONFIG: SignalScoutConfigApi = { description: 'Finds error trends.', scout_origin: 'canonical', enabled: true, + status: 'active', + pause_reason: null, emit: true, run_interval_minutes: 1440, run_cron_schedule: null, diff --git a/posthog/models/activity_logging/activity_log.py b/posthog/models/activity_logging/activity_log.py index f166ab1d4a97..6c3a1f4906f6 100644 --- a/posthog/models/activity_logging/activity_log.py +++ b/posthog/models/activity_logging/activity_log.py @@ -335,6 +335,7 @@ class Meta: "SignalScoutConfig": { "run_interval_minutes": "run interval (minutes)", "emit": "emit findings", + "pause_reason": "pause reason", }, "OAuthApplication": { "_provisioning_config": "provisioning config", @@ -759,6 +760,10 @@ class Meta: # Run bookkeeping, not user intent — keep it out of change detection even when it # rides along with a real change (belt-and-suspenders with signal_exclusions above). "last_run_at", + # Companion bookkeeping that rides along with every logged `status` change; the + # activity log entry itself already carries who and when. + "status_changed_at", + "status_changed_by", # Reverse relations auto-managed by FK creates, not user-initiated config changes. "runs", ], diff --git a/posthog/settings/web.py b/posthog/settings/web.py index 3762a31e1edb..2922af29a266 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -549,6 +549,8 @@ def static_varies_origin(headers, path, url): # path (drf-spectacular generates the x-spec-enum-id from the same tuples). # --- Model class paths (ChoiceField x-spec-enum-id hashes) --- "SignalReportRefundReasonEnum": "products.signals.backend.models.SignalReportRefund.Reason", + "ScoutConfigStatusEnum": "products.signals.backend.models.SignalScoutConfig.Status", + "ScoutConfigPauseReasonEnum": "products.signals.backend.models.SignalScoutConfig.PauseReason", "EngineeringAnalyticsPRStateEnum": "products.engineering_analytics.backend.facade.contracts.PRState", "QuarantineModeEnum": "products.engineering_analytics.backend.facade.contracts.QuarantineMode", "CITestRunnerEnum": "products.engineering_analytics.backend.facade.contracts.CITestRunner", diff --git a/products/signals/ARCHITECTURE.md b/products/signals/ARCHITECTURE.md index 9ce45c8d12ec..163c08196798 100644 --- a/products/signals/ARCHITECTURE.md +++ b/products/signals/ARCHITECTURE.md @@ -542,7 +542,11 @@ Per-scout binding for the headless **Signals agent**: one row per `(team, skill_ | ---------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `team` | FK → Team | Owning team (`related_name="signal_scout_configs"`). `unique_together(team, skill_name)`. | | `skill_name` | CharField | The `signals-scout-*` skill this row controls. Auto-registered by the coordinator when it finds the skill on a participating team. | -| `enabled` | Boolean | Per-scout switch; defaults `True`. `False` pauses just this scout. | +| `enabled` | Boolean | Per-scout switch; defaults `True`. Derived from `status` (`enabled = status in (active, pending_pause)`) but kept as a real column for SQL filtering and the warehouse mirror; `save()` reconciles the pair for writers that set only one side. | +| `status` | Char (choices) | Lifecycle source of truth: `active` / `pending_pause` / `paused_by_system` / `paused_by_user`. Who paused is the state itself: the system may resume its own pauses but must never touch a human's. `pending_pause` still runs (a warning, not a pause) and is cleared by any human config edit. | +| `pause_reason` | Char (nullable) | Why the system paused or warned: `no_output` / `ignored` / `repeated_failures`. Also names the writer that owns the pause: a system writer may only clear or overwrite a pause carrying its own reason (`transition_status_by_system()`). Null outside `pending_pause` / `paused_by_system`. | +| `status_changed_at` | DateTime (nullable) | When `status` last changed. Anchors the cold-start grace window (`in_cold_start_grace()`): any move back to `active` re-anchors it, deliberately independent of the actor so the window survives account deletion. | +| `status_changed_by` | FK → User (nullable) | The human behind the last status change; null on system transitions, so a human re-enable and a system resume are distinguishable and a system pause never reads as a person's action. | | `emit` | Boolean | Dry-run vs emit. Defaults `True`: a freshly authored scout is live from its first tick. Flip to `False` for dry-run — the scout runs and logs but `emit_finding` writes nothing — to validate it on a team before its findings reach the inbox. | | `run_interval_minutes` | PositiveInteger | Minutes between runs. The coordinator dispatches rolling schedules when `last_run_at is None or now - last_run_at >= run_interval_minutes`. Default `1440` (daily). Validated `30 <= N <= 43200`. | | `run_cron_schedule` | Char (nullable) | Optional five-field cron expression anchoring runs to wall-clock slots; takes precedence over `run_interval_minutes` when set. Interpreted in the project's timezone so daylight-saving changes are applied automatically. Occurrences must be ≥ 30 minutes apart. Null keeps the rolling interval behavior. | diff --git a/products/signals/backend/admin.py b/products/signals/backend/admin.py index 85e1a3d19a11..8300a4d69073 100644 --- a/products/signals/backend/admin.py +++ b/products/signals/backend/admin.py @@ -91,6 +91,7 @@ class SignalScoutConfigAdmin(admin.ModelAdmin): "team_link", "skill_name", "enabled", + "status", "emit", "run_interval_minutes", "run_cron_schedule", @@ -98,10 +99,24 @@ class SignalScoutConfigAdmin(admin.ModelAdmin): "updated_at", ) list_display_links = ("id",) - list_filter = ("enabled", "emit") + list_filter = ("enabled", "status", "emit") search_fields = ("id", "skill_name", "team__name", "team__organization__name") raw_id_fields = ("team", "created_by", "enabled_by") - readonly_fields = ("id", "created_at", "updated_at", "last_run_at") + # The status cluster is read-only here: admin's lifecycle control stays the `enabled` + # checkbox (the model's save() derives the status pair from it), and a hand-edited status + # or attribution stamp would bypass the transition rules the API and system writers + # enforce. Read-only also keeps `status_changed_by` off the default user `