Skip to content

Commit 8d32624

Browse files
authored
feat(store): durable webhook deliveries with X-GitHub-Delivery idempotency (#2 phase 1) (#44)
New crates/store crate: embedded SQLite (rusqlite, WAL, user_version migrations) holding webhook_deliveries, tasks, and task_attempts per docs/durable-task-store.md. The webhook route now records every delivery — and its routing outcome — atomically BEFORE GitHub hears success: - X-GitHub-Delivery is required (400 without it); it is the idempotency key - redelivered ids answer 200 {duplicate:true} and never dispatch twice - routed tasks get a durable queued row (with superseded tombstones for older queued reviews of the same PR); ping/unroutable deliveries are recorded as ignored:<reason> - a store failure answers 500 so GitHub retries instead of losing work [storage] config with doctor checks; compose gains a data volume; the demo gains an Act 1b proving redelivery dedup end to end (21 assertions); the smoke script covers the missing-delivery-id 400. Worker dispatch still rides the in-process channel in this phase; durable claims, restart recovery, and retirement of the in-memory task store land in phase 2. Signed-off-by: Val Alexander <bunsthedev@gmail.com>
1 parent 26090d0 commit 8d32624

17 files changed

Lines changed: 922 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 103 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
members = [
33
"crates/config",
44
"crates/github",
5+
"crates/store",
56
"crates/webhook",
67
"crates/worker",
78
"crates/server",
@@ -25,6 +26,7 @@ hmac = "0.12"
2526
jsonwebtoken = "9"
2627
octocrab = "0.41"
2728
reqwest = { version = "0.12", features = ["json"] }
29+
rusqlite = { version = "0.32", features = ["bundled"] }
2830
serde = { version = "1", features = ["derive"] }
2931
serde_json = "1"
3032
sha2 = "0.10"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ duplicate comments.
161161
| `coven-code --headless` execution | Partial | Worker spawns headless sessions with a tokenless session brief and enforces task timeouts; result quality depends on the runtime. |
162162
| Pull request creation | Partial | Opens draft PRs from session results against the repository's resolved default/base branch. |
163163
| CovenCave task polling | Partial | In-memory task API exists for local oversight; hosted control-plane auth and persistence are planned. |
164-
| Durable queue / task store | Planned | Required for hosted reliability and restarts. |
164+
| Durable queue / task store | Partial | Deliveries are persisted and deduplicated by `X-GitHub-Delivery` before GitHub hears success, and every routed task gets a durable record ([design](docs/durable-task-store.md)); worker claims + restart recovery land next. |
165165
| Hosted tier | Planned | See [Hosted vs self-hosted](docs/hosted-vs-self-hosted.md). |
166166
| Familiar trust contract | Planned | See [Familiar Contract](FAMILIAR-CONTRACT.md). |
167167

compose.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ services:
2323
- ./keys:/keys:ro
2424
# Ephemeral per-task workspaces. Keep worker.workspace_root pointed here.
2525
- coven-workspaces:/tmp/coven-github-tasks
26+
# Durable adapter state (webhook idempotency + task records). Point
27+
# storage.path at /data/coven-github.db in local.toml.
28+
- coven-data:/data
2629
environment:
2730
# Adjust log verbosity, e.g. RUST_LOG=coven_github=debug
2831
RUST_LOG: "coven_github=info"
2932
restart: unless-stopped
3033

3134
volumes:
3235
coven-workspaces:
36+
coven-data:

config/example.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ workspace_root = "/tmp/coven-github-tasks" # Ephemeral workspace root
1919
timeout_secs = 600 # 10 minutes per task
2020
max_retries = 2 # Retries for infra errors (exit code 2)
2121

22+
[storage]
23+
# Durable adapter state: webhook deliveries (idempotency) and task records.
24+
# SQLite file — keep it on a persistent volume; parents are created at start.
25+
path = "data/coven-github.db"
26+
2227
# ── Familiar configuration ──────────────────────────────────────────────────
2328
# Add one [[familiars]] block per familiar you want to expose as a GitHub bot.
2429

crates/config/src/lib.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@ pub struct Config {
1313
/// Automatic review trigger policy. Absent section = all lanes off.
1414
#[serde(default)]
1515
pub review: ReviewConfig,
16+
/// Durable adapter state (issue #2). Absent section = default path.
17+
#[serde(default)]
18+
pub storage: StorageConfig,
19+
}
20+
21+
/// Durable store location. See `docs/durable-task-store.md`.
22+
#[derive(Debug, Clone, Deserialize, Serialize)]
23+
pub struct StorageConfig {
24+
/// SQLite database path; parent directories are created at startup.
25+
#[serde(default = "default_storage_path")]
26+
pub path: PathBuf,
27+
}
28+
29+
impl Default for StorageConfig {
30+
fn default() -> Self {
31+
Self {
32+
path: default_storage_path(),
33+
}
34+
}
35+
}
36+
37+
fn default_storage_path() -> PathBuf {
38+
PathBuf::from("data/coven-github.db")
1639
}
1740

1841
/// Automatic review trigger policy (issue #10). Lanes default to off.
@@ -264,6 +287,33 @@ impl Config {
264287
}
265288
}
266289

290+
// ── Storage ─────────────────────────────────────────────────────
291+
if self.storage.path.is_dir() {
292+
out.push(Diagnostic::error(
293+
"storage.path",
294+
format!(
295+
"'{}' is a directory — storage.path must be the SQLite database file itself.",
296+
self.storage.path.display()
297+
),
298+
));
299+
} else if !self.storage.path.exists() {
300+
// Startup creates the file and any missing parents; surface where
301+
// it will land so operators mount/persist the right volume.
302+
let parent = self
303+
.storage
304+
.path
305+
.parent()
306+
.filter(|p| !p.as_os_str().is_empty())
307+
.map(|p| p.display().to_string())
308+
.unwrap_or_else(|| ".".to_string());
309+
out.push(Diagnostic::warning(
310+
"storage.path",
311+
format!(
312+
"database does not exist yet — it will be created under '{parent}' at startup; make sure that path is on a persistent volume."
313+
),
314+
));
315+
}
316+
267317
// ── Review policy ───────────────────────────────────────────────
268318
let known_ids: std::collections::HashSet<&str> =
269319
self.familiars.iter().map(|f| f.id.as_str()).collect();
@@ -370,6 +420,9 @@ fn next_step_for(field: &str, _message: &str) -> &'static str {
370420
"review.familiar" => {
371421
"Set review.familiar (or the repo override's familiar) to the id of a configured [[familiars]] block."
372422
}
423+
"storage.path" => {
424+
"Point storage.path at a writable SQLite file location on a persistent volume."
425+
}
373426
_ => "Update this config field, then rerun coven-github doctor.",
374427
}
375428
}
@@ -468,6 +521,7 @@ mod tests {
468521
worker,
469522
familiars,
470523
review: ReviewConfig::default(),
524+
storage: StorageConfig::default(),
471525
}
472526
}
473527

crates/server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ tracing.workspace = true
1818
tracing-subscriber.workspace = true
1919
coven-github-api = { path = "../github" }
2020
coven-github-config = { path = "../config" }
21+
coven-github-store = { path = "../store" }
2122
coven-github-webhook = { path = "../webhook" }
2223
coven-github-worker = { path = "../worker" }

crates/server/src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ async fn main() -> Result<()> {
8686

8787
let config = Arc::new(config);
8888

89+
// Durable deliveries + tasks (issue #2): open before serving so a
90+
// broken storage path fails the boot, not the first delivery.
91+
let store = coven_github_store::Store::open(&config.storage.path)?;
92+
tracing::info!(
93+
"durable store ready at {}",
94+
config.storage.path.display()
95+
);
96+
8997
let (task_tx, task_rx) = mpsc::channel(256);
9098
let task_store = TaskStore::default();
9199

@@ -101,6 +109,7 @@ async fn main() -> Result<()> {
101109
config: config.clone(),
102110
task_tx,
103111
task_store,
112+
store,
104113
};
105114

106115
let app = Router::new()

crates/store/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "coven-github-store"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
7+
[dependencies]
8+
anyhow.workspace = true
9+
chrono.workspace = true
10+
rusqlite.workspace = true
11+
serde_json.workspace = true
12+
tokio.workspace = true
13+
tracing.workspace = true
14+
coven-github-api = { path = "../github" }
15+
16+
[dev-dependencies]
17+
uuid.workspace = true

0 commit comments

Comments
 (0)