Skip to content

feat(calendar): relay Google Calendar push to local stacks via dev - #5593

Draft
gbirman wants to merge 9 commits into
mainfrom
gbirman/macro-2879-calendar-eventswatch-local-testing-integration
Draft

feat(calendar): relay Google Calendar push to local stacks via dev#5593
gbirman wants to merge 9 commits into
mainfrom
gbirman/macro-2879-calendar-eventswatch-local-testing-integration

Conversation

@gbirman

@gbirman gbirman commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Makes real Google Calendar push deliverable to run_local stacks with no tunnel daemon or DNS: a new dev-only calendar-event-local-tunnel service (ECS + ALB at calendar-event-local-tunnel-dev.macro.com, wired through services-config/nix/doppler-projects in this PR) is the public webhook address for local watch channels and fans deliveries out by channel token to secret-gated SSE subscribers; each stack's pubsub workers subscribe out and re-arm sync jobs (just calendar-push enable, recipe in docs/RUNNING_LOCALLY.md). Graceful local shutdown stops the stack's open channels at Google via the new channels/stop adapter. Before first deploy: pulumi up the doppler-projects stack, put CALENDAR_WATCH_RELAY_SECRET in the new project's dev config and in local/lcl_personal, and init the macro-inc/calendar-event-local-tunnel/dev + /prod Pulumi stacks (prod is an empty no-op).

@macro-application

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c24e6b7d-0689-444d-b169-6b7eba50f58d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added local Google Calendar push notification support with relay forwarding and live event subscriptions.
    • Added calendar-push enable, disable, and status commands for per-instance configuration.
    • Calendar synchronization now reconnects automatically and processes relayed notifications.
  • Reliability
    • Added graceful cleanup of active Google Calendar watch channels during shutdown, including partial-failure handling.
  • Documentation
    • Added local setup, verification, disabling, and direct webhook testing instructions.

Walkthrough

The change adds domain models and ports for Google Calendar watch-channel teardown. The calendar service stops active channels, clears successful records, caches tokens per authorization link, and reports failures. Google and PostgreSQL adapters implement provider stopping and guarded persistence cleanup. The email service adds a Redis-backed relay, authenticated SSE endpoints, and a reconnecting subscriber. Local tooling configures per-instance calendar-push overlays with enable, disable, and status actions. Documentation covers local setup and verification.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses conventional commit format, describes the relay feature, and is 66 characters long.
Description check ✅ Passed The description clearly explains the local Google Calendar push relay, SSE subscribers, shutdown cleanup, configuration, and usage.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch gbirman/macro-2879-calendar-eventswatch-local-testing-integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (4)
services/email_service/src/calendar_watch_relay.rs (2)

91-94: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Align the doc comment with the actual comparison.

Sha256::digest(...) == Sha256::digest(...) compares two GenericArray values with a normal byte-wise equality that can return early. The doc comment states the comparison has no early exit. Hashing first removes the practical timing leak of the raw secret, so the behavior is acceptable, but the wording is inaccurate. Either correct the comment or use a constant-time equality helper.

