feat: add trigger_history table with source tracking (#10696)

* feat: add trigger_history table with source tracking

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate trigger history reads on scopes and harden its writers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: filter trigger history scopes in SQL and match the cleared-handler diff

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: record a trigger restore from the trashbin in its history

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: record bulk http trigger creates and document the recording boundary

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: lock the trigger row when capturing its history preimage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: only record an auto-disable that actually flipped the schedule

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: state the auto-disable invariant once instead of at four call sites

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: render trigger history changes as a structured field diff

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: make a server-initiated disable atomic with its history row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: note that the auto-disable savepoint takes no pool connection

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: note the flow fallback is the last chance to disable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: never leave a trigger enabled because its history row failed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: retry the disable history row instead of dropping it on first failure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: use the design-system Button for the change-value expander

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hold the trigger row lock across its disable history row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the history-loss alert out of the listener cancellation race

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read the history workspace through the trigger-workspace seam

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-14 17:57:11 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 6d03784d4b
commit 633d7bcb2e
41 changed files with 1809 additions and 54 deletions
+98
View File
@@ -16,6 +16,7 @@ use windmill_api_auth::{build_scope_path_predicate, check_scopes, ApiAuthed};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
trigger_history::{self, TriggerHistoryEvent, TriggerOperation, TriggerSource},
user_drafts::{
delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only_list_rows,
overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
@@ -458,6 +459,45 @@ pub trait TriggerCrud: Send + Sync + 'static {
}
}
/// Append this mutation to `trigger_history`, diffing the row at `path` against
/// `before`.
///
/// Call it on the transaction that made the change, after the change: the
/// snapshot it takes is the "after" side of the diff, and the two commit or roll
/// back together.
///
/// Records nothing when the snapshots say no row was written. `TriggerCrud::update_trigger`
/// returns `Result<()>` and several impls do not check `rows_affected`, so an
/// update aimed at a path that does not exist — or that RLS hides from the
/// caller — reaches here having changed nothing; without this the caller could
/// forge history rows at any path, since the insert policy is `WITH CHECK (true)`.
async fn record_trigger_history<T: TriggerCrud>(
tx: &mut PgConnection,
authed: &ApiAuthed,
workspace_id: &str,
path: &str,
operation: TriggerOperation,
before: Option<serde_json::Value>,
) -> Result<()> {
let after = trigger_history::snapshot_row(&mut *tx, T::TABLE_NAME, workspace_id, path).await?;
if after.is_none() || (operation == TriggerOperation::Update && before.is_none()) {
return Ok(());
}
trigger_history::record(
&mut *tx,
TriggerHistoryEvent {
workspace_id,
trigger_kind: T::TRIGGER_TYPE,
path,
operation,
source: TriggerSource::of_request(authed.is_session_token),
username: Some(&authed.username),
changes: trigger_history::summarize_changes(before.as_ref(), after.as_ref()),
},
)
.await
}
pub fn trigger_routes<T: TriggerCrud + 'static>() -> Router {
let mut router = Router::new()
.route("/create", post(create_trigger::<T>))
@@ -556,6 +596,16 @@ async fn create_trigger<T: TriggerCrud>(
.await?;
}
record_trigger_history::<T>(
&mut *tx,
&authed,
&workspace_id,
&new_path,
TriggerOperation::Create,
None,
)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -782,6 +832,9 @@ async fn update_trigger<T: TriggerCrud>(
&authed.username,
);
let before =
trigger_history::snapshot_row(&mut *tx, T::TABLE_NAME, &workspace_id, path).await?;
handler
.update_trigger(&db, &mut *tx, &authed, &workspace_id, path, edit_trigger)
.await?;
@@ -799,6 +852,18 @@ async fn update_trigger<T: TriggerCrud>(
.await?;
}
// Recorded at the new path, so a rename reads as one event there with
// `path` among the changed fields rather than a delete plus a create.
record_trigger_history::<T>(
&mut *tx,
&authed,
&workspace_id,
&new_path,
TriggerOperation::Update,
before,
)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -913,6 +978,22 @@ async fn delete_trigger<T: TriggerCrud>(
.await?;
}
// No diff: the row is gone, and the trashbin above already keeps its full
// contents for a restore.
trigger_history::record(
&mut *tx,
TriggerHistoryEvent {
workspace_id: &workspace_id,
trigger_kind: T::TRIGGER_TYPE,
path,
operation: TriggerOperation::Delete,
source: TriggerSource::of_request(authed.is_session_token),
username: Some(&authed.username),
changes: None,
},
)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -1052,6 +1133,9 @@ async fn set_trigger_mode<T: TriggerCrud>(
}
}
let before =
trigger_history::snapshot_row(&mut *tx, T::TABLE_NAME, &workspace_id, path).await?;
let updated = handler
.set_trigger_mode(&authed, &mut *tx, &workspace_id, path, &payload.mode)
.await?;
@@ -1063,6 +1147,20 @@ async fn set_trigger_mode<T: TriggerCrud>(
)));
}
record_trigger_history::<T>(
&mut *tx,
&authed,
&workspace_id,
path,
match payload.mode {
TriggerMode::Enabled => TriggerOperation::Enable,
TriggerMode::Disabled => TriggerOperation::Disable,
TriggerMode::Suspended => TriggerOperation::Suspend,
},
before,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(