Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Project settings say who created the project, by name | 1 | //! The webhooks service: events, delivered to the addresses a repository or |
| 2 | //! a workspace registers. See `g1t_contracts::webhooks` for the methods and | |
| 3 | //! their arguments. | |
| 4 | //! | |
| 5 | //! An event from the bus becomes a delivery for each active webhook that | |
| 6 | //! wants it, and is sent at once. One that is not answered with a 2xx is | |
| 7 | //! tried again by the minute's sweep, waiting longer each time, until it | |
| 8 | //! has had every attempt. Every delivery is kept for a fortnight, with what | |
| 9 | //! was sent and what came back. | |
| 10 | ||
| 11 | mod deliver; | |
| 12 | mod rename; | |
| 13 | ||
| 14 | use std::time::Duration; | |
| 15 | ||
| 16 | use futures_util::future::{Either, select}; | |
| 17 | use g1t_contracts::access::{self, Capability}; | |
| 18 | use g1t_contracts::events::Event; | |
| 19 | use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs}; | |
| 20 | use g1t_contracts::repos::{GetArgs, GetByIdArgs, Repo}; | |
| 21 | use g1t_contracts::time::rfc3339; | |
| 22 | use g1t_contracts::webhooks::*; | |
| 23 | use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, Viewer, new_id}; | |
| 24 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 25 | use g1t_secrets::Sealer; | |
| 26 | use serde::Deserialize; | |
| 27 | use serde_json::Value; | |
| 28 | use worker::wasm_bindgen::JsValue; | |
| 29 | use worker::{ | |
| 30 | Context, D1Database, Delay, Env, Fetch, Fetcher, Headers, MessageBatch, MessageExt, Method, Request, RequestInit, Response, | |
| 31 | Result, ScheduleContext, ScheduledEvent, event, | |
| 32 | }; | |
| 33 | ||
| 34 | /// How long a receiver has to answer. | |
| 35 | const TIMEOUT: Duration = Duration::from_secs(10); | |
| 36 | /// How much of an answer is kept. | |
| 37 | const RESPONSE_KEPT: usize = 2_000; | |
| 38 | const DELIVERIES_SHOWN: u32 = 50; | |
| 39 | /// How long deliveries are kept. | |
| 40 | const KEPT_DAYS: u64 = 14; | |
| 41 | /// How many due retries one sweep makes. | |
| 42 | const SWEEP: u32 = 50; | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 43 | /// The hourly cron that forgets old deliveries (wrangler.jsonc); every |
| 44 | /// other minute only retries. | |
| 45 | const PURGE_CRON: &str = "37 * * * *"; | |
| 46 | /// How many old deliveries one statement deletes, and how many statements | |
| 47 | /// one purge makes: a busy hour's worth, in bites D1 answers quickly. | |
| 48 | const PURGE_BATCH: u32 = 1_000; | |
| 49 | const PURGE_BATCHES: u32 = 50; | |
| Project settings say who created the project, by name | 50 | |
| 51 | #[derive(Deserialize)] | |
| 52 | struct HookRow { | |
| 53 | id: String, | |
| 54 | scope: String, | |
| 55 | workspace: String, | |
| 56 | repo: Option<String>, | |
| 57 | url: String, | |
| 58 | events: String, | |
| 59 | active: u32, | |
| 60 | secret: String, | |
| 61 | secret_hint: String, | |
| 62 | created_by: String, | |
| 63 | created_at: String, | |
| 64 | last_status: Option<String>, | |
| 65 | last_delivered_at: Option<String>, | |
| 66 | } | |
| 67 | ||
| 68 | impl HookRow { | |
| 69 | fn events(&self) -> Vec<String> { | |
| 70 | serde_json::from_str(&self.events).unwrap_or_else(|_| vec!["*".to_owned()]) | |
| 71 | } | |
| 72 | ||
| 73 | fn to_hook(&self) -> Hook { | |
| 74 | Hook { | |
| 75 | id: self.id.clone(), | |
| 76 | scope: if self.scope == "repo" { HookScope::Repo } else { HookScope::Workspace }, | |
| 77 | workspace: self.workspace.clone(), | |
| 78 | repo: self.repo.clone(), | |
| 79 | url: self.url.clone(), | |
| 80 | events: self.events(), | |
| 81 | active: self.active != 0, | |
| 82 | secret_hint: self.secret_hint.clone(), | |
| 83 | created_by: self.created_by.clone(), | |
| 84 | created_at: self.created_at.clone(), | |
| 85 | last_status: self.last_status.clone(), | |
| 86 | last_delivered_at: self.last_delivered_at.clone(), | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | #[derive(Deserialize)] | |
| 92 | struct DeliveryRow { | |
| 93 | id: String, | |
| 94 | hook_id: String, | |
| 95 | event_id: String, | |
| 96 | event: String, | |
| 97 | payload: String, | |
| 98 | status: String, | |
| 99 | attempts: u32, | |
| 100 | response_status: Option<u16>, | |
| 101 | response_body: Option<String>, | |
| 102 | error: Option<String>, | |
| 103 | duration_ms: Option<u32>, | |
| 104 | created_at: String, | |
| 105 | delivered_at: Option<String>, | |
| 106 | next_attempt_at: Option<String>, | |
| 107 | } | |
| 108 | ||
| 109 | impl From<DeliveryRow> for HookDelivery { | |
| 110 | fn from(row: DeliveryRow) -> Self { | |
| 111 | HookDelivery { | |
| 112 | id: row.id, | |
| 113 | hook_id: row.hook_id, | |
| 114 | event_id: row.event_id, | |
| 115 | event: row.event, | |
| 116 | status: row.status, | |
| 117 | attempts: row.attempts, | |
| 118 | response_status: row.response_status, | |
| 119 | response_body: row.response_body, | |
| 120 | error: row.error, | |
| 121 | duration_ms: row.duration_ms, | |
| 122 | payload: row.payload, | |
| 123 | created_at: row.created_at, | |
| 124 | delivered_at: row.delivered_at, | |
| 125 | next_attempt_at: row.next_attempt_at, | |
| 126 | } | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | #[derive(Deserialize)] | |
| 131 | struct NameRow { | |
| 132 | namespace: String, | |
| 133 | name: String, | |
| 134 | } | |
| 135 | ||
| 136 | /// What one attempt to send came to. | |
| 137 | struct Attempt { | |
| 138 | status: Option<u16>, | |
| 139 | body: Option<String>, | |
| 140 | error: Option<String>, | |
| 141 | duration_ms: u32, | |
| 142 | } | |
| 143 | ||
| 144 | impl Attempt { | |
| 145 | fn delivered(&self) -> bool { | |
| 146 | self.status.is_some_and(|status| (200..300).contains(&status)) | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | fn optional(value: Option<&str>) -> JsValue { | |
| 151 | value.map_or(JsValue::NULL, JsValue::from) | |
| 152 | } | |
| 153 | ||
| 154 | fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> { | |
| 155 | Outcome::fail(code, message) | |
| 156 | } | |
| 157 | ||
| 158 | /// The workspace itself, as the one asking: it can see its own | |
| 159 | /// repositories, private ones too, and no one else's. | |
| 160 | fn workspace_viewer(slug: &str) -> Viewer { | |
| 161 | Some(User { | |
| 162 | id: String::new(), | |
| 163 | username: slug.to_owned(), | |
| 164 | kind: PrincipalKind::Workspace, | |
| 165 | verified: true, | |
| 166 | workspaces: vec![Membership::member(slug.to_owned())], | |
| 167 | ..User::default() | |
| 168 | }) | |
| 169 | } | |
| 170 | ||
| 171 | struct Webhooks { | |
| 172 | db: D1Database, | |
| 173 | sealer: Option<Sealer>, | |
| 174 | repos: Fetcher, | |
| 175 | identity: Fetcher, | |
| 176 | } | |
| 177 | ||
| 178 | impl Webhooks { | |
| 179 | fn new(env: &Env) -> Result<Self> { | |
| 180 | Ok(Webhooks { | |
| 181 | db: env.d1("DB")?, | |
| 182 | sealer: env.secret("WEBHOOKS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())), | |
| 183 | repos: env.service("REPOS")?, | |
| 184 | identity: env.service("IDENTITY")?, | |
| 185 | }) | |
| 186 | } | |
| 187 | ||
| 188 | // --- Who may do what -------------------------------------------------------- | |
| 189 | ||
| 190 | /// The repository a repository's webhooks are for, if it is the | |
| 191 | /// workspace's and the viewer can see it. | |
| 192 | async fn repository(&self, owner: &HookOwner, viewer: &Viewer) -> Result<Option<Repo>> { | |
| 193 | let Some(path) = &owner.repo else { | |
| 194 | return Ok(None); | |
| 195 | }; | |
| 196 | let found: Outcome<Repo> = g1t_kit::call( | |
| 197 | &self.repos, | |
| 198 | "get", | |
| 199 | &GetArgs { | |
| 200 | path: path.clone(), | |
| 201 | viewer: viewer.clone(), | |
| 202 | }, | |
| 203 | ) | |
| 204 | .await?; | |
| 205 | Ok(found | |
| 206 | .into_result() | |
| 207 | .ok() | |
| 208 | .filter(|repo| repo.namespace == owner.workspace && repo.fork_of.is_none())) | |
| 209 | } | |
| 210 | ||
| 211 | /// Whether `viewer` may see (or, `managing`, change) `owner`'s webhooks, | |
| 212 | /// and the repository when they are a repository's. A repository's | |
| 213 | /// need the Admin role on it, to see as to change, since they carry | |
| 214 | /// its events out; the workspace's are its members' to see and its | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 215 | /// owners' to change. |
| Project settings say who created the project, by name | 216 | async fn allowed(&self, viewer: &Viewer, owner: &HookOwner, managing: bool) -> Result<Outcome<Option<Repo>>> { |
| 217 | let Some(user) = viewer.as_ref() else { | |
| 218 | return Ok(fail(FailureCode::Forbidden, "Sign in to see webhooks.")); | |
| 219 | }; | |
| 220 | if owner.repo.is_some() { | |
| 221 | let Some(repo) = self.repository(owner, viewer).await? else { | |
| 222 | return Ok(fail(FailureCode::NotFound, "There is no such repository in this workspace.")); | |
| 223 | }; | |
| 224 | if !access::can(viewer.as_ref(), &repo, Capability::ManageIntegrations) { | |
| 225 | return Ok(fail( | |
| 226 | FailureCode::Forbidden, | |
| 227 | access::needs(Capability::ManageIntegrations, &format!("{}/{}", repo.namespace, repo.name)), | |
| 228 | )); | |
| 229 | } | |
| 230 | return Ok(Outcome::Ok(Some(repo))); | |
| 231 | } | |
| 232 | if !user.is_member(&owner.workspace) { | |
| 233 | return Ok(fail(FailureCode::Forbidden, format!("Only members of {} can see its webhooks.", owner.workspace))); | |
| 234 | } | |
| 235 | if managing && user.role_in(&owner.workspace) != Some(Role::Owner) { | |
| 236 | return Ok(fail(FailureCode::Forbidden, "Only an owner can manage a workspace's own webhooks.")); | |
| 237 | } | |
| 238 | Ok(Outcome::Ok(None)) | |
| 239 | } | |
| 240 | ||
| 241 | fn owner(mut owner: HookOwner) -> HookOwner { | |
| 242 | owner.workspace = owner.workspace.to_lowercase(); | |
| 243 | if let Some(repo) = &mut owner.repo { | |
| 244 | repo.namespace = repo.namespace.to_lowercase(); | |
| 245 | } | |
| 246 | owner | |
| 247 | } | |
| 248 | ||
| 249 | /// The webhook, if it belongs to `owner`. | |
| 250 | async fn hook_of(&self, owner: &HookOwner, id: &str) -> Result<Option<HookRow>> { | |
| 251 | let row = self | |
| 252 | .db | |
| 253 | .prepare("SELECT * FROM hooks WHERE id = ? AND workspace = ?") | |
| 254 | .bind(&[id.into(), owner.workspace.as_str().into()])? | |
| 255 | .first::<HookRow>(None) | |
| 256 | .await?; | |
| 257 | let repo = owner.repo.as_ref().map(|path| format!("{}/{}", path.namespace, path.name)); | |
| 258 | Ok(row.filter(|row| match &repo { | |
| 259 | Some(repo) => row.scope == "repo" && row.repo.as_deref().is_some_and(|r| r.eq_ignore_ascii_case(repo)), | |
| 260 | None => row.scope == "workspace", | |
| 261 | })) | |
| 262 | } | |
| 263 | ||
| 264 | // --- Managing webhooks ------------------------------------------------------ | |
| 265 | ||
| 266 | async fn list(&self, a: ListArgs) -> Result<Outcome<Vec<Hook>>> { | |
| 267 | let owner = Self::owner(a.owner); | |
| 268 | if let Outcome::Fail(refused) = self.allowed(&a.viewer, &owner, false).await? { | |
| 269 | return Ok(Outcome::Fail(refused)); | |
| 270 | } | |
| 271 | let rows = match &owner.repo { | |
| 272 | Some(path) => self | |
| 273 | .db | |
| 274 | .prepare("SELECT * FROM hooks WHERE scope = 'repo' AND workspace = ? AND lower(repo) = lower(?) ORDER BY id") | |
| 275 | .bind(&[owner.workspace.as_str().into(), format!("{}/{}", path.namespace, path.name).into()])?, | |
| 276 | None => self | |
| 277 | .db | |
| 278 | .prepare("SELECT * FROM hooks WHERE scope = 'workspace' AND workspace = ? ORDER BY id") | |
| 279 | .bind(&[owner.workspace.as_str().into()])?, | |
| 280 | } | |
| 281 | .all() | |
| 282 | .await? | |
| 283 | .results::<HookRow>()?; | |
| 284 | Ok(Outcome::Ok(rows.iter().map(HookRow::to_hook).collect())) | |
| 285 | } | |
| 286 | ||
| 287 | async fn create(&self, a: CreateArgs) -> Result<Outcome<CreatedHook>> { | |
| 288 | let owner = Self::owner(a.owner); | |
| 289 | let repo = match self.allowed(&Some(a.actor.clone()), &owner, true).await? { | |
| 290 | Outcome::Ok(repo) => repo, | |
| 291 | Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)), | |
| 292 | }; | |
| 293 | let Some(sealer) = &self.sealer else { | |
| 294 | return Ok(fail(FailureCode::Conflict, "Webhooks are not set up on this g1t: it has no key to keep secrets with.")); | |
| 295 | }; | |
| 296 | let url = a.url.trim().to_owned(); | |
| 297 | if let Err(problem) = deliver::check_url(&url) { | |
| 298 | return Ok(fail(FailureCode::Invalid, problem)); | |
| 299 | } | |
| 300 | let events = match deliver::tidy_events(&a.events) { | |
| 301 | Ok(events) => events, | |
| 302 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 303 | }; | |
| 304 | let given = a.secret.map(|secret| secret.trim().to_owned()).filter(|secret| !secret.is_empty()); | |
| 305 | let made = given.is_none(); | |
| 306 | let secret = given.unwrap_or_else(|| format!("whsec_{}", g1t_secrets::random_hex(24))); | |
| 307 | let now = now_ms(); | |
| 308 | let id = new_id("hk", now); | |
| 309 | self.db | |
| 310 | .prepare( | |
| 311 | "INSERT INTO hooks (id, scope, workspace, repo_id, repo, url, events, secret, secret_hint, created_by, created_at) | |
| 312 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 313 | ) | |
| 314 | .bind(&[ | |
| 315 | id.as_str().into(), | |
| 316 | if repo.is_some() { "repo" } else { "workspace" }.into(), | |
| 317 | owner.workspace.as_str().into(), | |
| 318 | optional(repo.as_ref().map(|repo| repo.id.as_str())), | |
| 319 | optional(repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)).as_deref()), | |
| 320 | url.as_str().into(), | |
| 321 | serde_json::to_string(&events)?.into(), | |
| 322 | sealer.seal(&secret, &id).into(), | |
| 323 | g1t_secrets::hint(&secret).into(), | |
| 324 | a.actor.username.as_str().into(), | |
| 325 | rfc3339(now).into(), | |
| 326 | ])? | |
| 327 | .run() | |
| 328 | .await?; | |
| 329 | let Some(row) = self.hook_of(&owner, &id).await? else { | |
| 330 | return Ok(fail(FailureCode::NotFound, "The webhook was not saved.")); | |
| 331 | }; | |
| 332 | // Tells the receiver it is wired up, and shows whether it answers. | |
| 333 | self.send_ping(&row).await?; | |
| 334 | let row = self.hook_of(&owner, &id).await?.unwrap_or(row); | |
| 335 | Ok(Outcome::Ok(CreatedHook { | |
| 336 | hook: row.to_hook(), | |
| 337 | secret: made.then_some(secret), | |
| 338 | })) | |
| 339 | } | |
| 340 | ||
| 341 | async fn update(&self, a: UpdateArgs) -> Result<Outcome<Hook>> { | |
| 342 | let owner = Self::owner(a.owner); | |
| 343 | if let Outcome::Fail(refused) = self.allowed(&Some(a.actor.clone()), &owner, true).await? { | |
| 344 | return Ok(Outcome::Fail(refused)); | |
| 345 | } | |
| 346 | let Some(row) = self.hook_of(&owner, &a.id).await? else { | |
| 347 | return Ok(fail(FailureCode::NotFound, "No such webhook.")); | |
| 348 | }; | |
| 349 | let url = a.url.map(|url| url.trim().to_owned()).unwrap_or(row.url.clone()); | |
| 350 | if let Err(problem) = deliver::check_url(&url) { | |
| 351 | return Ok(fail(FailureCode::Invalid, problem)); | |
| 352 | } | |
| 353 | let events = match a.events { | |
| 354 | Some(events) => match deliver::tidy_events(&events) { | |
| 355 | Ok(events) => events, | |
| 356 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 357 | }, | |
| 358 | None => row.events(), | |
| 359 | }; | |
| 360 | let active = a.active.unwrap_or(row.active != 0); | |
| 361 | self.db | |
| 362 | .prepare("UPDATE hooks SET url = ?, events = ?, active = ? WHERE id = ?") | |
| 363 | .bind(&[url.into(), serde_json::to_string(&events)?.into(), (active as u32).into(), row.id.as_str().into()])? | |
| 364 | .run() | |
| 365 | .await?; | |
| 366 | Ok(match self.hook_of(&owner, &row.id).await? { | |
| 367 | Some(row) => Outcome::Ok(row.to_hook()), | |
| 368 | None => fail(FailureCode::NotFound, "No such webhook."), | |
| 369 | }) | |
| 370 | } | |
| 371 | ||
| 372 | async fn delete(&self, a: HookArgs) -> Result<Outcome<bool>> { | |
| 373 | let owner = Self::owner(a.owner); | |
| 374 | if let Outcome::Fail(refused) = self.allowed(&Some(a.actor.clone()), &owner, true).await? { | |
| 375 | return Ok(Outcome::Fail(refused)); | |
| 376 | } | |
| 377 | let Some(row) = self.hook_of(&owner, &a.id).await? else { | |
| 378 | return Ok(fail(FailureCode::NotFound, "No such webhook.")); | |
| 379 | }; | |
| 380 | self.db | |
| 381 | .batch(vec![ | |
| 382 | self.db.prepare("DELETE FROM hooks WHERE id = ?").bind(&[row.id.as_str().into()])?, | |
| 383 | self.db.prepare("DELETE FROM deliveries WHERE hook_id = ?").bind(&[row.id.as_str().into()])?, | |
| 384 | ]) | |
| 385 | .await?; | |
| 386 | Ok(Outcome::Ok(true)) | |
| 387 | } | |
| 388 | ||
| 389 | async fn ping(&self, a: HookArgs) -> Result<Outcome<HookDelivery>> { | |
| 390 | let owner = Self::owner(a.owner); | |
| 391 | if let Outcome::Fail(refused) = self.allowed(&Some(a.actor.clone()), &owner, true).await? { | |
| 392 | return Ok(Outcome::Fail(refused)); | |
| 393 | } | |
| 394 | let Some(row) = self.hook_of(&owner, &a.id).await? else { | |
| 395 | return Ok(fail(FailureCode::NotFound, "No such webhook.")); | |
| 396 | }; | |
| 397 | let id = self.send_ping(&row).await?; | |
| 398 | Ok(self.delivery(&id).await?.map_or_else(|| fail(FailureCode::NotFound, "The ping was not recorded."), Outcome::Ok)) | |
| 399 | } | |
| 400 | ||
| 401 | async fn deliveries(&self, a: DeliveriesArgs) -> Result<Outcome<Vec<HookDelivery>>> { | |
| 402 | let owner = Self::owner(a.owner); | |
| 403 | if let Outcome::Fail(refused) = self.allowed(&a.viewer, &owner, false).await? { | |
| 404 | return Ok(Outcome::Fail(refused)); | |
| 405 | } | |
| 406 | if self.hook_of(&owner, &a.id).await?.is_none() { | |
| 407 | return Ok(fail(FailureCode::NotFound, "No such webhook.")); | |
| 408 | } | |
| 409 | let rows = self | |
| 410 | .db | |
| 411 | .prepare("SELECT * FROM deliveries WHERE hook_id = ? ORDER BY id DESC LIMIT ?") | |
| 412 | .bind(&[a.id.as_str().into(), DELIVERIES_SHOWN.into()])? | |
| 413 | .all() | |
| 414 | .await? | |
| 415 | .results::<DeliveryRow>()?; | |
| 416 | Ok(Outcome::Ok(rows.into_iter().map(HookDelivery::from).collect())) | |
| 417 | } | |
| 418 | ||
| 419 | async fn redeliver(&self, a: RedeliverArgs) -> Result<Outcome<HookDelivery>> { | |
| 420 | let owner = Self::owner(a.owner); | |
| 421 | if let Outcome::Fail(refused) = self.allowed(&Some(a.actor.clone()), &owner, true).await? { | |
| 422 | return Ok(Outcome::Fail(refused)); | |
| 423 | } | |
| 424 | let Some(original) = self.delivery(&a.delivery_id).await? else { | |
| 425 | return Ok(fail(FailureCode::NotFound, "No such delivery.")); | |
| 426 | }; | |
| 427 | let Some(row) = self.hook_of(&owner, &original.hook_id).await? else { | |
| 428 | return Ok(fail(FailureCode::NotFound, "No such delivery.")); | |
| 429 | }; | |
| 430 | // A new delivery of the same payload: its own attempts and log. | |
| 431 | let id = self.enqueue(&row, "", &original.event, &original.payload).await?; | |
| 432 | if let Some(id) = &id { | |
| 433 | self.attempt(&row, id).await?; | |
| 434 | } | |
| 435 | Ok(match id { | |
| 436 | Some(id) => self.delivery(&id).await?.map_or_else(|| fail(FailureCode::NotFound, "No such delivery."), Outcome::Ok), | |
| 437 | None => fail(FailureCode::Conflict, "That delivery could not be made again."), | |
| 438 | }) | |
| 439 | } | |
| 440 | ||
| 441 | async fn delivery(&self, id: &str) -> Result<Option<HookDelivery>> { | |
| 442 | Ok(self | |
| 443 | .db | |
| 444 | .prepare("SELECT * FROM deliveries WHERE id = ?") | |
| 445 | .bind(&[id.into()])? | |
| 446 | .first::<DeliveryRow>(None) | |
| 447 | .await? | |
| 448 | .map(HookDelivery::from)) | |
| 449 | } | |
| 450 | ||
| 451 | // --- Delivering ------------------------------------------------------------- | |
| 452 | ||
| 453 | /// Records a delivery to make. `None` when this event was already | |
| 454 | /// delivered to this webhook. | |
| 455 | async fn enqueue(&self, hook: &HookRow, event_id: &str, event: &str, payload: &str) -> Result<Option<String>> { | |
| 456 | let now = now_ms(); | |
| 457 | let id = new_id("dlv", now); | |
| 458 | let inserted = self | |
| 459 | .db | |
| 460 | .prepare( | |
| 461 | "INSERT OR IGNORE INTO deliveries (id, hook_id, event_id, event, payload, status, created_at, next_attempt_at) | |
| 462 | VALUES (?, ?, ?, ?, ?, 'pending', ?, ?) RETURNING id", | |
| 463 | ) | |
| 464 | .bind(&[ | |
| 465 | id.as_str().into(), | |
| 466 | hook.id.as_str().into(), | |
| 467 | event_id.into(), | |
| 468 | event.into(), | |
| 469 | payload.into(), | |
| 470 | rfc3339(now).into(), | |
| 471 | rfc3339(now).into(), | |
| 472 | ])? | |
| 473 | .first::<Value>(None) | |
| 474 | .await?; | |
| 475 | Ok(inserted.map(|_| id)) | |
| 476 | } | |
| 477 | ||
| 478 | async fn send_ping(&self, hook: &HookRow) -> Result<String> { | |
| 479 | let body = deliver::ping(&hook.id, &hook.url, &hook.events(), &rfc3339(now_ms())).to_string(); | |
| 480 | let id = self.enqueue(hook, "", "ping", &body).await?.unwrap_or_default(); | |
| 481 | self.attempt(hook, &id).await?; | |
| 482 | Ok(id) | |
| 483 | } | |
| 484 | ||
| 485 | /// Sends one delivery once, and records how it went. | |
| 486 | async fn attempt(&self, hook: &HookRow, delivery_id: &str) -> Result<()> { | |
| 487 | let Some(delivery) = self.delivery(delivery_id).await? else { | |
| 488 | return Ok(()); | |
| 489 | }; | |
| 490 | let Some(secret) = self.sealer.as_ref().and_then(|sealer| sealer.open(&hook.secret, &hook.id)) else { | |
| 491 | return Ok(()); | |
| 492 | }; | |
| 493 | let attempt = send(&hook.url, &hook.id, &delivery, &secret).await; | |
| 494 | let attempts = delivery.attempts + 1; | |
| 495 | let now = now_ms(); | |
| 496 | let (status, next) = if attempt.delivered() { | |
| 497 | ("delivered", None) | |
| 498 | } else { | |
| 499 | match deliver::retry_after(attempts) { | |
| 500 | Some(wait) => ("pending", Some(rfc3339(now + wait * 1000))), | |
| 501 | None => ("failed", None), | |
| 502 | } | |
| 503 | }; | |
| 504 | self.db | |
| 505 | .batch(vec![ | |
| 506 | self.db | |
| 507 | .prepare( | |
| 508 | "UPDATE deliveries SET status = ?, attempts = ?, response_status = ?, response_body = ?, error = ?, | |
| 509 | duration_ms = ?, delivered_at = ?, next_attempt_at = ? WHERE id = ?", | |
| 510 | ) | |
| 511 | .bind(&[ | |
| 512 | status.into(), | |
| 513 | attempts.into(), | |
| 514 | attempt.status.map_or(JsValue::NULL, |status| status.into()), | |
| 515 | optional(attempt.body.as_deref()), | |
| 516 | optional(attempt.error.as_deref()), | |
| 517 | attempt.duration_ms.into(), | |
| 518 | optional(attempt.delivered().then(|| rfc3339(now)).as_deref()), | |
| 519 | optional(next.as_deref()), | |
| 520 | delivery_id.into(), | |
| 521 | ])?, | |
| 522 | self.db | |
| 523 | .prepare("UPDATE hooks SET last_status = ?, last_delivered_at = ? WHERE id = ?") | |
| 524 | .bind(&[status.into(), rfc3339(now).into(), hook.id.as_str().into()])?, | |
| 525 | ]) | |
| 526 | .await?; | |
| 527 | Ok(()) | |
| 528 | } | |
| 529 | ||
| 530 | /// Which workspace a repository is in, and its name. | |
| 531 | async fn repo_name(&self, repo_id: &str, workspaces: &[String]) -> Result<Option<NameRow>> { | |
| 532 | if let Some(known) = self | |
| 533 | .db | |
| 534 | .prepare("SELECT namespace, name FROM repo_names WHERE repo_id = ?") | |
| 535 | .bind(&[repo_id.into()])? | |
| 536 | .first::<NameRow>(None) | |
| 537 | .await? | |
| 538 | { | |
| 539 | return Ok(Some(known)); | |
| 540 | } | |
| 541 | // Asked as each workspace with webhooks in turn: each sees only its | |
| 542 | // own private repositories. | |
| 543 | for workspace in workspaces { | |
| 544 | let found: Outcome<Repo> = g1t_kit::call( | |
| 545 | &self.repos, | |
| 546 | "get_by_id", | |
| 547 | &GetByIdArgs { | |
| 548 | id: repo_id.to_owned(), | |
| 549 | viewer: workspace_viewer(workspace), | |
| 550 | }, | |
| 551 | ) | |
| 552 | .await?; | |
| 553 | if let Outcome::Ok(repo) = found | |
| 554 | && repo.fork_of.is_none() | |
| 555 | { | |
| 556 | self.remember(repo_id, &repo.namespace, &repo.name).await?; | |
| 557 | return Ok(Some(NameRow { | |
| 558 | namespace: repo.namespace, | |
| 559 | name: repo.name, | |
| 560 | })); | |
| 561 | } | |
| 562 | } | |
| 563 | Ok(None) | |
| 564 | } | |
| 565 | ||
| 566 | async fn remember(&self, repo_id: &str, namespace: &str, name: &str) -> Result<()> { | |
| 567 | self.db | |
| 568 | .prepare("INSERT OR REPLACE INTO repo_names (repo_id, namespace, name) VALUES (?, ?, ?)") | |
| 569 | .bind(&[repo_id.into(), namespace.into(), name.into()])? | |
| 570 | .run() | |
| 571 | .await?; | |
| 572 | Ok(()) | |
| 573 | } | |
| 574 | ||
| 575 | /// An event from the bus, delivered to every webhook that wants it. | |
| 576 | async fn on_event(&self, event: &Event) -> Result<()> { | |
| 577 | if event.kind == "repo.created" | |
| 578 | && let (Some(id), Some(namespace), Some(name)) = | |
| 579 | (event.data["repoId"].as_str(), event.data["namespace"].as_str(), event.data["name"].as_str()) | |
| 580 | { | |
| 581 | self.remember(id, namespace, name).await?; | |
| 582 | } | |
| 583 | let Some(repo_id) = event.repo_id.as_deref().or_else(|| event.data["repoId"].as_str()) else { | |
| 584 | // About no repository: a workspace's own package, for its | |
| 585 | // workspace's webhooks. | |
| 586 | if let Some(workspace) = deliver::workspace_scoped(event) { | |
| 587 | let hooks = self | |
| 588 | .db | |
| 589 | .prepare("SELECT * FROM hooks WHERE active = 1 AND scope = 'workspace' AND workspace = ?") | |
| 590 | .bind(&[workspace.as_str().into()])? | |
| 591 | .all() | |
| 592 | .await? | |
| 593 | .results::<HookRow>()?; | |
| 594 | self.send_all(event, hooks, None).await?; | |
| 595 | } | |
| 596 | return Ok(()); | |
| 597 | }; | |
| 598 | let mut hooks = self | |
| 599 | .db | |
| 600 | .prepare("SELECT * FROM hooks WHERE active = 1 AND scope = 'repo' AND repo_id = ?") | |
| 601 | .bind(&[repo_id.into()])? | |
| 602 | .all() | |
| 603 | .await? | |
| 604 | .results::<HookRow>()?; | |
| 605 | let workspaces: Vec<String> = self | |
| 606 | .db | |
| 607 | .prepare("SELECT DISTINCT workspace FROM hooks WHERE active = 1 AND scope = 'workspace'") | |
| 608 | .all() | |
| 609 | .await? | |
| 610 | .results::<Value>()? | |
| 611 | .into_iter() | |
| 612 | .filter_map(|row| row["workspace"].as_str().map(str::to_owned)) | |
| 613 | .collect(); | |
| 614 | let name = if hooks.is_empty() && workspaces.is_empty() { | |
| 615 | None | |
| 616 | } else { | |
| 617 | // A deleted repository looks missing to repos; its own events | |
| 618 | // name it. | |
| 619 | self.repo_name(repo_id, &workspaces) | |
| 620 | .await? | |
| 621 | .or_else(|| deliver::named_in(event).map(|(namespace, name)| NameRow { namespace, name })) | |
| 622 | }; | |
| 623 | if let Some(name) = &name { | |
| 624 | hooks.extend( | |
| 625 | self.db | |
| 626 | .prepare("SELECT * FROM hooks WHERE active = 1 AND scope = 'workspace' AND workspace = ?") | |
| 627 | .bind(&[name.namespace.as_str().into()])? | |
| 628 | .all() | |
| 629 | .await? | |
| 630 | .results::<HookRow>()?, | |
| 631 | ); | |
| 632 | } | |
| 633 | self.send_all(event, hooks, Some((repo_id, name.as_ref()))).await | |
| 634 | } | |
| 635 | ||
| 636 | /// Sends `event` to each of `hooks` that wants it. `repo` is the | |
| 637 | /// repository it is about, and its name when known. | |
| 638 | async fn send_all(&self, event: &Event, hooks: Vec<HookRow>, repo: Option<(&str, Option<&NameRow>)>) -> Result<()> { | |
| 639 | let wanted: Vec<&HookRow> = hooks.iter().filter(|hook| deliver::wants(&hook.events(), &event.kind)).collect(); | |
| 640 | if wanted.is_empty() { | |
| 641 | return Ok(()); | |
| 642 | } | |
| 643 | // Who caused it, by name: people and workspaces, or g1t's agent. | |
| 644 | let actor_name = match &event.actor { | |
| 645 | Some(id) => { | |
| 646 | let names: std::collections::HashMap<String, String> = | |
| 647 | g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.clone()] }).await?; | |
| 648 | names.get(id).cloned().or_else(|| (id == AGENT_ID).then(|| AGENT_NAME.to_owned())) | |
| 649 | } | |
| 650 | None => None, | |
| 651 | }; | |
| 652 | for hook in wanted { | |
| 653 | let full_name = repo.map(|(_, name)| { | |
| 654 | hook.repo | |
| 655 | .clone() | |
| 656 | .or_else(|| name.map(|name| format!("{}/{}", name.namespace, name.name))) | |
| 657 | .unwrap_or_default() | |
| 658 | }); | |
| 659 | let named = repo.zip(full_name.as_deref()).map(|((id, _), full)| (id, full)); | |
| 660 | let payload = deliver::payload(event, &hook.workspace, named, actor_name.as_deref()).to_string(); | |
| 661 | if let Some(id) = self.enqueue(hook, &event.id, &event.kind, &payload).await? { | |
| 662 | self.attempt(hook, &id).await?; | |
| 663 | } | |
| 664 | } | |
| 665 | Ok(()) | |
| 666 | } | |
| 667 | ||
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 668 | /// Tries again what is due. |
| Project settings say who created the project, by name | 669 | async fn sweep(&self) -> Result<()> { |
| 670 | let now = now_ms(); | |
| 671 | let due = self | |
| 672 | .db | |
| 673 | .prepare("SELECT * FROM deliveries WHERE status = 'pending' AND next_attempt_at <= ? ORDER BY next_attempt_at LIMIT ?") | |
| 674 | .bind(&[rfc3339(now).into(), SWEEP.into()])? | |
| 675 | .all() | |
| 676 | .await? | |
| 677 | .results::<DeliveryRow>()?; | |
| 678 | for delivery in due { | |
| 679 | let hook = self | |
| 680 | .db | |
| 681 | .prepare("SELECT * FROM hooks WHERE id = ?") | |
| 682 | .bind(&[delivery.hook_id.as_str().into()])? | |
| 683 | .first::<HookRow>(None) | |
| 684 | .await?; | |
| 685 | match hook { | |
| 686 | Some(hook) if hook.active != 0 => self.attempt(&hook, &delivery.id).await?, | |
| 687 | // A webhook turned off or removed takes its retries with it. | |
| 688 | _ => { | |
| 689 | self.db | |
| 690 | .prepare("UPDATE deliveries SET status = 'failed', next_attempt_at = NULL WHERE id = ?") | |
| 691 | .bind(&[delivery.id.as_str().into()])? | |
| 692 | .run() | |
| 693 | .await?; | |
| 694 | } | |
| 695 | } | |
| 696 | } | |
| 697 | Ok(()) | |
| 698 | } | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 699 | |
| 700 | /// Forgets deliveries older than [`KEPT_DAYS`], oldest first, a batch | |
| 701 | /// at a time (`deliveries_by_time`, migration 0002). What one purge | |
| 702 | /// leaves, the next hour's takes. Returns how many it deleted. | |
| 703 | async fn purge(&self) -> Result<u32> { | |
| 704 | let before = rfc3339(now_ms().saturating_sub(KEPT_DAYS * 24 * 60 * 60 * 1000)); | |
| 705 | let mut deleted = 0; | |
| 706 | for _ in 0..PURGE_BATCHES { | |
| 707 | let result = self | |
| 708 | .db | |
| 709 | .prepare(PURGE_SQL) | |
| 710 | .bind(&[before.as_str().into(), PURGE_BATCH.into()])? | |
| 711 | .run() | |
| 712 | .await?; | |
| 713 | let changed = result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) as u32; | |
| 714 | deleted += changed; | |
| 715 | if changed < PURGE_BATCH { | |
| 716 | break; | |
| 717 | } | |
| 718 | } | |
| 719 | Ok(deleted) | |
| 720 | } | |
| Project settings say who created the project, by name | 721 | } |
| 722 | ||
| 723 | /// One HTTPS POST of a delivery's payload, signed, given ten seconds. | |
| 724 | async fn send(url: &str, hook_id: &str, delivery: &HookDelivery, secret: &str) -> Attempt { | |
| 725 | let started = now_ms(); | |
| 726 | let elapsed = || (now_ms() - started) as u32; | |
| 727 | let request = (|| -> Result<Request> { | |
| 728 | let headers = Headers::new(); | |
| 729 | headers.set("content-type", "application/json")?; | |
| 730 | headers.set("user-agent", "g1t-webhooks/1 (+https://docs.g1t.sh/guides/webhooks/)")?; | |
| 731 | headers.set("x-g1t-event", &delivery.event)?; | |
| 732 | headers.set("x-g1t-delivery", &delivery.id)?; | |
| 733 | headers.set("x-g1t-hook", hook_id)?; | |
| 734 | headers.set("x-g1t-signature-256", &deliver::signature(secret, &delivery.payload))?; | |
| 735 | let mut init = RequestInit::new(); | |
| 736 | init.with_method(Method::Post).with_headers(headers).with_body(Some(delivery.payload.clone().into())); | |
| 737 | Request::new_with_init(url, &init) | |
| 738 | })(); | |
| 739 | let request = match request { | |
| 740 | Ok(request) => request, | |
| 741 | Err(error) => { | |
| 742 | return Attempt { | |
| 743 | status: None, | |
| 744 | body: None, | |
| 745 | error: Some(format!("The request could not be made: {error}")), | |
| 746 | duration_ms: 0, | |
| 747 | }; | |
| 748 | } | |
| 749 | }; | |
| 750 | let fetcher = Fetch::Request(request); | |
| 751 | let fetch = Box::pin(fetcher.send()); | |
| 752 | let timeout = Box::pin(Delay::from(TIMEOUT)); | |
| 753 | match select(fetch, timeout).await { | |
| 754 | Either::Left((Ok(mut response), _)) => { | |
| 755 | let status = response.status_code(); | |
| 756 | let body: String = response.text().await.unwrap_or_default().chars().take(RESPONSE_KEPT).collect(); | |
| 757 | Attempt { | |
| 758 | status: Some(status), | |
| 759 | body: Some(body), | |
| 760 | error: None, | |
| 761 | duration_ms: elapsed(), | |
| 762 | } | |
| 763 | } | |
| 764 | Either::Left((Err(error), _)) => Attempt { | |
| 765 | status: None, | |
| 766 | body: None, | |
| 767 | error: Some(format!("The receiver could not be reached: {error}")), | |
| 768 | duration_ms: elapsed(), | |
| 769 | }, | |
| 770 | Either::Right(_) => Attempt { | |
| 771 | status: None, | |
| 772 | body: None, | |
| 773 | error: Some(format!("The receiver did not answer within {} seconds.", TIMEOUT.as_secs())), | |
| 774 | duration_ms: elapsed(), | |
| 775 | }, | |
| 776 | } | |
| 777 | } | |
| 778 | ||
| 779 | #[event(fetch)] | |
| 780 | async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> { | |
| 781 | let Some(method) = rpc_method(&request) else { | |
| 782 | return Response::error("Not found", 404); | |
| 783 | }; | |
| 784 | let body: Value = request.json().await?; | |
| 785 | let service = Webhooks::new(&env)?; | |
| 786 | match method.as_str() { | |
| 787 | "list" => reply(&service.list(args(body)?).await?), | |
| 788 | "create" => reply(&service.create(args(body)?).await?), | |
| 789 | "update" => reply(&service.update(args(body)?).await?), | |
| 790 | "delete" => reply(&service.delete(args(body)?).await?), | |
| 791 | "ping" => reply(&service.ping(args(body)?).await?), | |
| 792 | "deliveries" => reply(&service.deliveries(args(body)?).await?), | |
| 793 | "redeliver" => reply(&service.redeliver(args(body)?).await?), | |
| 794 | _ => Response::error("Unknown method", 404), | |
| 795 | } | |
| 796 | } | |
| 797 | ||
| 798 | /// Events from the bus, on this service's own queue. | |
| 799 | #[event(queue)] | |
| 800 | async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> { | |
| 801 | let service = Webhooks::new(&env)?; | |
| 802 | for message in batch.messages()? { | |
| 803 | // A workspace renamed: its rows move to the slug it has now. | |
| 804 | if g1t_kit::rename::on_event(&env, &env.d1("DB")?, message.body(), rename::STATEMENTS).await? { | |
| 805 | message.ack(); | |
| 806 | continue; | |
| 807 | } | |
| 808 | // A repository renamed or transferred: its rows follow its new path, | |
| 809 | // and then the event is delivered like any other, naming it there. | |
| 810 | if g1t_kit::transfer::on_event(&env, &env.d1("DB")?, message.body(), rename::TRANSFERRED).await? { | |
| 811 | service.on_event(message.body()).await?; | |
| 812 | message.ack(); | |
| 813 | continue; | |
| 814 | } | |
| 815 | // A repository purged: its last event is delivered, then its own | |
| 816 | // webhooks go. | |
| 817 | if message.body().kind == "repo.purged" { | |
| 818 | service.on_event(message.body()).await?; | |
| 819 | g1t_kit::lifecycle::on_purged(&env.d1("DB")?, message.body(), rename::PURGED).await?; | |
| 820 | message.ack(); | |
| 821 | continue; | |
| 822 | } | |
| 823 | // A workspace deleted: what it kept for itself goes. | |
| 824 | if g1t_kit::deleted::on_event(&env.d1("DB")?, message.body(), rename::DELETED).await? { | |
| 825 | message.ack(); | |
| 826 | continue; | |
| 827 | } | |
| 828 | service.on_event(message.body()).await?; | |
| 829 | message.ack(); | |
| 830 | } | |
| 831 | Ok(()) | |
| 832 | } | |
| 833 | ||
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 834 | /// One batch of old deliveries, by time. |
| 835 | const PURGE_SQL: &str = "DELETE FROM deliveries WHERE rowid IN (SELECT rowid FROM deliveries WHERE created_at < ? ORDER BY created_at LIMIT ?)"; | |
| 836 | ||
| 837 | /// Every minute: retries that are due. Once an hour, at [`PURGE_CRON`]: | |
| 838 | /// deliveries old enough to forget. | |
| Project settings say who created the project, by name | 839 | #[event(scheduled)] |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 840 | async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { |
| 841 | let service = match Webhooks::new(&env) { | |
| 842 | Ok(service) => service, | |
| 843 | Err(error) => { | |
| 844 | worker::console_error!("webhooks: could not start: {error}"); | |
| 845 | return; | |
| 846 | } | |
| 847 | }; | |
| 848 | if event.cron() == PURGE_CRON { | |
| 849 | match service.purge().await { | |
| 850 | Ok(0) => {} | |
| 851 | Ok(deleted) => worker::console_log!("webhooks: forgot {deleted} old deliveries"), | |
| 852 | Err(error) => worker::console_error!("webhooks: the purge failed: {error}"), | |
| Project settings say who created the project, by name | 853 | } |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 854 | return; |
| 855 | } | |
| 856 | if let Err(error) = service.sweep().await { | |
| 857 | worker::console_error!("webhooks: the sweep failed: {error}"); | |
| 858 | } | |
| 859 | } | |
| 860 | ||
| 861 | #[cfg(test)] | |
| 862 | mod tests { | |
| 863 | use super::*; | |
| 864 | ||
| 865 | #[test] | |
| 866 | fn the_purge_has_a_cron_of_its_own() { | |
| 867 | let wrangler = include_str!("../wrangler.jsonc"); | |
| 868 | assert!(wrangler.contains(&format!("\"{PURGE_CRON}\"")), "{PURGE_CRON} is not among the crons"); | |
| 869 | assert_ne!(PURGE_CRON, "* * * * *"); | |
| 870 | } | |
| 871 | ||
| 872 | #[test] | |
| 873 | fn the_purge_deletes_in_batches_by_time() { | |
| 874 | assert!(PURGE_SQL.contains("WHERE created_at < ? ORDER BY created_at LIMIT ?")); | |
| 875 | assert!(PURGE_BATCH * PURGE_BATCHES >= 10_000); | |
| Project settings say who created the project, by name | 876 | } |
| 877 | } | |
| 878 |
This file's history is long; its oldest lines are credited to the oldest commit read.