♻️ Suggested comment correction
-/// Compare two secrets without early exit on the first differing byte.
+/// Compare two secrets by SHA-256 digest, so a timing difference cannot
+/// reveal a prefix of the expected secret.
 pub fn secrets_match(presented: &str, expected: &str) -> bool {
     Sha256::digest(presented.as_bytes()) == Sha256::digest(expected.as_bytes())
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/email_service/src/calendar_watch_relay.rs` around lines 91 - 94,
Update the doc comment for secrets_match to accurately describe the existing
SHA-256 digest comparison, removing the claim that it avoids early exit; do not
change the comparison implementation.

121-131: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider reusing one multiplexed connection for publishing.

publish calls get_multiplexed_async_connection on every notification, so each relayed delivery pays a connection setup. RedisWatchRelayBus is cloneable and long-lived, so it can hold a lazily created MultiplexedConnection instead. Traffic is dev-only today, so this is optional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/email_service/src/calendar_watch_relay.rs` around lines 121 - 131,
Update RedisWatchRelayBus and its publish method to lazily initialize and reuse
a single cloneable multiplexed Redis connection across notifications instead of
calling get_multiplexed_async_connection on every publish. Preserve the existing
serialization, channel, publish, and error propagation behavior.
services/email_service/src/api/calendar_watch.rs (1)

79-108: 🔒 Security & Privacy | 🔵 Trivial

Consider rate limiting the unauthenticated relay publish path on dev.

/calendar/notifications accepts any caller. When the relay serves, any request that carries the four x-goog-* headers causes a Redis PUBLISH under the hash of the presented token. A caller that learns a local instance token can inject a synthetic notification and force that stack to re-arm a sync job, and unauthenticated traffic can drive Redis publish load on the dev deployment. The relay is inert in production, so this is dev-only exposure. A simple per-IP or per-token rate limit on the relay branch contains both effects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/email_service/src/api/calendar_watch.rs` around lines 79 - 108,
Apply a simple rate limit to the relay branch of the calendar notification
handler before calling `relay.bus.publish`, keyed by caller IP or the presented
token. Reject requests exceeding the limit without publishing, while preserving
the existing validation and successful notification flow for allowed requests.
services/email_service/src/pubsub/calendar_watch_subscriber.rs (1)

127-145: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider decoding SSE bytes without from_utf8_lossy per chunk.

String::from_utf8_lossy(&chunk) decodes each chunk independently. A multi-byte UTF-8 character split across two chunks becomes replacement characters, which corrupts the payload. The relayed fields are Google header values and are ASCII in practice, so the risk is low today. Feeding bytes into the parser, or buffering an incomplete trailing sequence, removes the failure mode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/email_service/src/pubsub/calendar_watch_subscriber.rs` around lines
127 - 145, Update the SSE parsing loop around SseDataParser::push to avoid
decoding each stream chunk independently with String::from_utf8_lossy. Feed
bytes directly into a byte-aware parser or retain incomplete trailing UTF-8
sequences between iterations, ensuring multi-byte characters split across chunks
are decoded intact before serde_json parses each RelayedWatchNotification.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/calendar_events/src/domain/service.rs`:
- Around line 667-678: Update the watch-channel cleanup flow around
clear_watch_channel so cleanup errors are not discarded or counted as
successfully stopped: increment summary.failed (or the established separate
cleanup-failure counter) when clearing bookkeeping fails, while retaining
summary.stopped only for successful cleanup. Add a test covering a
clear_watch_channel failure and verify the summary reflects the failure.

In `@services/email_service/src/pubsub/calendar_watch_subscriber.rs`:
- Around line 115-126: Wrap the subscribe request’s send future in a bounded
timeout so stalled response headers return control to the reconnect loop for
backoff and retry. Update the request flow around the visible
client.get(...).send().await call, add the request-timeout constant alongside
the existing timeout constants, and propagate or handle the timeout error
consistently with the surrounding reconnect logic.

In `@tooling/xtask/crates/xtask_local/src/local/calendar_push.rs`:
- Around line 37-42: Update the persisted-token loading logic in the token
helper around std::fs::read_to_string so generation occurs only when the path is
absent. Propagate permission and other read errors, and return an error when an
existing token file is empty; preserve the existing-token return path for
non-empty content and avoid minting or overwriting a replacement token in
failure cases.

In `@tooling/xtask/crates/xtask_local/src/local/env_layer.rs`:
- Around line 115-126: Update the merged-environment validation in resolve so
calendar push remains inactive when CALENDAR_WATCH_RELAY_SECRET is absent,
empty, or whitespace-only. Validate the secret’s trimmed content rather than
using contains_key; on failure, return an error or remove the relay
configuration, ensuring calendar_push::enable cannot leave
CALENDAR_WATCH_RELAY_URL armed without a valid secret.

---

Nitpick comments:
In `@services/email_service/src/api/calendar_watch.rs`:
- Around line 79-108: Apply a simple rate limit to the relay branch of the
calendar notification handler before calling `relay.bus.publish`, keyed by
caller IP or the presented token. Reject requests exceeding the limit without
publishing, while preserving the existing validation and successful notification
flow for allowed requests.

In `@services/email_service/src/calendar_watch_relay.rs`:
- Around line 91-94: Update the doc comment for secrets_match to accurately
describe the existing SHA-256 digest comparison, removing the claim that it
avoids early exit; do not change the comparison implementation.
- Around line 121-131: Update RedisWatchRelayBus and its publish method to
lazily initialize and reuse a single cloneable multiplexed Redis connection
across notifications instead of calling get_multiplexed_async_connection on
every publish. Preserve the existing serialization, channel, publish, and error
propagation behavior.

In `@services/email_service/src/pubsub/calendar_watch_subscriber.rs`:
- Around line 127-145: Update the SSE parsing loop around SseDataParser::push to
avoid decoding each stream chunk independently with String::from_utf8_lossy.
Feed bytes directly into a byte-aware parser or retain incomplete trailing UTF-8
sequences between iterations, ensuring multi-byte characters split across chunks
are decoded intact before serde_json parses each RelayedWatchNotification.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 88e989f2-5b5f-4ad8-afb1-0909e5acca57

📥 Commits

Reviewing files that changed from the base of the PR and between 412bc88 and 9e153f7.

⛔ Files ignored due to path filters (2)
  • .sqlx/query-a6ea220aaff4d5b84963280a24d51034b8f16fc6dbd288ebbd027cfde86b7872.json is excluded by !**/.sqlx/**
  • .sqlx/query-ec4d63f3ed765a0f289331e66a95171e097fa6ff3676cc64fdcbebf517bea83b.json is excluded by !**/.sqlx/**
📒 Files selected for processing (23)
  • crates/calendar_events/src/domain/models.rs
  • crates/calendar_events/src/domain/ports.rs
  • crates/calendar_events/src/domain/service.rs
  • crates/calendar_events/src/domain/service/test.rs
  • crates/calendar_events/src/outbound/google.rs
  • crates/calendar_events/src/outbound/pg.rs
  • crates/calendar_events/src/outbound/pg/test.rs
  • docs/RUNNING_LOCALLY.md
  • services/email_service/src/api/calendar_watch.rs
  • services/email_service/src/api/context.rs
  • services/email_service/src/bin/pubsub_workers/pubsub_workers.rs
  • services/email_service/src/calendar_watch_relay.rs
  • services/email_service/src/calendar_watch_relay/test.rs
  • services/email_service/src/lib.rs
  • services/email_service/src/main.rs
  • services/email_service/src/openapi.rs
  • services/email_service/src/pubsub/calendar_watch_subscriber.rs
  • services/email_service/src/pubsub/mod.rs
  • tooling/just/xtask.just
  • tooling/xtask/crates/xtask_local/src/local/calendar_push.rs
  • tooling/xtask/crates/xtask_local/src/local/cli.rs
  • tooling/xtask/crates/xtask_local/src/local/env_layer.rs
  • tooling/xtask/crates/xtask_local/src/local/mod.rs

Comment on lines +667 to +678
repository
.clear_watch_channel(channel.calendar_id, &channel.channel_id)
.await
.inspect_err(|error| {
tracing::warn!(
error=?error,
calendar_id=%channel.calendar_id,
"stopped a watch channel but failed to clear its bookkeeping"
);
})
.ok();
summary.stopped += 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Count bookkeeping cleanup failures as failed.

At Line 667, clear_watch_channel errors are discarded. At Line 678, the code then increments summary.stopped. This reports the channel as cleared when its bookkeeping remains retained.

Increment failed for cleanup errors, or add a separate cleanup-failure count. Add a test for this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/calendar_events/src/domain/service.rs` around lines 667 - 678, Update
the watch-channel cleanup flow around clear_watch_channel so cleanup errors are
not discarded or counted as successfully stopped: increment summary.failed (or
the established separate cleanup-failure counter) when clearing bookkeeping
fails, while retaining summary.stopped only for successful cleanup. Add a test
covering a clear_watch_channel failure and verify the summary reflects the
failure.

Comment on lines +115 to +126
let response = client
.get(format!("{}/calendar/relay/subscribe", config.url))
.header("x-relay-secret", &config.secret)
.header("x-relay-token", token)
.send()
.await?;
if response.status() != reqwest::StatusCode::OK {
anyhow::bail!(
"relay subscription rejected with status {}",
response.status()
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the initial request so a stalled response cannot stop reconnects.

The client sets only connect_timeout, and READ_TIMEOUT applies after the body stream starts. If the relay accepts the TCP connection but never sends response headers, send().await waits without limit. The reconnect loop then stays parked on that future, and the local stack receives no relayed notifications until it shuts down. Wrap the request in a timeout so the loop can back off and retry.

🐛 Proposed fix
-    let response = client
-        .get(format!("{}/calendar/relay/subscribe", config.url))
-        .header("x-relay-secret", &config.secret)
-        .header("x-relay-token", token)
-        .send()
-        .await?;
+    let request = client
+        .get(format!("{}/calendar/relay/subscribe", config.url))
+        .header("x-relay-secret", &config.secret)
+        .header("x-relay-token", token)
+        .send();
+    let response = match tokio::time::timeout(CONNECT_TIMEOUT, request).await {
+        Ok(result) => result?,
+        Err(_) => anyhow::bail!("relay subscription request timed out"),
+    };

Add the constant next to the other timeouts:

/// Bound on the subscribe request, which must return headers promptly even
/// though the body stays open.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let response = client
.get(format!("{}/calendar/relay/subscribe", config.url))
.header("x-relay-secret", &config.secret)
.header("x-relay-token", token)
.send()
.await?;
if response.status() != reqwest::StatusCode::OK {
anyhow::bail!(
"relay subscription rejected with status {}",
response.status()
);
}
let request = client
.get(format!("{}/calendar/relay/subscribe", config.url))
.header("x-relay-secret", &config.secret)
.header("x-relay-token", token)
.send();
let response = match tokio::time::timeout(CONNECT_TIMEOUT, request).await {
Ok(result) => result?,
Err(_) => anyhow::bail!("relay subscription request timed out"),
};
if response.status() != reqwest::StatusCode::OK {
anyhow::bail!(
"relay subscription rejected with status {}",
response.status()
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/email_service/src/pubsub/calendar_watch_subscriber.rs` around lines
115 - 126, Wrap the subscribe request’s send future in a bounded timeout so
stalled response headers return control to the reconnect loop for backoff and
retry. Update the request flow around the visible client.get(...).send().await
call, add the request-timeout constant alongside the existing timeout constants,
and propagate or handle the timeout error consistently with the surrounding
reconnect logic.

use std::io::Read;
use std::path::PathBuf;

use anyhow::{Context, Result};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
wc -l tooling/xtask/crates/xtask_local/src/local/calendar_push.rs
cat -n tooling/xtask/crates/xtask_local/src/local/calendar_push.rs
printf '%s\n' '--- manifests ---'
fd -i 'Cargo.toml' tooling/xtask/crates/xtask_local tooling/xtask | head -40
printf '%s\n' '--- target manifest ---'
cat -n tooling/xtask/crates/xtask_local/Cargo.toml
printf '%s\n' '--- rootcause and anyhow usage in xtask_local ---'
rg -n -C 2 '\b(rootcause|anyhow)\b' tooling/xtask/crates/xtask_local --glob '*.rs' --glob 'Cargo.toml'

Repository: macro-inc/macro

Length of output: 28940


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- rootcause declarations ---'
rg -n -C 3 'rootcause' --glob 'Cargo.toml' --glob '*.lock' --glob '*.rs' | head -300
printf '%s\n' '--- applicable instruction files ---'
find tooling/xtask -name 'CLAUDE.md' -o -name 'AGENTS.md' -o -name 'CONTRIBUTING.md' | sort
printf '%s\n' '--- workspace manifest context ---'
rg -n -C 4 '^\[workspace.dependencies\]|^anyhow|^rootcause' Cargo.toml tooling/xtask/Cargo.toml
printf '%s\n' '--- local crate module declarations ---'
cat -n tooling/xtask/crates/xtask_local/src/local/mod.rs | sed -n '1,80p'

Repository: macro-inc/macro

Length of output: 618


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- rootcause references in tracked files ---'
git grep -n -i 'rootcause' -- ':!*.lock' || true
printf '%s\n' '--- workspace dependency section ---'
awk '
  /^\[workspace.dependencies\]/ {show=1}
  show {print NR ":" $0}
  show && /^\[/ && $0 != "[workspace.dependencies]" {exit}
' Cargo.toml
printf '%s\n' '--- xtask manifest ---'
cat -n tooling/xtask/Cargo.toml | sed -n '1,100p'
printf '%s\n' '--- applicable instruction files ---'
git ls-files | grep -E '(^|/)(CLAUDE|AGENTS|CONTRIBUTING)\.md$' | grep '^tooling/xtask/' || true
printf '%s\n' '--- local module declarations ---'
cat -n tooling/xtask/crates/xtask_local/src/local/mod.rs | sed -n '1,90p'

Repository: macro-inc/macro

Length of output: 50371


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- calendar_push call sites and declarations ---'
git grep -n -C 3 -E 'calendar_push|calendar push' -- tooling/xtask/crates/xtask_local/src tooling/xtask/crates/xtask_local/Cargo.toml || true
printf '%s\n' '--- enclosing return types ---'
git grep -n -C 2 -E 'fn (enable|disable|status)|calendar_push::(enable|disable|status)' -- tooling/xtask/crates/xtask_local/src || true
printf '%s\n' '--- change metadata ---'
git status --short -- tooling/xtask/crates/xtask_local/src/local/calendar_push.rs tooling/xtask/crates/xtask_local/Cargo.toml tooling/xtask/crates/xtask_local/src/local/mod.rs
git diff --stat -- tooling/xtask/crates/xtask_local/src/local/calendar_push.rs tooling/xtask/crates/xtask_local/Cargo.toml tooling/xtask/crates/xtask_local/src/local/mod.rs

Repository: macro-inc/macro

Length of output: 12108


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- rootcause/anyhow boundary patterns ---'
git grep -n -C 2 -E 'anyhow::Error::from\(.*rootcause|rootcause::Report.*anyhow|anyhow::Result.*rootcause|Result<.*rootcause::Report' -- '*.rs' | head -240 || true
printf '%s\n' '--- cli return boundary ---'
cat -n tooling/xtask/crates/xtask_local/src/local/cli.rs | sed -n '235,282p'
cat -n tooling/xtask/crates/xtask_local/src/main.rs | sed -n '1,45p'
printf '%s\n' '--- rootcause ResultExt usage examples ---'
git grep -n -C 2 'rootcause::prelude::ResultExt' -- '*.rs' | head -120 || true

Repository: macro-inc/macro

Length of output: 23647


Use rootcause for this module's new error handling.

Add the workspace dependency and use rootcause::Report with rootcause::prelude::ResultExt for the module's fallible functions.

Source: Coding guidelines

Comment on lines +37 to +42
if let Ok(existing) = std::fs::read_to_string(&path) {
let existing = existing.trim();
if !existing.is_empty() {
return Ok(existing.to_owned());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not rotate the persisted token after an arbitrary read failure.

if let Ok(existing) treats permission and I/O errors as a missing token file. enable can then mint and overwrite a new token. Existing Google channels still use the old token, which can orphan their deliveries. Generate only when the path is absent. Return errors for other read failures and for an existing empty token.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tooling/xtask/crates/xtask_local/src/local/calendar_push.rs` around lines 37
- 42, Update the persisted-token loading logic in the token helper around
std::fs::read_to_string so generation occurs only when the path is absent.
Propagate permission and other read errors, and return an error when an existing
token file is empty; preserve the existing-token return path for non-empty
content and avoid minting or overwriting a replacement token in failure cases.

Comment on lines +115 to +126
// The relay secret rides a later layer (Doppler or --env-file), so only
// the fully merged map can tell whether calendar push will actually arm.
if env.contains_key("CALENDAR_WATCH_RELAY_URL")
&& !env.contains_key("CALENDAR_WATCH_RELAY_SECRET")
{
eprintln!(
"warning: calendar push is enabled but CALENDAR_WATCH_RELAY_SECRET is missing — \
the stack will open watch channels without subscribing to their deliveries; \
supply the secret via Doppler or --env-file"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not arm calendar push with a missing or blank relay secret.

tooling/xtask/crates/xtask_local/src/local/calendar_push.rs::enable writes CALENDAR_WATCH_RELAY_URL before this check. When CALENDAR_WATCH_RELAY_SECRET is absent, resolve only warns and returns success, so the next stack can open Google watch channels without a subscriber. contains_key also treats CALENDAR_WATCH_RELAY_SECRET= and whitespace-only values as configured. Return an error or omit the relay variables when the secret is missing or blank.

The PR objective requires the relay to remain inactive until its required configuration is present. Based on learnings, validate required secret content instead of checking only key presence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tooling/xtask/crates/xtask_local/src/local/env_layer.rs` around lines 115 -
126, Update the merged-environment validation in resolve so calendar push
remains inactive when CALENDAR_WATCH_RELAY_SECRET is absent, empty, or
whitespace-only. Validate the secret’s trimmed content rather than using
contains_key; on failure, return an error or remove the relay configuration,
ensuring calendar_push::enable cannot leave CALENDAR_WATCH_RELAY_URL armed
without a valid secret.

Source: Learnings

@gbirman
gbirman marked this pull request as draft August 12, 2026 05:21
Local stacks open events.watch channels against dev's verified webhook
address with a per-instance token; dev forwards non-matching tokens over
Redis pub/sub to an SSE subscribe endpoint, and the local pubsub workers
consume that stream and re-arm sync jobs. Graceful shutdown now stops the
stack's open channels at Google (channels.stop). `just calendar-push
enable` arms it per instance.
…blank

A present-but-empty CALENDAR_WATCH_RELAY_SECRET previously passed the
contains_key check, leaving the overlay's watch config armed so the stack
opened channels nobody subscribes to. Resolve now validates the trimmed
secret and strips the overlay's contribution (keeping later-layer
overrides); hand-armed relay config still just warns.
A stopped channel whose watch columns survive is not fully torn down —
the summary previously reported it as stopped. Kept bookkeeping means a
later pass retries; channels.stop tolerates already-gone channels, so
the retry converges.
…not park reconnects

Only connect_timeout was set; a server that accepts TCP but never sends
response headers left send() pending forever, and READ_TIMEOUT only
starts once the body streams.
A permission or I/O error reading the token file was treated as a
missing token, minting a replacement and orphaning channels opened
under the old one. Only NotFound mints; other failures (and a corrupt
empty file) now error out.
… serving from email_service

The relay's serving side moves to its own dev-only service, so
email_service reverts to the plain webhook handler; the wire model, SSE
parsing, secret comparison, and subscriber config move to a shared crate
consumed by both the tunnel service and the subscriber worker.
Stateless axum service that is the public webhook address for local
stacks' watch channels: POST /calendar/notifications routes deliveries
by channel token through an in-memory fan-out to secret-gated SSE
subscribers on /calendar/relay/subscribe. Strays with no live
subscriber are acknowledged and dropped.
ECS Fargate + ALB + Route53 A-alias at
calendar-event-local-tunnel-dev.macro.com, copied from the unfurl stack.
Pinned to a single task with no autoscaling because subscriber fan-out is
in-memory; long ALB idle timeout for the held SSE connections. The prod
stack is an empty no-op so environment-wide deploys succeed.
@gbirman
gbirman force-pushed the gbirman/macro-2879-calendar-eventswatch-local-testing-integration branch from 591fa14 to 1e82076 Compare August 12, 2026 19:38
@github-actions

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant