Skip to content

Commit 173dceb

Browse files
Phase 18: Webhook Payload + State-Filter + Coalescing (#51)
* docs(18): capture phase context .planning/phases/18-webhook-payload-state-filter-coalescing/18-CONTEXT.md .planning/phases/18-webhook-payload-state-filter-coalescing/18-DISCUSSION-LOG.md * docs(state): record phase 18 context session .planning/STATE.md * docs(18): add validation strategy and research * docs(18): map patterns from existing codebase analogs * docs(18): create phase plan for webhook payload + state-filter + coalescing 6 plans across 4 waves implementing WH-01 (config), WH-03 (Standard Webhooks v1 headers + HMAC-SHA256), WH-06 (edge-triggered streak coalescing), WH-09 (locked 15-field v1 payload schema). Wave 0 lands deps + telemetry; waves 1-2 build the schema/encoder/coalesce/dispatcher; wave 3 wires the bin layer + integration tests + maintainer UAT. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(18): address checker feedback (name-keyed map, D-15 sentinel, UAT recipe-wraps-curl, 16-field, todo!, Connection: close, validation map) * docs(18): mark process Ds informational; cite D-12/13/14/19 in coalescing/dispatcher plans * docs(state): record Phase 18 planning completion (6 plans, ready to execute) * chore(18-01): add Phase 18 webhook deps (reqwest 0.13 rustls + hmac + base64 + ulid + wiremock-dev) - reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } (Pitfall A: 0.13 renamed `rustls-tls` to `rustls`; CONTEXT D-20 annotation embedded) - hmac = "0.13", base64 = "0.22", ulid = "1.2" as runtime deps - wiremock = "0.6" as dev-dependency for HttpDispatcher integration tests - cargo build clean; `cargo tree -i openssl-sys` returns empty (rustls-only invariant holds) * feat(18-03): add WebhookPayload struct with deterministic JSON encoding - src/webhooks/payload.rs: 16-field WebhookPayload<'a> struct (D-06) + build() constructor; field declaration order == JSON serialization order; chrono::SecondsFormat::Secs + use_z=true for RFC3339 Z suffix (Pitfall F); tags: Vec<String> = vec![] until Phase 22 (D-07); cronduit_version passed in by caller (D-07) - src/webhooks/coalesce.rs: stub module (Task 2 fills in) - src/webhooks/mod.rs: pub mod coalesce + pub mod payload + pub use WebhookPayload re-export - 9 unit tests cover: payload_version="v1" (D-08), event_type= "run_finalized" (D-06), all 16 field keys present, deterministic byte output on repeat (Pitfall B), no newlines in compact JSON (Pitfall C), declaration order, image_digest/config_hash null when None, tags=[] empty array, RFC3339 Z suffix, cronduit_version from env! macro Mitigates T-18-10 (byte determinism), T-18-11 (timestamp drift), T-18-12 (compact JSON whitespace). * feat(18-01): describe + zero-baseline sent/failed webhook counters; add just test-unit recipe - src/telemetry.rs: describe_counter! + zero-baseline for cronduit_webhook_delivery_sent_total and cronduit_webhook_delivery_failed_total inside setup_metrics OnceLock init (Pitfall 3 — families render HELP/TYPE from boot, not first observation) - tests/metrics_endpoint.rs: extend metrics_families_described_from_boot to assert HELP + TYPE for both new counters; existing dropped_total assertions preserved - justfile: add `just test-unit` recipe (cargo test --lib --all-features) under [group('test')] for the per-task fast feedback loop referenced by 18-VALIDATION.md * docs(18-01): complete webhook foundation deps + telemetry scaffolding plan - 5 new crates landed (reqwest 0.13 rustls, hmac 0.13, base64 0.22, ulid 1.2, wiremock 0.6 dev) - 2 new counter families described + zero-baselined; regression-locked in metrics_endpoint test - just test-unit recipe for fast per-task feedback - cargo tree -i openssl-sys empty (rustls-only invariant holds) Requirements completed: WH-01, WH-03, WH-06, WH-09 * feat(18-03): add filter_position helper with D-15 success sentinel - src/webhooks/coalesce.rs: filter_position(pool, job_id, current_start, states) returns the count of consecutive matching runs counting back from the current run, stopping at the first non-match OR the first success (D-12, D-15). Mirrors get_failure_context's dual-SQL CTE shape (PoolRef dispatch, '1970-01-01T00:00:00Z' epoch sentinel). - D-15 success sentinel hard-coded in CASE expression on BOTH backends: WHEN status='success' THEN 0 BEFORE the IN-list check, so success is ALWAYS a streak break even if the operator misconfigures states. - pad_states_to_6 helper (Pitfall I): repeats the last entry to fill 6 bind placeholders; duplicates collapse harmlessly inside SQL IN(...). - 4 unit tests (in-process SQLite memory pool): * filter_position_basic_streak: 3 failures -> 3 * filter_position_stops_at_success: success+2failures -> 2 * filter_position_d13_scenario: failed->timeout, states=[timeout] -> 1 * filter_position_treats_success_as_break_even_when_in_states (D-15 regression): without the SQL sentinel this returns 3; with it, 2 - tests/v12_webhook_filter_position_explain.rs: EXPLAIN regression test asserting idx_job_runs_job_id_start hit on SQLite (>120 rows + ANALYZE) and Postgres (#[ignore]-gated testcontainer, 10000 rows + ANALYZE), mirroring tests/v12_fctx_explain.rs precedent. Also: corrected the first_break.break_time aggregate from MIN -> MAX (Rule 1 bug fix on plan-template SQL); MAX correctly identifies the most-recent non-match before the current run, ensuring count of matches strictly newer than the most recent break. Mitigates T-18-13 (SQL injection — bind parameters), T-18-14 (DoS via slow query — index-backed), T-18-15 (placeholder mismatch — pre-pad), T-18-37 (operator success-in-states misconfig — sentinel hard-coded). * docs(18-03): complete webhook payload + filter-position plan Summary of WH-06 + WH-09 deliverables: 16-field WebhookPayload struct with byte-stable serde_json output (Pitfalls B/C/F mitigations); coalesce::filter_position helper with D-15 success sentinel hard-coded in CASE expression on both SQLite and Postgres backends; EXPLAIN regression test asserting idx_job_runs_job_id_start hit. 13 unit tests + 1 SQLite EXPLAIN test pass; 1 Postgres EXPLAIN test #[ignore]-gated. One Rule 1 deviation: corrected first_break aggregate from MIN to MAX on the plan-template SQL. * feat(18-02): add WebhookConfig struct + JobConfig.webhook + DefaultsConfig.webhook WH-01 / D-02: introduce per-job and [defaults] webhook configuration. - WebhookConfig with 5 fields: url, states (default ["failed","timeout"]), secret (Option<SecretString>, scrubbed), unsigned (bool), fire_every (i64, default 1) - JobConfig.webhook + DefaultsConfig.webhook fields (Option<WebhookConfig>) - default_webhook_states() and default_fire_every() helper fns - 4 unit tests cover per-job parse, [defaults] parse, default-state vec, default-fire_every webhook is intentionally NOT added to DockerJobConfig / serialize_config_json / compute_config_hash — the 5-layer parity invariant from Phase 17 LBL applies to docker-execution surface only. The dispatcher (Plan 04) reads webhook config from a bin-layer Arc<HashMap<i64, WebhookConfig>> built at startup (Plan 05). Existing JobConfig/DefaultsConfig literal fixtures across the codebase get mechanical webhook: None additions to keep them compiling. * feat(18-02): extend apply_defaults with webhook merge (replace-on-collision) WH-01 / D-01: per-job webhook always wins; defaults.webhook fills only when job.webhook is None and use_defaults != Some(false). Mirrors the image/network/volumes/timeout/delete per-field replace pattern, NOT the labels HashMap union — webhook is a single inline block, so collision semantics are replace, not field-merge. NO type-gate (unlike LBL-04): webhooks fire on RunFinalized for every job type (command/script/docker). The dispatcher reads webhook config from a bin-layer HashMap; it never enters DockerJobConfig. 4 new tests cover: fill from defaults, use_defaults=false discards, per-job overrides entirely, command-jobs receive webhook (no type-gate). * feat(18-02): add check_webhook_url + check_webhook_block_completeness validators WH-01 / D-04: LOAD-time rejection of every malformed webhook config combination. - check_webhook_url: rejects unparsable URLs and non-http/https schemes - check_webhook_block_completeness: 5 independent assertions in one fn 1. states non-empty (rejects `states = []`) 2. every state ∈ VALID_WEBHOOK_STATES (sorted offending list — Pitfall G) 3. secret xor unsigned (both: ambiguous; neither: signing intent unclear) 4. fire_every >= 0 (rejects negative coalescing knob) 5. Pitfall H: secret resolved to empty string (silent HMAC-with-empty-key) Both wired into run_all_checks per-job loop. VALID_WEBHOOK_STATES const enumerates the canonical RunFinalized status values from src/scheduler/run.rs. Error messages name the offending field, the offending value, and the remediation — Phase 17 LBL precedent. T-18-07 mitigated: no expose_secret() output in any error message; only the `is_empty()` boolean is referenced. 11 unit tests cover every D-04 branch + Pitfall G ordering + Pitfall H empty secret + signed/unsigned acceptance paths. * docs(18): log pre-existing v12_labels_interpolation flaky test as deferred item * docs(18-02): complete WebhookConfig + apply_defaults merge + validators plan * fix(18): post-merge integration — relocate config tests to file end Wave 1 cross-plan defect: 18-02 placed `mod tests {}` at line 213 with `pub fn parse_and_validate` at line 284. Once worktrees merged, clippy's `items_after_test_module` lint caught it. Move the tests to the end of the file (canonical Rust convention) so the lint and `cargo fmt --check` both pass on the merged tree. Also runs `cargo fmt` to settle a multi-line formatting diff in `validate.rs` that surfaced after the merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(18-04): add HttpDispatcher with Standard Webhooks v1 wire format WH-03 surface: HttpDispatcher implementing WebhookDispatcher trait. - HttpDispatcher struct owns reqwest::Client (rustls; 10s per-request timeout per D-18; pool_idle_timeout=90s for keep-alive), DbPool, and Arc<HashMap<i64, WebhookConfig>> handed in at construction (Plan 05 wire-up consumes). - sign_v1: HMAC-SHA256 over `${webhook-id}.${webhook-timestamp}.${body}` with literal `.` separators (D-09); base64 STANDARD alphabet WITH `=` padding (D-10, Pitfall E). - should_fire: pure D-16 coalesce decision matrix (0=always, 1=first-of- stream, N>1=every Nth match where filter_position % N == 1). - 11-step deliver() flow: webhook lookup → state filter → filter_position → coalesce decision → get_failure_context → get_run_by_id → payload build → serialize ONCE (Pitfall B) → headers → conditional sign → send same body bytes → metrics + WARN-on-failure (D-17). - D-05 unsigned mode: omits `webhook-signature` header but still emits `webhook-id` and `webhook-timestamp`. - WebhookError extended with HttpStatus / Network / Timeout / InvalidUrl / SerializationFailed variants for Phase 20 RetryingDispatcher to surface distinctly; Phase 18 funnels through DispatchFailed log path. - 6 unit tests cover sign_v1 fixture, base64 alphabet (200 random bodies — Pitfall E regression), v1,b64 header value, ULID 26-char width, 10-digit Unix seconds (Pitfall D regression), and the full coalesce decision matrix. - mod.rs re-exports HttpDispatcher. Rule 3 fixes applied: - hmac 0.13: `new_from_slice` lives on `KeyInit` trait, NOT on `Mac`. Plan's `<Hmac<Sha256> as Mac>::new_from_slice` form fails to compile. Use prelude-resolved `Hmac::<Sha256>::new_from_slice` after importing `KeyInit` alongside `Mac`. - queries helper is `get_run_by_id` returning `Option<DbRunDetail>`, not `get_run_detail`. Call existing helper and surface a clear error if the row is missing (should be impossible — scheduler just inserted the finalize row before emitting RunFinalized). Verification: cargo build clean, 19 webhook tests pass, just openssl-check confirms rustls invariant holds across native + arm64-musl + amd64-musl targets, clippy --all-targets --all-features -D warnings is clean. * docs(18-04): complete HttpDispatcher plan Summary of Plan 04: HttpDispatcher landed alongside NoopDispatcher with Standard Webhooks v1 wire format (HMAC-SHA256 sign-once / send-once), D-16 coalesce decision, D-05 unsigned mode, and a connection-pooled rustls reqwest::Client. - 1 task / 1 commit (`47f9cd4`). - 6 unit tests added (sign_v1 fixture, base64 alphabet, v1,b64 header, ULID width, 10-digit timestamp, coalesce matrix); 19 webhook tests green; 256 lib tests green; clippy clean; rustls invariant holds. - 2 Rule 3 deviations auto-fixed (hmac KeyInit trait routing, missing get_run_detail → use existing get_run_by_id). - WH-03 requirement complete. * fix(18-04): apply cargo fmt to merged dispatcher.rs Trivial whitespace-only formatting fixes applied by `cargo fmt` after worktree merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(18-05): wire HttpDispatcher into bin layer with name-keyed lookup - Build per-job webhook map in src/cli/run.rs by NAME-keyed lookup (cfg.jobs by name -> sync_result.jobs by name -> DbJob.id), NOT blind index-aligned zip. Mitigates T-18-36: a future reorder/filter inside sync_config_to_db cannot silently mis-wire one job's webhook to another job's id. - Conditional dispatcher swap: HttpDispatcher when at least one job has a webhook configured; NoopDispatcher fallback when none — avoids spinning up a reqwest::Client unnecessarily. - HttpDispatcher::new errors are surfaced via anyhow::anyhow! so the surrounding fn's anyhow::Result error-propagation idiom is preserved. Verified: cargo build clean, cargo clippy --all-targets --all-features -D warnings clean, all 256 lib unit tests pass. * feat(18-06): add Phase 18 UAT scaffolding (recipes + mock receiver + config) - Add 4 new just recipes: - api-run-now JOB_NAME (cross-cutting curl wrapper for /api/jobs/.../run-now) - uat-webhook-mock (start mock receiver via cargo example) - uat-webhook-fire JOB_NAME (delegates to api-run-now; zero raw curl) - uat-webhook-verify (tail /tmp/cronduit-webhook-mock.log) - Add examples/webhook_mock_server.rs (~110-line cargo example): - Listens on 127.0.0.1:9999, logs requests to stdout + /tmp log - Connection: close response framing for reqwest keep-alive safety - Header-end + Content-Length aware reader; 1 MiB safety cap - Extend examples/cronduit.toml with 3 webhook variants: - wh-example-signed (default state filter, default coalescing) - wh-example-unsigned (states=[failed,stopped], unsigned=true) - wh-example-fire-every-zero (states=[timeout], fire_every=0) - check-config validates the extended toml when WEBHOOK_SECRET is set * test(18-05): add wiremock e2e + unsigned + state-filter integration tests Adds 4 wiremock-based integration tests covering the WH-XX wire contract: - tests/v12_webhook_delivery_e2e.rs::webhook_delivery_e2e_signed Asserts the 3 Standard Webhooks v1 headers (webhook-id 26-char ULID, webhook-timestamp 10-digit Unix seconds, webhook-signature v1,<b64>), the 16-field payload body, and recomputes the HMAC-SHA256 signature client-side to confirm it matches what the dispatcher sent. - tests/v12_webhook_delivery_e2e.rs::v12_webhook_two_jobs_distinct_urls T-18-36 regression: two jobs with different webhook URLs must each route to their own URL. Locks the name-keyed lookup in src/cli/run.rs against future drift if sync_config_to_db ever reorders jobs[]. - tests/v12_webhook_unsigned_omits_signature.rs D-05: unsigned=true with secret=None must omit webhook-signature header but still emit webhook-id and webhook-timestamp. - tests/v12_webhook_state_filter_excludes_success.rs states=["failed"] with a status="success" event MUST NOT fire (received.is_empty() against a permissive mock). All four tests pass; clippy on the test crate is clean. * docs(18-06): author 18-HUMAN-UAT.md (7 maintainer-validated scenarios, all unchecked) - 7 scenarios cover WH-01/03/06/09 + ${WEBHOOK_SECRET} flow + metrics families: - S1: signed delivery + 3 headers + 16-field payload - S2: unsigned delivery omits webhook-signature header - S3: default coalescing (fire_every = 1) — first-of-streak only - S4: state filter excludes success - S5: fire_every = 0 legacy always-fire mode - S6: ${WEBHOOK_SECRET} env-var interpolation flow (unset/empty/set) - S7: cronduit_webhook_delivery_{dropped,sent,failed}_total via metrics-check - All scenarios reference only `just` recipes (per feedback_uat_use_just_commands.md) - All 7 boxes UNCHECKED — Claude does NOT mark UAT passed (per feedback_uat_user_validates.md) - Document text awaits maintainer review and approval before scenario runs * docs(18-06): add 18-06 SUMMARY.md UAT scaffolding for Phase 18 webhook delivery is committed; UAT itself awaits maintainer validation per project memory feedback_uat_user_validates.md. Documents: - 4 new just recipes (3 uat-webhook-* + 1 api-run-now helper) - examples/webhook_mock_server.rs (loopback HTTP/1.1 receiver) - 3 webhook variants in examples/cronduit.toml - 18-HUMAN-UAT.md with 7 unchecked maintainer-validated scenarios - 2 deferred issues (Scenario 7 metrics-check gap; pre-existing curl wrappers) - 2 deviations (checkbox order; use_defaults=false on command jobs) * test(18-05): add D-17 webhook metric integration tests (3 files) Adds delta-asserted metric counter tests using the canonical pattern from tests/v12_webhook_queue_drop.rs (baseline before action, then assert final - baseline == expected_delta): - tests/v12_webhook_success_metric.rs 2xx response increments cronduit_webhook_delivery_sent_total by 1; cronduit_webhook_delivery_failed_total unchanged. - tests/v12_webhook_failed_metric.rs 5xx response increments cronduit_webhook_delivery_failed_total by 1; cronduit_webhook_delivery_sent_total unchanged. - tests/v12_webhook_network_error_metric.rs Connection refused (URL pointed at 127.0.0.1:1, an unbindable privileged port) increments cronduit_webhook_delivery_failed_total by 1. Note: drop(MockServer) was tried first but proved unreliable on macOS where the wiremock listener can linger past the drop point; 127.0.0.1:1 connection refused is deterministic across macOS / Linux / CI runners. All 3 tests pass; clippy clean on the test binaries. * docs(18-05): complete bin-layer wire-up + wiremock integration tests plan Adds 18-05-SUMMARY.md documenting the bin-layer HttpDispatcher wire-up (name-keyed lookup, NOT blind zip — T-18-36 mitigation), the 4 wiremock wire-format tests (single-signed e2e + multi-job alignment regression + unsigned mode + state-filter exclusion), the 3 metric counter tests (D-17 sent/failed/network-error branches), and the rustls invariant re-verification. All 7 plan-05 integration tests green; cargo build + clippy --workspace clean. * fix(18): post-merge — fmt + clippy collapsible_if on webhook scaffolding cargo fmt reflowed the `dispatcher` Arc construction in run.rs to a single-line variant; clippy::collapsible_if rewrote two nested-if branches in examples/webhook_mock_server.rs to use let-chains. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(18): record VERIFICATION.md — 8/9 PASS, UAT awaiting maintainer Verifier verdict: PASS for all automated checks (WH-01/03/06/09 wire contract, bin-layer wiring, 256 unit + 7 wiremock integration tests green, fmt + clippy + rustls invariant clean). UAT artifacts present but the 7 scenarios in 18-HUMAN-UAT.md remain unchecked per feedback_uat_user_validates.md — maintainer flips the boxes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(state): record Phase 18 complete — verifier 8/9 PASS, UAT awaiting - ROADMAP: Phase 18 [x] (completed 2026-04-29); all 6 plans [x] - STATE: status=awaiting-uat; 4/10 phases complete; current focus = maintainer UAT - Resume: maintainer runs 18-HUMAN-UAT.md (7 scenarios), then /gsd-discuss-phase 19 Per project memory feedback_uat_user_validates.md, Phase 18 is NOT declared shipped — the 7 maintainer-validated checkboxes in 18-HUMAN-UAT.md remain `[ ]` until the maintainer confirms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(18-06): make uat-webhook-fire actually work — CSRF + name→id lookup The original 18-06 UAT recipe (`api-run-now JOB_NAME`) hit `/api/jobs/{NAME}/run-now` with a plain `curl -X POST`. Three real bugs that the maintainer-validation step caught on first run: 1. Wrong path: actual route is `/api/jobs/{id}/run`, not `/run-now`. 2. Wrong key: handler is `Path<i64>` (numeric job id), not name. 3. No CSRF: the handler validates `cronduit_csrf` cookie + `csrf_token` form body — a plain POST gets 403. Fix: - `api-run-now` now takes a numeric `JOB_ID` matching the handler signature. It primes the CSRF cookie via GET `/api/jobs`, extracts the `cronduit_csrf` value from curl's Netscape-format cookie jar, and POSTs with cookie + form-body token. - New `api-job-id JOB_NAME` recipe wraps `GET /api/jobs | jq` to resolve the operator's NAME (from cronduit.toml) to the numeric id. - `uat-webhook-fire JOB_NAME` (the public UAT-callable surface) is unchanged in shape: it still takes a name, but now composes the two helpers via a single bash recipe body. Operator workflow stays identical: just dev # terminal A just uat-webhook-mock # terminal B just uat-webhook-fire wh-example-unsigned # terminal C Verified statically: route/form-field/cookie names all match the handler in src/web/handlers/api.rs:run_now and src/web/csrf.rs. Live verification awaits the next maintainer UAT run. Requires: `jq` and `curl` (already implicit in the existing UAT recipes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(18): UAT 7/7 validated — flip all maintainer-validated boxes Maintainer (robert@simplicityguy.com) confirmed all 7 scenarios in 18-HUMAN-UAT.md pass against the running cronduit instance via the schedule-driven path (waited for the natural cron fires; the `uat-webhook-fire` recipe was broken at validation time and has since been fixed in e01a186). STATE moves from awaiting-uat → ready-for-pr. Next step: open PR for phase-18-webhook-payload against main, then start Phase 19. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(18): ship phase 18 — PR #51 PR: #51 Branch: phase-18-webhook-payload → main Commits: 41 ahead, 47 files changed (+11876/-34) Verification: 9/9 PASS (8/9 automated + maintainer UAT 7/7) STATE: ready-for-pr → shipped-pending-merge Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(18-06): unbreak compose-smoke — comment out signed webhook examples The Phase 18 plan-06 added 3 webhook examples to examples/cronduit.toml. Two of them (wh-example-signed and wh-example-fire-every-zero) reference `${WEBHOOK_SECRET}`, which the LOAD validator rejects when unset/empty (Pitfall H). The compose-smoke CI workflow boots the example config inside docker without setting the env var, so the container exits before /health responds — both `quickstart compose smoke (docker-compose.yml)` and `(docker-compose.secure.yml)` jobs failed on PR #51. Fix: leave wh-example-unsigned active (no env var needed; exercises the full Phase 18 dispatcher minus the signature header) and convert the two signed examples into commented-out templates with a clear "set WEBHOOK_SECRET first, then uncomment" instruction. Coverage trade-off: CI smoke still exercises the dispatcher, payload, state-filter, and coalescing through the unsigned path. The signed templates remain in-file as documentation; UAT scenarios 1/3/5/6 add a prerequisite to uncomment them before `just dev`. Verified locally: `just check-config examples/cronduit.toml` exits 0 with WEBHOOK_SECRET unset (the failure mode the CI was hitting). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9fd85f0 commit 173dceb

47 files changed

Lines changed: 11893 additions & 34 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.planning/ROADMAP.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
- [x] **Phase 15: Foundation Preamble**`Cargo.toml` 1.1.0→1.2.0 bump, `cargo-deny` CI preamble (non-blocking), webhook delivery worker foundation (bounded `mpsc(1024)` + dedicated worker task + drop counter) (completed 2026-04-26)
4545
- [x] **Phase 16: Failure-Context Schema + run.rs:277 Bug Fix**`DockerExecResult.container_id` field added and assignment corrected, `job_runs.config_hash` per-run column added (Option A), `get_failure_context(job_id)` single-query helper landed (completed 2026-04-28)
4646
- [x] **Phase 17: Custom Docker Labels (SEED-001)** — operator-defined `labels` plumbed through to `bollard::Config::labels`, merge semantics, `cronduit.*` reserved-namespace validator, type-gated validator, `${ENV_VAR}` interpolation in values, size limits (completed 2026-04-29)
47-
- [ ] **Phase 18: Webhook Payload + State-Filter + Coalescing** — Standard Webhooks v1 payload schema (`payload_version: "v1"`), per-job + `[defaults]` config with `use_defaults = false` disable, edge-triggered streak coalescing (default fires on `streak_position == 1`, `fire_every` per-job override)
47+
- [x] **Phase 18: Webhook Payload + State-Filter + Coalescing** — Standard Webhooks v1 payload schema (`payload_version: "v1"`), per-job + `[defaults]` config with `use_defaults = false` disable, edge-triggered streak coalescing (default fires on `streak_position == 1`, `fire_every` per-job override) (completed 2026-04-29)
4848
- [ ] **Phase 19: Webhook HMAC Signing + Receiver Examples** — HMAC-SHA256 only, Standard Webhooks signing-string `webhook-id.webhook-timestamp.payload`, signature header `v1,<base64>`, Python/Go/Node receiver examples with constant-time compare
4949
- [ ] **Phase 20: Webhook SSRF/HTTPS Posture + Retry/Drain + Metrics — rc.1** — HTTPS required for non-loopback/non-RFC1918, 3-attempt full-jitter exponential backoff (t=0/30s/300s × 0.8-1.2× rand), `webhook_deliveries` dead-letter table, 30s drain on shutdown, `cronduit_webhook_*` metric family; **cuts `v1.2.0-rc.1`**
5050
- [ ] **Phase 21: Failure-Context UI Panel + Exit-Code Histogram Card — rc.2** — Inline collapsed-by-default panel on run-detail with 5 P1 signals (time deltas, image-digest delta, config-hash delta, duration-vs-p50, scheduler-fire-skew), 10-bucket exit-code histogram on job-detail with `stopped` as distinct bucket and exit `0` as separate stat; **cuts `v1.2.0-rc.2`**
@@ -145,7 +145,15 @@ Plans:
145145
3. An operator inspecting a delivered webhook payload sees the locked v1.2.0 schema fields: `payload_version: "v1"`, `event_type: "run_finalized"`, `run_id`, `job_id`, `job_name`, `status`, `exit_code`, `started_at`, `finished_at`, `duration_ms`, `streak_position`, `consecutive_failures`, `image_digest` (docker only), `config_hash`, `tags`, `cronduit_version`.
146146
4. An operator inspecting delivered headers sees `webhook-id`, `webhook-timestamp`, and `webhook-signature` (Standard Webhooks v1 spec) on every delivery.
147147

148-
**Plans**: TBD
148+
**Plans:** 6/6 plans complete
149+
150+
Plans:
151+
- [x] 18-01-PLAN.md — Foundation: Cargo deps (reqwest 0.13 rustls / hmac / base64 / ulid + wiremock dev) + just test-unit recipe + 2 new webhook counters described+zero-baselined
152+
- [x] 18-02-PLAN.md — WH-01: WebhookConfig struct + apply_defaults webhook merge + check_webhook_url + check_webhook_block_completeness validators (incl. Pitfall H empty-secret)
153+
- [x] 18-03-PLAN.md — WH-06+WH-09: WebhookPayload encoder (15-field v1 schema) + coalesce::filter_position SQL helper + EXPLAIN PLAN regression test
154+
- [x] 18-04-PLAN.md — WH-03: HttpDispatcher impl (Standard Webhooks v1 headers + HMAC-SHA256 sign_v1 + reqwest 0.13 rustls Client + should_fire D-16 matrix)
155+
- [x] 18-05-PLAN.md — Bin-layer wire-up (HttpDispatcher swap in src/cli/run.rs) + 6 wiremock integration tests (e2e signed, unsigned, state-filter, 3x metric counter)
156+
- [x] 18-06-PLAN.md — Maintainer UAT: 3 new just recipes + examples/webhook_mock_server.rs + examples/cronduit.toml extension + 18-HUMAN-UAT.md (autonomous=false; maintainer-validated)
149157

150158
### Phase 19: Webhook HMAC Signing + Receiver Examples
151159

@@ -263,7 +271,7 @@ Plans:
263271
| 15. Foundation Preamble | 5/5 | Complete | 2026-04-26 |
264272
| 16. Failure-Context Schema + run.rs Bug Fix | 7/7 | Complete | 2026-04-28 |
265273
| 17. Custom Docker Labels (SEED-001) | 6/6 + 3 gap closure | Gap-closure pending | 2026-04-29 (core) |
266-
| 18. Webhook Payload + State-Filter + Coalescing | 0/— | Not started | |
274+
| 18. Webhook Payload + State-Filter + Coalescing | 6/6 | Complete | 2026-04-29 |
267275
| 19. Webhook HMAC Signing + Receiver Examples | 0/— | Not started ||
268276
| 20. Webhook SSRF/HTTPS + Retry/Drain + Metrics — rc.1 | 0/— | Not started ||
269277
| 21. Failure-Context UI + Exit-Code Histogram — rc.2 | 0/— | Not started ||

.planning/STATE.md

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
gsd_state_version: 1.0
33
milestone: v1.2
44
milestone_name: — Operator Integration & Insight
5-
status: "Phase 17 MERGED (PR #48, squash a34cad0); v1.2: 1 phase shipped"
6-
stopped_at: Phase 17 context gathered
7-
last_updated: "2026-04-29T17:41:38.606Z"
8-
last_activity: "2026-04-29 -- Phase 17 merged to main via PR #48"
5+
status: shipped-pending-merge
6+
stopped_at: Phase 18 shipped — PR #51 open against main, awaiting CI + merge
7+
last_updated: "2026-04-29T23:00:00.000Z"
8+
last_activity: 2026-04-29 -- Phase 18 PR #51 opened (https://github.com/SimplicityGuy/cronduit/pull/51)
99
progress:
1010
total_phases: 10
11-
completed_phases: 2
12-
total_plans: 14
13-
completed_plans: 14
11+
completed_phases: 4
12+
total_plans: 27
13+
completed_plans: 27
1414
percent: 100
1515
---
1616

@@ -21,18 +21,18 @@ progress:
2121
See: `.planning/PROJECT.md` (updated 2026-04-25 — v1.2 milestone kicked off)
2222

2323
**Core value:** One tool that both runs recurrent jobs reliably AND makes their state observable through a web UI.
24-
**Current focus:** Phase 17 — custom-docker-labels-seed-001 (gap closure complete; awaiting next-phase decision)
24+
**Current focus:** Phase 18 PR #51 awaiting CI + merge; queueing Phase 19 (HMAC + receiver examples)
2525

2626
## Current Position
2727

2828
Milestone: v1.2 — Operator Integration & Insight (in progress; roadmap created 2026-04-25)
2929
Previous milestone: v1.1 (SHIPPED 2026-04-23, tags `v1.1.0-rc.1``v1.1.0-rc.6`, final `v1.1.0`)
30-
Phase: 17 (custom-docker-labels-seed-001) — COMPLETE (gap closure passed verification 2026-04-29; see 17-VERIFICATION-GAP-CLOSURE.md)
31-
Plan: 9 of 9 (all gap-closure plans landed: 17-07 CR-01, 17-08 CR-02, 17-09 bookkeeping)
32-
Status: Phase 17 MERGED (PR #48, squash a34cad0); v1.2: 1 phase shipped
33-
Last activity: 2026-04-29 -- Phase 17 merged to main via PR #48
30+
Phase: 18 (webhook-payload-state-filter-coalescing) — SHIPPED (PR #51 https://github.com/SimplicityGuy/cronduit/pull/51)
31+
Plan: 6 of 6 complete
32+
Status: Phase 18 PR #51 open against `main`; awaiting CI + maintainer merge
33+
Last activity: 2026-04-29 -- PR #51 opened (41 commits, 47 files, +11876/-34)
3434

35-
Progress: [█░░░░░░░░░] 10% (v1.2: 1/10 phases complete; 9/— plans complete)
35+
Progress: [███░░░░░░] 40% (v1.2: 4/10 phases complete; 27/— plans complete)
3636

3737
## v1.2 Roadmap Summary
3838

@@ -173,8 +173,8 @@ v1.0 quick task `260414-gbf` is archived in `.planning/milestones/v1.0-MILESTONE
173173

174174
## Session Continuity
175175

176-
Last session: 2026-04-28T22:18:56.529Z
177-
Stopped at: Phase 17 context gathered
178-
Resume command: `/gsd-plan-phase 17 --gaps` — plan gap-closure for CR-01 (README contract) + CR-02 (LBL-04 error wording)
176+
Last session: 2026-04-29T23:00:00.000Z
177+
Stopped at: Phase 18 PR #51 opened (https://github.com/SimplicityGuy/cronduit/pull/51); awaiting CI + merge
178+
Resume command: after PR #51 merges, `/gsd-discuss-phase 19` for HMAC + receiver examples
179179

180-
**Planned Phase:** 15Foundation Preamble (Cargo 1.1.0→1.2.0 bump; cargo-deny CI preamble (non-blocking); webhook delivery worker foundation: bounded mpsc(1024) + dedicated worker task + drop counter; FOUND-15, FOUND-16, WH-02)
180+
**Planned Phase:** 19Webhook HMAC Signing + Receiver Examples (HMAC-SHA256 only, Standard Webhooks signing-string `webhook-id.webhook-timestamp.payload`, signature header `v1,<base64>`, Python/Go/Node receiver examples with constant-time compare)

0 commit comments

Comments
 (0)