From 30c9970b89196b6c787b873517e12285e2b2c55e Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Thu, 6 Aug 2026 00:50:01 +0800 Subject: [PATCH 1/8] ci: publish fork images + cut c7e release tags Retarget publish-images.yml at ghcr.io/cartridge-gg/centaur on GitHub-hosted runners with docker buildx (this fork has no Depot project), add the release/c7e-centaur-overlay* branch glob and contrib/chart/** path trigger, and auto-dispatch the c7e overlay deploy when canonical-branch images publish. Add tag-release.yml: durable c7e-- tags cut from main, so deployed commits can never be orphaned by history rewrites. Gate release-chart.yml's tag minting to the upstream repo so the fork cannot mint colliding centaur-* tags. Co-Authored-By: Claude Fable 5 --- .github/workflows/publish-images.yml | 59 +++++++-- .github/workflows/release-chart.yml | 12 +- .github/workflows/tag-release.yml | 183 +++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/tag-release.yml diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 124fc46f55..b6d4eaaca6 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -2,10 +2,13 @@ name: Publish Images on: push: - branches: [main] + branches: + - main + - release/c7e-centaur-overlay* tags: [v*] paths: - .github/workflows/publish-images.yml + - contrib/chart/** - services/** - crates/harness-server/** - harness/** @@ -31,22 +34,27 @@ permissions: env: REGISTRY: ghcr.io - IMAGE_NAMESPACE: paradigmxyz/centaur - IMAGE_SOURCE: https://github.com/paradigmxyz/centaur + IMAGE_NAMESPACE: cartridge-gg/centaur + IMAGE_SOURCE: https://github.com/cartridge-gg/centaur # Main/tags keep optimized release images. PRs and manual branch publishes # use debug builds so staging/dev iteration does not spend minutes optimizing # Rust binaries that are immediately replaced by the next test build. RUST_BUILD_PROFILE: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && 'debug' || 'release' }} jobs: - # Build each image with Depot's hosted builders, push by digest, and hand - # the digests to the merge job below which assembles the multi-arch manifest. + # Build each image natively per platform (amd64 on x64 runners, arm64 on + # arm runners — no QEMU emulation), push by digest, and hand the digests + # to the merge job below which assembles the multi-arch manifest. # arm64 is only built on pushes to main and release tags — PR and manual # dispatch builds stay amd64-only to keep iteration fast. Fork PRs skip both # jobs so untrusted changes do not run on the hosted image builders. + # + # c7e overlay: upstream builds on Depot (depot-* runners + paradigmxyz's Depot + # project). This fork has no Depot project, so the build stays on GitHub-hosted + # runners with docker buildx. Reconcile if upstream reworks the build again. build: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - runs-on: ${{ matrix.platform == 'linux/arm64' && 'depot-ubuntu-24.04-arm-16' || 'depot-ubuntu-24.04-16' }} + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} strategy: fail-fast: false matrix: @@ -110,8 +118,8 @@ jobs: platform="${{ matrix.platform }}" echo "PLATFORM_SLUG=${platform//\//-}" >> "$GITHUB_ENV" - - name: Set up Depot - uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -133,9 +141,8 @@ jobs: - name: Build and push ${{ matrix.image }} (${{ matrix.platform }}) id: build - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: - project: d8qqlh1bmq context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} target: ${{ matrix.target }} @@ -147,6 +154,14 @@ jobs: outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }} build-args: | RUST_BUILD_PROFILE=${{ env.RUST_BUILD_PROFILE }} + cache-from: | + type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }}:buildcache-${{ env.PLATFORM_SLUG }} + type=gha,scope=${{ matrix.image }}-${{ env.PLATFORM_SLUG }} + # Fork PRs run with a read-only GITHUB_TOKEN: exporting the registry + # cache would fail the build, so only export it when we can push. + cache-to: | + ${{ !github.event.pull_request.head.repo.fork && format('type=registry,ref={0}/{1}/{2}:buildcache-{3},mode=max', env.REGISTRY, env.IMAGE_NAMESPACE, matrix.image, env.PLATFORM_SLUG) || '' }} + type=gha,mode=max,scope=${{ matrix.image }}-${{ env.PLATFORM_SLUG }} - name: Export digest if: ${{ !github.event.pull_request.head.repo.fork }} @@ -165,7 +180,7 @@ jobs: retention-days: 1 merge: - runs-on: depot-ubuntu-24.04-16 + runs-on: ubuntu-latest needs: build if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} strategy: @@ -231,3 +246,25 @@ jobs: - name: Inspect manifest run: | docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }}:${{ steps.meta.outputs.version }} + + # Auto-deploy: once the canonical release branch's base images are published, + # tell the c7e overlay to roll them out (it gates on the `latitude` + # environment). Manual pin-bump + dispatch still works as a fallback. + notify-overlay: + needs: merge + if: github.event_name == 'push' && github.ref == 'refs/heads/release/c7e-centaur-overlay' + runs-on: ubuntu-latest + steps: + - name: Dispatch overlay deploy with the new base image sha + env: + GH_TOKEN: ${{ secrets.OVERLAY_DISPATCH_TOKEN }} + run: | + short="${GITHUB_SHA:0:7}" + gh api repos/cartridge-gg/agent/dispatches --method POST --input - < tags that collide with + # upstream's. Forks version themselves via tag-release.yml (c7e-* tags). + if: github.event_name == 'push' && github.repository == 'paradigmxyz/centaur' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -67,6 +70,10 @@ jobs: uses: mikefarah/yq@c14f446382944492701b16c1ddb48bb9dbe683e3 # v4.53.6 - name: Validate chart version bump + # Upstream only. Chart.yaml .version is upstream's release number; forcing forks to + # bump it would both block overlay PRs that touch contrib/chart and destroy its + # meaning as the upstream base that tag-release.yml reads. + if: github.repository == 'paradigmxyz/centaur' run: | set -e @@ -98,7 +105,8 @@ jobs: exit 1 - name: Run chart-releaser - if: github.event_name == 'push' + # Upstream only — this is what mints the centaur- tags and releases. + if: github.event_name == 'push' && github.repository == 'paradigmxyz/centaur' uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 with: skip_existing: true diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000000..3168699c8a --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,183 @@ +name: Tag release + +# Cuts a durable, fork-distinct version tag from main. +# +# Scheme: c7e--, e.g. c7e-0.1.109-2 +# +# The upstream base is derived from the newest paradigmxyz/centaur release tag that is an +# ancestor of HEAD, NOT from contrib/chart/Chart.yaml — release-chart.yml forces that field +# to be bumped whenever contrib/chart changes, at which point it stops meaning "the upstream +# release we sit on". +# +# Why tag at all: the previously deployed commit (1807424a) was orphaned by a rebase of the +# release branch and is reachable from no ref. A tag cannot be orphaned that way, so the +# commit — and its published sha- image — stay resolvable. + +on: + workflow_dispatch: + inputs: + dry_run: + description: Resolve the next version but create nothing + type: boolean + required: false + default: false + create_release: + description: Publish a GitHub Release alongside the tag + type: boolean + required: false + default: true + +permissions: + contents: write + +concurrency: + group: tag-release + cancel-in-progress: false + +jobs: + tag: + name: Cut release tag + # ubuntu-latest is load-bearing: depot-* jobs never get a runner in this fork. + runs-on: ubuntu-latest + steps: + - name: Require main + run: | + set -euo pipefail + if [ "${GITHUB_REF_NAME}" != "main" ]; then + echo "::error::Releases are cut from main only; this ran on '${GITHUB_REF_NAME}'." + exit 1 + fi + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Fetch upstream tags and main + run: | + set -euo pipefail + url=https://github.com/paradigmxyz/centaur.git + git remote add upstream "$url" 2>/dev/null || git remote set-url upstream "$url" + # Local to this runner; never pushed back to origin. + git fetch --quiet upstream 'refs/tags/centaur-*:refs/tags/centaur-*' + git fetch --quiet upstream main:refs/remotes/upstream/main + + - name: Resolve next version + id: version + run: | + set -euo pipefail + + base_tag=$(git describe --tags --match 'centaur-*' --abbrev=0 HEAD 2>/dev/null || true) + if [ -z "$base_tag" ]; then + echo "::error::No upstream centaur-* release tag is an ancestor of HEAD; cannot derive a base version." + exit 1 + fi + base="${base_tag#centaur-}" + + already=$(git tag --list 'c7e-*' --points-at HEAD | head -n 1) + if [ -n "$already" ]; then + echo "::error::HEAD is already tagged '${already}'. Nothing new to release." + exit 1 + fi + + last=$(git tag --list "c7e-${base}-*" \ + | sed "s|^c7e-${base}-||" \ + | grep -E '^[0-9]+$' \ + | sort -n | tail -n 1 || true) + next=$(( ${last:-0} + 1 )) + tag="c7e-${base}-${next}" + + prev=$(git tag --list 'c7e-*' --sort=-creatordate | head -n 1) + + # Three distinct figures. Conflating them is the trap: "commits since the upstream + # release" mixes synced upstream commits with our own, and is not staleness. + since_base=$(git rev-list --count "${base_tag}..HEAD") + overlay=$(git rev-list --count upstream/main..HEAD) + behind=$(git rev-list --count HEAD..upstream/main) + upstream_latest=$(git tag --list 'centaur-*' | sort -V | tail -n 1) + + { + echo "tag=${tag}" + echo "base=${base}" + echo "base_tag=${base_tag}" + echo "prev=${prev}" + echo "since_base=${since_base}" + echo "overlay=${overlay}" + echo "behind=${behind}" + echo "upstream_latest=${upstream_latest}" + } >> "$GITHUB_OUTPUT" + + { + echo "# ${tag}" + echo + echo "| | |" + echo "| --- | --- |" + echo "| Upstream base | \`${base_tag}\` (\`$(git rev-parse --short "${base_tag}^{commit}")\`) |" + echo "| Release commit | \`$(git rev-parse --short HEAD)\` |" + echo "| Overlay commits | ${overlay} |" + echo "| Behind upstream | ${behind} |" + echo "| Commits since upstream release | ${since_base} |" + if [ "$upstream_latest" != "$base_tag" ]; then + echo + echo "> [!WARNING]" + echo "> Upstream's newest release is \`${upstream_latest}\`, but this release sits on \`${base_tag}\` and is ${behind} commit(s) behind upstream main." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Create and push tag + if: ${{ !inputs.dry_run }} + env: + TAG: ${{ steps.version.outputs.tag }} + BASE_TAG: ${{ steps.version.outputs.base_tag }} + OVERLAY: ${{ steps.version.outputs.overlay }} + BEHIND: ${{ steps.version.outputs.behind }} + SINCE_BASE: ${{ steps.version.outputs.since_base }} + UPSTREAM_LATEST: ${{ steps.version.outputs.upstream_latest }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + { + echo "${TAG}" + echo + echo "Upstream base: ${BASE_TAG} ($(git rev-parse --short "${BASE_TAG}^{commit}"))" + echo "Release commit: $(git rev-parse HEAD)" + echo "Overlay commits: ${OVERLAY}" + echo "Behind upstream: ${BEHIND}" + echo "Since upstream release: ${SINCE_BASE}" + if [ "${UPSTREAM_LATEST}" != "${BASE_TAG}" ]; then + echo + echo "Upstream's newest release at tag time was ${UPSTREAM_LATEST}." + fi + } > /tmp/tag-message.txt + + git tag -a "${TAG}" -F /tmp/tag-message.txt + git push origin "refs/tags/${TAG}" + echo "::notice::Created tag ${TAG}" + + - name: Publish GitHub Release + if: ${{ !inputs.dry_run && inputs.create_release }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + PREV: ${{ steps.version.outputs.prev }} + run: | + set -euo pipefail + args=(--title "${TAG}" --generate-notes) + if [ -n "${PREV}" ]; then + args+=(--notes-start-tag "${PREV}") + fi + url=$(gh release create "${TAG}" "${args[@]}") + echo "::notice::Published release ${url}" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Release: ${url}" >> "$GITHUB_STEP_SUMMARY" + + - name: Report dry run + if: ${{ inputs.dry_run }} + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + echo "::notice::Dry run — next version would be ${TAG}. Nothing was created." + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Dry run** — nothing was created." >> "$GITHUB_STEP_SUMMARY" From 13dfb68f45ab8fe53310ce41a1797460899882a5 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Thu, 6 Aug 2026 02:01:25 +0800 Subject: [PATCH 2/8] feat: github_app broker credential grant + tool secret-source passthrough Console: add a github_app grant to the broker-credential registry. The console holds the App private key (client_secret, stored encrypted) and mints short-lived installation tokens via an RS256 App JWT, rotating them like any other brokered credential. An idempotent seed migration provisions the credential from GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY_B64 when set; the chart wires those env vars into the console from the shared infra Secret. api-rs: let a tool's pyproject declare where an HTTP secret's value comes from (source = {type = "token_broker", credential_id = ...}) and forward the table verbatim into the generated iron-proxy fragment: tool_discovery parses it, centaur-perms mirrors it, iron-control accepts typed sources at startup. Falls back to the {placeholder: } shape when no source table is given. Together these let a tool (the deployment's github_auth_headers) ride broker-rotated GitHub App tokens: raw tokens never enter sandboxes and no out-of-band CronJob rotates a shared secret. Pending upstream as paradigmxyz/centaur#793 + companion. Co-Authored-By: Claude Fable 5 --- contrib/chart/templates/console.yaml | 18 +++ .../centaur-api-server/src/tool_discovery.rs | 71 ++++++++- .../centaur-iron-control/src/registry.rs | 71 ++++++++- .../api-rs/crates/centaur-perms/src/tests.rs | 35 +++++ .../api-rs/crates/centaur-perms/src/tools.rs | 33 ++++- .../crates/centaur-perms/src/translate.rs | 22 ++- ...00100_seed_github_app_broker_credential.rb | 38 +++++ .../console/lib/broker/credential_grants.rb | 129 ++++++++++++++++- .../v1/broker_credentials_controller_test.rb | 30 ++++ .../test/models/broker_credential_test.rb | 136 ++++++++++++++++++ 10 files changed, 577 insertions(+), 6 deletions(-) create mode 100644 services/console/db/migrate/20260904000100_seed_github_app_broker_credential.rb diff --git a/contrib/chart/templates/console.yaml b/contrib/chart/templates/console.yaml index 246b1c0910..2a5e86777b 100644 --- a/contrib/chart/templates/console.yaml +++ b/contrib/chart/templates/console.yaml @@ -212,6 +212,24 @@ spec: - name: CENTAUR_CONSOLE_SSO_EMAIL_DOMAINS value: {{ join "," . | quote }} {{- end }} + # GitHub App credentials used by the github_app secret source. The + # console mints short-lived installation tokens for per-sandbox + # proxies over /proxy/sync; raw tokens still never enter sandboxes. + - name: GITHUB_APP_ID + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sGITHUB_APP_ID" $prefix }} + - name: GITHUB_APP_INSTALLATION_ID + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sGITHUB_APP_INSTALLATION_ID" $prefix }} + - name: GITHUB_APP_PRIVATE_KEY_B64 + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sGITHUB_APP_PRIVATE_KEY_B64" $prefix }} {{- if $console.googleOauth.enabled }} # Google OAuth app credentials (sign-in + brokered token refresh). # Gated on console.googleOauth.enabled; the keys must exist in the diff --git a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs index 8dd06c8d28..8ff6150e72 100644 --- a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs +++ b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs @@ -610,6 +610,7 @@ enum ToolSecret { struct HttpSecret { name: String, secret_ref: String, + source: Option>, labels: BTreeMap, mode: HttpSecretMode, hosts: Vec, @@ -730,6 +731,7 @@ fn parse_secret( return Ok(ToolSecret::Http(HttpSecret { name: name.clone(), secret_ref: name.clone(), + source: None, labels: labels.clone(), mode: HttpSecretMode::Replace, hosts: default_hosts.to_vec(), @@ -776,6 +778,7 @@ fn parse_http_secret( default_hosts: &[String], labels: &BTreeMap, ) -> Result { + let source = optional_string_map(table.get("source"))?; let hosts = optional_string_array(table.get("hosts"))?.unwrap_or_else(|| default_hosts.to_vec()); if hosts.is_empty() || hosts.iter().any(String::is_empty) { @@ -801,6 +804,7 @@ fn parse_http_secret( Ok(ToolSecret::Http(HttpSecret { name, secret_ref, + source, labels: labels.clone(), mode: HttpSecretMode::Replace, hosts, @@ -836,6 +840,7 @@ fn parse_http_secret( Ok(ToolSecret::Http(HttpSecret { name, secret_ref, + source, labels: labels.clone(), mode: HttpSecretMode::Inject, hosts, @@ -1107,7 +1112,10 @@ fn http_secret_transform(secrets: &[ToolSecret]) -> Result, To let mut extra = BTreeMap::new(); let mut entry = Secret { id: Some(key.name.clone()), - source: Some(yaml_map([("placeholder", yaml_string(&key.secret_ref))])?), + source: Some(match key.source { + Some(source) => yaml_value(source)?, + None => yaml_map([("placeholder", yaml_string(&key.secret_ref))])?, + }), rules: host_rules(hosts)?, ..Default::default() }; @@ -1156,6 +1164,7 @@ fn http_secret_transform(secrets: &[ToolSecret]) -> Result, To struct HttpSecretKey { name: String, secret_ref: String, + source: Option>, mode: HttpSecretMode, replacer: String, match_headers: Vec, @@ -1171,6 +1180,7 @@ impl From<&HttpSecret> for HttpSecretKey { Self { name: secret.name.clone(), secret_ref: secret.secret_ref.clone(), + source: secret.source.clone(), mode: secret.mode.clone(), replacer: secret.replacer.clone(), match_headers: secret.match_headers.clone(), @@ -1551,6 +1561,41 @@ fn optional_string_array( Ok(Some(out)) } +fn optional_string_map( + value: Option<&TomlValue>, +) -> Result>, ToolDiscoveryError> { + let Some(value) = value else { + return Ok(None); + }; + let table = value + .as_table() + .ok_or_else(|| ToolDiscoveryError::Invalid("expected string map".to_owned()))?; + let mut out = BTreeMap::new(); + for (key, value) in table { + let Some(value) = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err(ToolDiscoveryError::Invalid(format!( + "source.{key} must be a non-empty string" + ))); + }; + out.insert(key.clone(), value.to_owned()); + } + if out.is_empty() { + return Err(ToolDiscoveryError::Invalid( + "source must contain at least one key".to_owned(), + )); + } + if !out.contains_key("type") { + return Err(ToolDiscoveryError::Invalid( + "source must include non-empty string key \"type\"".to_owned(), + )); + } + Ok(Some(out)) +} + fn optional_bool(table: &toml::Table, key: &str) -> Result, ToolDiscoveryError> { match table.get(key) { Some(value) => value @@ -1797,6 +1842,30 @@ secrets = [ let _ = fs::remove_dir_all(temp); } + #[test] + fn http_secret_source_table_is_preserved_in_proxy_fragment() { + let toml = r#" +[ + {type = "http", name = "GITHUB_TOKEN_BEARER", source = {type = "github_app", app_id_env = "GITHUB_APP_ID", installation_id_env = "GITHUB_APP_INSTALLATION_ID", private_key_b64_env = "GITHUB_APP_PRIVATE_KEY_B64"}, replacer = "Bearer GITHUB_TOKEN", match_headers = ["Authorization"], hosts = ["github.com"]} +] +"#; + let doc: TomlValue = toml::from_str(&format!("secrets = {}", toml.trim())).unwrap(); + let secrets = parse_secret_list(Some(&doc["secrets"]), &[], &BTreeMap::new()).unwrap(); + let transform = http_secret_transform(&secrets).unwrap().unwrap(); + let source = transform.config.secrets[0].source.as_ref().unwrap(); + + assert_eq!(source["type"].as_str(), Some("github_app")); + assert_eq!(source["app_id_env"].as_str(), Some("GITHUB_APP_ID")); + assert_eq!( + source["installation_id_env"].as_str(), + Some("GITHUB_APP_INSTALLATION_ID") + ); + assert_eq!( + source["private_key_b64_env"].as_str(), + Some("GITHUB_APP_PRIVATE_KEY_B64") + ); + } + #[test] fn discovers_personas_with_overlay_shadowing_and_default_validation() { let temp = temp_dir("api-rs-personas"); diff --git a/services/api-rs/crates/centaur-iron-control/src/registry.rs b/services/api-rs/crates/centaur-iron-control/src/registry.rs index 3d65b43476..d46628ee24 100644 --- a/services/api-rs/crates/centaur-iron-control/src/registry.rs +++ b/services/api-rs/crates/centaur-iron-control/src/registry.rs @@ -344,6 +344,30 @@ fn token_broker_source( Ok(Some(SecretSource::token_broker(credential_id))) } +/// Pass through a typed secret source (any `source` table with a `type`, e.g. +/// `github_app`): `type` becomes the `source_type` and the remaining string +/// fields become `config`. Mirrors `centaur_perms::translate::http_source`. +/// Returns `None` when `source` has no `type` key. +fn typed_source(source: &YamlValue) -> Option { + let source_type = yaml_str(source, "type")?.to_owned(); + let mut config = serde_json::Map::new(); + if let Some(mapping) = source.as_mapping() { + for (key, value) in mapping { + let (Some(key), Some(value)) = (key.as_str(), value.as_str()) else { + continue; + }; + if key != "type" { + config.insert(key.to_owned(), serde_json::Value::String(value.to_owned())); + } + } + } + Some(SecretSource { + source_type, + secret: None, + config: serde_json::Value::Object(config), + }) +} + fn source_from_secret( role: &str, secret: &Secret, @@ -360,9 +384,17 @@ fn source_from_secret( yaml_str(source, "json_key"), )); } + // A typed source (e.g. `type = "github_app"`): pass it through verbatim so + // iron-control's resolver handles it. Mirrors + // `centaur_perms::translate::http_source`, so the api-rs startup reconcile + // accepts the same tool-secret shapes the perms CLI registers (without + // this, a `github_app` source crashes api-rs at startup). + if let Some(typed) = typed_source(source) { + return Ok(typed); + } return Err(malformed( role, - "secret source must be a placeholder or token_broker reference", + "secret source must be a placeholder, token_broker, or typed (type=...) reference", )); } if let Some(proxy_value) = replace_proxy_value(secret) { @@ -1021,6 +1053,43 @@ transforms: assert!(input.replace_config.is_none()); } + #[test] + fn translates_typed_github_app_secret() { + // Regression: a `github_app` typed source must register (passed through to + // the resolver) rather than crash the api-rs startup reconcile. + let fragment = load_fragment_str( + r#" +transforms: + - name: secrets + config: + secrets: + - name: GITHUB_TOKEN_BEARER + source: + type: github_app + app_id_env: GITHUB_APP_ID + installation_id_env: GITHUB_APP_INSTALLATION_ID + private_key_b64_env: GITHUB_APP_PRIVATE_KEY_B64 + replace: { proxy_value: "Bearer GITHUB_TOKEN", match_headers: [Authorization] } + rules: [{ host: github.com }] +"#, + ) + .unwrap(); + let inputs = + secret_inputs_from_fragment("default", "infra", &fragment, &env_policy()).unwrap(); + let SecretInput::Static(input) = &inputs[0] else { + panic!("expected a static secret"); + }; + assert_eq!(input.source.source_type, "github_app"); + assert_eq!( + input.source.config, + json!({ + "app_id_env": "GITHUB_APP_ID", + "installation_id_env": "GITHUB_APP_INSTALLATION_ID", + "private_key_b64_env": "GITHUB_APP_PRIVATE_KEY_B64", + }) + ); + } + #[test] fn placeholder_inject_secret_derives_source() { let fragment = load_fragment_str( diff --git a/services/api-rs/crates/centaur-perms/src/tests.rs b/services/api-rs/crates/centaur-perms/src/tests.rs index a597730694..d9738a80f8 100644 --- a/services/api-rs/crates/centaur-perms/src/tests.rs +++ b/services/api-rs/crates/centaur-perms/src/tests.rs @@ -472,6 +472,41 @@ fn translates_http_replace_to_static_input() { assert_eq!(input.rules[0].host.as_deref(), Some("slack.com")); } +#[test] +fn translates_http_source_table_to_static_input() { + let secrets = vec![ + tools::parse_secret( + &entry(r#"{type = "http", name = "GITHUB_TOKEN_BEARER", source = {type = "github_app", app_id_env = "GITHUB_APP_ID", installation_id_env = "GITHUB_APP_INSTALLATION_ID", private_key_b64_env = "GITHUB_APP_PRIVATE_KEY_B64"}, replacer = "Bearer GITHUB_TOKEN", match_headers = ["Authorization"], hosts = ["github.com"]}"#), + &[], + ) + .unwrap(), + ]; + let ParsedSecret::Http(http) = &secrets[0] else { + panic!("expected http") + }; + assert_eq!( + http.source + .as_ref() + .and_then(|s| s.get("type")) + .map(String::as_str), + Some("github_app") + ); + + let out = translate::translate("tool-github", &secrets, &SourcePolicy::env()); + let SecretInput::Static(input) = &out.inputs[0] else { + panic!("expected static") + }; + assert_eq!(input.source.source_type, "github_app"); + assert_eq!( + input.source.config, + serde_json::json!({ + "app_id_env": "GITHUB_APP_ID", + "installation_id_env": "GITHUB_APP_INSTALLATION_ID", + "private_key_b64_env": "GITHUB_APP_PRIVATE_KEY_B64", + }) + ); +} + #[test] fn translates_gcp_auth_defaults_scopes_when_unset() { let secrets = vec![ diff --git a/services/api-rs/crates/centaur-perms/src/tools.rs b/services/api-rs/crates/centaur-perms/src/tools.rs index 33073d34ff..ec6273039c 100644 --- a/services/api-rs/crates/centaur-perms/src/tools.rs +++ b/services/api-rs/crates/centaur-perms/src/tools.rs @@ -8,7 +8,10 @@ //! into iron-control inputs. Only the secret *schema* is reimplemented here; //! the API's loader stays the source of truth for runtime tool loading. -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use centaur_iron_control::{GCP_ID_TOKEN_ALLOWED_HEADERS, normalize_gcp_id_token_header}; use centaur_iron_proxy::{PgDsnSetting, PgDsnSettingValueFrom}; @@ -91,6 +94,7 @@ pub struct FieldSource { pub struct HttpSecret { pub name: String, pub secret_ref: String, + pub source: Option>, pub mode: SecretMode, pub hosts: Vec, // replace mode @@ -426,6 +430,7 @@ pub fn parse_secret(entry: &Value, default_hosts: &[String]) -> Result Result { + let source = string_map(table.get("source"))?; let mode = match table .get("mode") .and_then(Value::as_str) @@ -536,6 +542,7 @@ fn parse_http( Ok(HttpSecret { name: name.to_owned(), secret_ref: secret_ref.to_owned(), + source, mode, hosts, replacer, @@ -572,6 +579,7 @@ fn parse_http( Ok(HttpSecret { name: name.to_owned(), secret_ref: secret_ref.to_owned(), + source, mode, hosts, replacer: String::new(), @@ -1065,6 +1073,29 @@ fn non_empty_str_array(value: Option<&Value>) -> Option> { Some(out) } +fn string_map(value: Option<&Value>) -> Result>> { + let Some(value) = value else { + return Ok(None); + }; + let table = value + .as_table() + .ok_or_else(|| eyre!("source must be a table of strings"))?; + let mut out = BTreeMap::new(); + for (key, value) in table { + let Some(value) = value.as_str().filter(|s| !s.is_empty()) else { + bail!("source.{key} must be a non-empty string"); + }; + out.insert(key.clone(), value.to_owned()); + } + if out.is_empty() { + bail!("source must contain at least one key"); + } + if !out.contains_key("type") { + bail!("source must include non-empty string key \"type\""); + } + Ok(Some(out)) +} + fn validate_gcp_id_token_header(value: String) -> Result { normalize_gcp_id_token_header(&value).ok_or_else(|| { eyre!( diff --git a/services/api-rs/crates/centaur-perms/src/translate.rs b/services/api-rs/crates/centaur-perms/src/translate.rs index 4554daa285..6737476c37 100644 --- a/services/api-rs/crates/centaur-perms/src/translate.rs +++ b/services/api-rs/crates/centaur-perms/src/translate.rs @@ -18,6 +18,7 @@ use centaur_iron_control::{ }; use centaur_iron_proxy::SourcePolicy; use centaur_iron_proxy::{PgDsnSetting, PgDsnSettingValueFrom}; +use serde_json::json; use crate::tools::{ AwsAuthSecret, BrokerTokenSecret, FieldSource, GcpAuthSecret, GcpIdTokenSecret, HmacSignSecret, @@ -183,11 +184,30 @@ fn static_input( labels: labels.clone(), inject_config, replace_config, - source: source_from_placeholder(policy, &http.secret_ref, None), + source: http_source(http, policy), rules: rules_from_hosts(&http.hosts), } } +fn http_source(http: &HttpSecret, policy: &SourcePolicy) -> SecretSource { + if let Some(source) = &http.source + && let Some(source_type) = source.get("type") + { + let mut config = serde_json::Map::new(); + for (key, value) in source { + if key != "type" { + config.insert(key.clone(), json!(value)); + } + } + return SecretSource { + source_type: source_type.clone(), + secret: None, + config: serde_json::Value::Object(config), + }; + } + source_from_placeholder(policy, &http.secret_ref, None) +} + fn oauth_input( role: &str, oauth: &OAuthTokenSecret, diff --git a/services/console/db/migrate/20260904000100_seed_github_app_broker_credential.rb b/services/console/db/migrate/20260904000100_seed_github_app_broker_credential.rb new file mode 100644 index 0000000000..9cf9c91867 --- /dev/null +++ b/services/console/db/migrate/20260904000100_seed_github_app_broker_credential.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Versioned after upstream migrations; `up` remains idempotent for deployments +# that already applied the earlier fork-local version of this seed. + +# Provision the GitHub App broker credential from the deployment's GITHUB_APP_* +# env (the same values the old inline `github_app` secret source read) so the +# `github_auth_headers` tool's `token_broker` source can resolve it and the +# broker mints/rotates the installation token out-of-band. +# +# Idempotent (find_or_create by namespace+foreign_id) and a no-op where the env +# is unset (dev/test). Uses the model so client_secret is stored encrypted; a +# failure is logged rather than raised so it never breaks a console deploy. +class SeedGithubAppBrokerCredential < ActiveRecord::Migration[8.1] + def up + app_id = ENV["GITHUB_APP_ID"].to_s + installation_id = ENV["GITHUB_APP_INSTALLATION_ID"].to_s + private_key_b64 = ENV["GITHUB_APP_PRIVATE_KEY_B64"].to_s + if app_id.blank? || installation_id.blank? || private_key_b64.blank? + say "GITHUB_APP_* env not set; skipping github_app broker credential seed" + return + end + + BrokerCredential.find_or_create_by!(namespace: "default", foreign_id: "github-app") do |c| + c.grant = "github_app" + c.client_id = app_id + c.token_endpoint = "https://api.github.com/app/installations/#{installation_id}/access_tokens" + c.client_secret = private_key_b64 + end + say "seeded github_app broker credential (namespace=default foreign_id=github-app)" + rescue StandardError => e + say "WARNING: github_app broker credential seed failed: #{e.class}: #{e.message}" + end + + def down + BrokerCredential.where(namespace: "default", foreign_id: "github-app", grant: "github_app").destroy_all + end +end diff --git a/services/console/lib/broker/credential_grants.rb b/services/console/lib/broker/credential_grants.rb index 8a389fd3c7..0865f0c869 100644 --- a/services/console/lib/broker/credential_grants.rb +++ b/services/console/lib/broker/credential_grants.rb @@ -3,15 +3,24 @@ module Broker # owns persistence and scheduling; these strategies own provider-specific # request shapes and bootstrap validation. module CredentialGrants + require "base64" + require "json" + require "net/http" + require "openssl" + require "time" + require "uri" + PREQIN_TOKEN_ENDPOINT = "https://api.preqin.com/connect/token".freeze PREQIN_REFRESH_TOKEN_ENDPOINT = "https://api.preqin.com/connect/refresh_token".freeze - GRANTS = %w[refresh_token client_credentials password preqin].freeze - REFRESHABLE_WITHOUT_TOKEN_GRANTS = %w[client_credentials password preqin].freeze + GRANTS = %w[refresh_token client_credentials password preqin github_app].freeze + REFRESHABLE_WITHOUT_TOKEN_GRANTS = %w[client_credentials password preqin github_app].freeze Outcome = Data.define(:result, :clear_refresh_token, :dead_reason) class << self + attr_writer :github_app_http_client + def default_token_endpoint(grant) PREQIN_TOKEN_ENDPOINT if grant == "preqin" end @@ -28,6 +37,8 @@ def validate(credential) validate_password(credential) when "preqin" validate_preqin(credential) + when "github_app" + validate_github_app(credential) end end @@ -39,6 +50,8 @@ def refresh(credential) refresh_password(credential) when "preqin" refresh_preqin(credential) + when "github_app" + refresh_github_app(credential) else refresh_token(credential) end @@ -129,6 +142,63 @@ def refresh_preqin(credential) success(result, clear_refresh_token: clear_stale_refresh_token && result.refresh_token.blank?) end + def refresh_github_app(credential) + now = Time.current + require_value!("client_id", credential.client_id) + require_value!("client_secret", credential.client_secret) + require_value!("token_endpoint", credential.token_endpoint) + + uri = URI.parse(credential.token_endpoint) + request = Net::HTTP::Post.new(uri) + request["Authorization"] = "Bearer #{github_app_jwt(credential, now)}" + request["Accept"] = "application/vnd.github+json" + request["X-GitHub-Api-Version"] = "2022-11-28" + + response = github_app_http_request(uri, request, credential.refresh_timeout_seconds) + unless response.is_a?(Net::HTTPSuccess) + raise github_app_http_error(response) + end + + parsed = JSON.parse(response.body) + token = parsed["token"].to_s + raise Broker::RefreshError.new("GitHub App token response missing token", stage: "parse", retryable: true) if token.blank? + + expires_at = Time.iso8601(parsed.fetch("expires_at")) + expires_in = [ (expires_at - now).to_i, 1 ].max + result = Broker::RefreshClient::Result.new( + access_token: token, + refresh_token: nil, + expires_in: expires_in + ) + success(result, clear_refresh_token: true) + rescue JSON::ParserError, KeyError, ArgumentError, TypeError => e + raise Broker::RefreshError.new( + "GitHub App token response could not be parsed: #{e.class}", + stage: "parse", + retryable: true + ) + rescue OpenSSL::PKey::PKeyError => e + raise Broker::RefreshError.new( + "GitHub App private key is invalid: #{e.class}", + stage: "config", + code: "invalid_private_key", + retryable: false + ) + rescue URI::InvalidURIError => e + raise Broker::RefreshError.new( + "GitHub App token endpoint is invalid: #{e.class}", + stage: "config", + code: "invalid_token_endpoint", + retryable: false + ) + rescue IOError, SystemCallError, Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e + raise Broker::RefreshError.new( + "GitHub App token endpoint request failed: #{e.class}", + stage: "network", + retryable: true + ) + end + def oauth_refresh_token(credential) post_token_form( credential, @@ -202,6 +272,55 @@ def preqin_refresh_token_form(credential) { "refresh_token" => credential.refresh_token } end + def github_app_jwt(credential, now) + header = base64url(JSON.generate({ alg: "RS256", typ: "JWT" })) + payload = base64url(JSON.generate({ + iat: now.to_i - 60, + exp: now.to_i + 9.minutes.to_i, + iss: credential.client_id + })) + signing_input = "#{header}.#{payload}" + key = OpenSSL::PKey::RSA.new(github_app_private_key_pem(credential.client_secret)) + signature = key.sign(OpenSSL::Digest.new("SHA256"), signing_input) + "#{signing_input}.#{base64url(signature)}" + end + + def github_app_private_key_pem(value) + text = value.to_s + return text if text.include?("-----BEGIN") + + Base64.strict_decode64(text) + rescue ArgumentError + text + end + + def github_app_http_request(uri, request, timeout) + if @github_app_http_client + return @github_app_http_client.call(uri, request) + end + + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", + open_timeout: timeout, read_timeout: timeout) do |http| + http.request(request) + end + end + + def github_app_http_error(response) + status = response.code.to_i + retryable = status / 100 == 5 || status == 429 + Broker::RefreshError.new( + "GitHub App installation token endpoint returned HTTP #{status}", + stage: "http", + code: "http_#{status}", + status: status, + retryable: retryable + ) + end + + def base64url(value) + Base64.urlsafe_encode64(value, padding: false) + end + def add_oauth_optional_fields(form, credential) form["client_secret"] = credential.effective_client_secret if credential.effective_client_secret.present? @@ -230,6 +349,12 @@ def validate_preqin(credential) credential.errors.add(:api_key, "can't be blank for the Preqin broker grant") if credential.api_key.blank? end + def validate_github_app(credential) + if credential.client_secret.blank? + credential.errors.add(:client_secret, "can't be blank for the GitHub App broker grant") + end + end + def password_values_present?(credential) credential.username.present? && credential.password.present? end diff --git a/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb b/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb index 629da94a31..c719b81787 100644 --- a/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb +++ b/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb @@ -174,6 +174,36 @@ def json_body = JSON.parse(response.body) assert created.next_attempt_at.present? end + test "create github_app grant stores private key and redacts it" do + private_key = OpenSSL::PKey::RSA.generate(2048).to_pem + body = { + data: { + foreign_id: "github-app", + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: private_key + } + } + + assert_difference -> { BrokerCredential.count } => 1 do + post api_v1_broker_credentials_url, params: body.to_json, headers: auth_headers + end + assert_response :created + data = json_body.fetch("data") + assert_equal "github_app", data["grant"] + assert_equal "123", data["client_id"] + refute data.key?("client_secret") + refute data.key?("access_token") + + created = BrokerCredential.find_by_oid(data["id"]) + assert_equal private_key, created.client_secret + # A freshly-created github_app credential must be due for its first mint. + # next_attempt_at is left nil on create here, which the refreshable scope + # treats as immediately due (next_attempt_at IS NULL). + assert BrokerCredential.refreshable.exists?(id: created.id) + end + test "create rejects a missing client_id" do body = { data: { diff --git a/services/console/test/models/broker_credential_test.rb b/services/console/test/models/broker_credential_test.rb index 2cb99ae89a..42721f5b69 100644 --- a/services/console/test/models/broker_credential_test.rb +++ b/services/console/test/models/broker_credential_test.rb @@ -41,6 +41,26 @@ def create_credential(**kw) bc end + def github_private_key + @github_private_key ||= OpenSSL::PKey::RSA.generate(2048) + end + + def github_app_response(token: "ghs_installation", expires_at: 1.hour.from_now) + response = Net::HTTPOK.new("1.1", "200", "OK") + response.instance_variable_set( + :@body, + JSON.generate({ "token" => token, "expires_at" => expires_at.iso8601 }) + ) + # Mark the body as already read; otherwise Net::HTTPResponse#body tries to + # read from a (nil) socket and raises "attempt to read body out of block". + response.instance_variable_set(:@read, true) + response + end + + teardown do + Broker::CredentialGrants.github_app_http_client = nil + end + # --- validations ---------------------------------------------------------- test "valid with a client_id" do @@ -100,6 +120,29 @@ def create_credential(**kw) assert bc.errors[:api_key].any? { |m| m.include?("Preqin broker grant") } end + test "github_app grant is valid with app id, private key, and installation token endpoint" do + bc = build_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: github_private_key.to_pem, + refresh_token: nil + ) + assert bc.valid?, bc.errors.full_messages.to_sentence + end + + test "github_app grant requires a private key" do + bc = build_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: nil, + refresh_token: nil + ) + refute bc.valid? + assert bc.errors[:client_secret].any? { |m| m.include?("GitHub App broker grant") } + end + # --- oauth_app provenance (flow-minted credentials) ----------------------- def build_app(**overrides) @@ -454,6 +497,86 @@ def build_app(**overrides) assert_equal "AT", bc.access_token end + test "github_app grant mints and stores an installation access token" do + captured = {} + expires_at = 1.hour.from_now + Broker::CredentialGrants.github_app_http_client = ->(uri, request) { + captured = { uri: uri, request: request } + github_app_response(token: "ghs_installation", expires_at: expires_at) + } + now = Time.current + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: github_private_key.to_pem, + refresh_token: nil + ) + + bc.refresh!(now: now) + bc.reload + + assert_equal "ghs_installation", bc.access_token + assert_nil bc.refresh_token + assert_in_delta expires_at.to_f, bc.expires_at.to_f, 2 + assert_equal "https://api.github.com/app/installations/456/access_tokens", captured[:uri].to_s + assert_match(/\ABearer /, captured[:request]["Authorization"]) + assert_equal "application/vnd.github+json", captured[:request]["Accept"] + end + + test "github_app grant output is delivered through existing token_broker sources" do + Broker::CredentialGrants.github_app_http_client = ->(_uri, _request) { + github_app_response(token: "ghs_brokered", expires_at: 1.hour.from_now) + } + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: github_private_key.to_pem, + refresh_token: nil + ) + bc.refresh! + + source = SecretSource.new(source_type: "token_broker", config: { "credential_id" => bc.oid }) + + assert_equal({ "type" => "control_plane", "value" => "ghs_brokered" }, source.to_proxy_source) + assert source.deliverable? + end + + test "github_app grant accepts a base64 encoded private key" do + Broker::CredentialGrants.github_app_http_client = ->(_uri, _request) { + github_app_response(token: "ghs_encoded", expires_at: 1.hour.from_now) + } + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: Base64.strict_encode64(github_private_key.to_pem), + refresh_token: nil + ) + + bc.refresh! + bc.reload + + assert_equal "ghs_encoded", bc.access_token + end + + test "github_app grant marks invalid private keys dead" do + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: "not-a-private-key", + refresh_token: nil + ) + + bc.refresh! + bc.reload + + assert bc.dead? + assert_equal "invalid_private_key", bc.dead_reason + end + test "preqin grant prefers the Preqin refresh endpoint when it has a refresh token" do client = Minitest::Mock.new expect_refresh(client, returns: result(access_token: "AT", refresh_token: nil)) do |request| @@ -565,6 +688,19 @@ def build_app(**overrides) assert_includes BrokerCredential.refreshable.pluck(:id), bc.id end + test "refreshable includes github_app credentials without a refresh_token" do + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: github_private_key.to_pem, + refresh_token: nil + ) + bc.update_columns(last_refresh: 1.hour.ago, next_attempt_at: 1.minute.ago) + + assert_includes BrokerCredential.refreshable.pluck(:id), bc.id + end + # --- delete guard --------------------------------------------------------- test "cannot be destroyed while a token_broker source references it" do From 6511e38f71a1f07c9d636186df664e2b78c506f2 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:26:01 +0000 Subject: [PATCH 3/8] Allow ambient Slack thread replies (cherry picked from commit 3ccdd798ab6707436a2059eecf4eebc2b7c91439) --- services/slackbotv2/AGENTS.md | 11 +-- services/slackbotv2/src/index.ts | 11 +-- .../slackbotv2/test/chat-sdk-emulate.test.ts | 77 ++++++++++++++----- 3 files changed, 63 insertions(+), 36 deletions(-) diff --git a/services/slackbotv2/AGENTS.md b/services/slackbotv2/AGENTS.md index d25d93b463..f07a942111 100644 --- a/services/slackbotv2/AGENTS.md +++ b/services/slackbotv2/AGENTS.md @@ -30,11 +30,12 @@ lifecycle, harness formatting, and durable execution state belong in `api-rs`. message, block, attachment, and rate limits, including fallback text. - Avoid serializing raw webhook bodies on the hot path or in normal logs. Never log bot tokens, signing secrets, private file URLs, or user file contents. -- Preserve mentioned stop commands, harness/model overrides, late-file repair, - initial thread context, and mention-gated subscribed-message semantics when - refactoring the main callback flow. Unmentioned replies must not be appended - to or interrupt an active execution; collect them when the next mention - refreshes the Slack thread context. +- Preserve stop commands, harness/model overrides, late-file repair, initial + thread context, and ambient subscribed-message semantics when refactoring the + main callback flow. Every allowed reply in a subscribed thread executes (or + durably appends while an execution is active) without requiring a mention; + new top-level messages still require one. Bot-authored messages must stay + excluded outside the trigger-bot allowlist so bots cannot loop. ## Validation diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index ae0b751e17..874c8862b1 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -420,18 +420,9 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { chat.onSubscribedMessage(async (thread, message) => { if (!(await isAllowedSlackMessage(message, options, logger))) return if (slackRichTextMentionsUser(message.raw, options.botUserId)) message.isMention = true - if (message.isMention !== true) { - traceLog( - options, - 'slackbotv2_subscribed_message_without_mention_ignored', - createHandoffTrace(thread, message, 'append'), - { trigger: 'subscribed_message' } - ) - return - } lateSlackFiles.rememberFilelessMention(thread, message) await handleSlackMessageHandoff(thread, message, { - assistantStatusRequested: true, + assistantStatusRequested: message.isMention === true, mode: 'execute', options, state, diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 0fdde23fb3..f17bc94710 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -436,7 +436,7 @@ describe('slackbotv2', () => { expect(codexApi.workflowEvents).toHaveLength(1) }) - it('collects ignored subscribed messages when the bot is next mentioned', async () => { + it('executes unmentioned subscribed messages as ambient thread replies', async () => { const parent = await postUserMessage('The deploy context is above.') const firstMention = await postUserMessage( `<@${BOT_USER_ID}> run with this screenshot`, @@ -498,8 +498,8 @@ describe('slackbotv2', () => { expect(followUpResponse.status).toBe(200) await Promise.all(followUpWaits) - expect(codexApi.appends).toHaveLength(1) - expect(codexApi.executes).toHaveLength(1) + expect(codexApi.appends).toHaveLength(2) + expect(codexApi.executes).toHaveLength(2) const secondMention = await postUserMessage(`<@${BOT_USER_ID}> now execute with the latest`, parent.ts) const secondMentionWaits: Promise[] = [] @@ -524,12 +524,13 @@ describe('slackbotv2', () => { expect(secondMentionResponse.status).toBe(200) await Promise.all(secondMentionWaits) - expect(codexApi.appends).toHaveLength(2) + expect(codexApi.appends).toHaveLength(3) expect(codexApi.creates.map(create => create.threadKey)).toEqual([ + threadKey(parent.ts), threadKey(parent.ts), threadKey(parent.ts) ]) - expect(codexApi.executes).toHaveLength(2) + expect(codexApi.executes).toHaveLength(3) const firstAppend = codexApi.appends[0]! expect(firstAppend.threadKey).toBe(threadKey(parent.ts)) @@ -582,26 +583,36 @@ describe('slackbotv2', () => { ) expect(JSON.stringify(firstInputLine)).not.toContain('data:image/png;base64') - const secondMentionAppend = codexApi.appends[1]! + const followUpAppend = codexApi.appends[1]! + expect(followUpAppend.threadKey).toBe(threadKey(parent.ts)) + expect(followUpAppend.body.messages.map(message => message.client_message_id)).toEqual([ + followUp.ts + ]) + expect(sessionMessageTexts(followUpAppend.body.messages)).toEqual([ + 'Additional detail for the subscribed thread.' + ]) + const followUpExecute = codexApi.executes[1]! + expect(followUpExecute.body.idempotency_key).toBe(followUp.ts) + expect(JSON.stringify(JSON.parse(followUpExecute.body.input_lines[0]!))).toContain( + 'Additional detail for the subscribed thread.' + ) + + const secondMentionAppend = codexApi.appends[2]! expect(secondMentionAppend.threadKey).toBe(threadKey(parent.ts)) expect(secondMentionAppend.body.messages.map(message => message.client_message_id)).toEqual([ - followUp.ts, secondMention.ts ]) - expect(sessionMessageTexts(secondMentionAppend.body.messages)[0]).toBe( - 'Additional detail for the subscribed thread.' - ) - expect(sessionMessageTexts(secondMentionAppend.body.messages)[1]).toContain( + expect(sessionMessageTexts(secondMentionAppend.body.messages)[0]).toContain( 'now execute with the latest' ) - const secondExecute = codexApi.executes[1]! + const secondExecute = codexApi.executes[2]! expect(secondExecute.body.idempotency_key).toBe(secondMention.ts) expect(JSON.stringify(JSON.parse(secondExecute.body.input_lines[0]!))).toContain( 'now execute with the latest' ) expectSlackPlanStreamShape(slackApi.calls, { - answers: ['Executed request 1.', 'Executed request 2.'], + answers: ['Executed request 1.', 'Executed request 2.', 'Executed request 3.'], parentTs: parent.ts }) const assistantStatuses = slackApi.calls @@ -609,8 +620,8 @@ describe('slackbotv2', () => { .map(call => stringField(call.body.status)) expect(assistantStatuses[0]).toBe('Thinking...') expect(assistantStatuses.at(-1)).toBe('') - expect(assistantStatuses.filter(status => status === 'Thinking...').length).toBeGreaterThanOrEqual(2) - expect(assistantStatuses.filter(status => status === '').length).toBeGreaterThanOrEqual(2) + expect(assistantStatuses.filter(status => status === 'Thinking...').length).toBeGreaterThanOrEqual(3) + expect(assistantStatuses.filter(status => status === '').length).toBeGreaterThanOrEqual(3) expect( slackApi.calls .filter(call => call.method === 'assistant.threads.setTitle') @@ -618,8 +629,10 @@ describe('slackbotv2', () => { ).toEqual([ 'run with this screenshot', 'Codex request 1', + 'Additional detail for the subscribed thread.', + 'Codex request 2', 'now execute with the latest', - 'Codex request 2' + 'Codex request 3' ]) const text = await threadText(parent.ts) @@ -633,13 +646,15 @@ describe('slackbotv2', () => { expect(text).not.toContain('tests passed') expect(text).toContain('Executed request 1.') expect(text).toContain('Executed request 2.') + expect(text).toContain('Executed request 3.') const renderedReplies = (await threadTexts(parent.ts)).filter(reply => reply.includes('Executed request') ) - expect(renderedReplies).toHaveLength(2) + expect(renderedReplies).toHaveLength(3) expectSlackRenderedReply(renderedReplies[0]!, 'Executed request 1.') expectSlackRenderedReply(renderedReplies[1]!, 'Executed request 2.') + expectSlackRenderedReply(renderedReplies[2]!, 'Executed request 3.') }) // The paragraph break (`\n\n`) after the model value is deliberate: the @@ -2388,7 +2403,7 @@ describe('slackbotv2', () => { ) }) - it('ignores unmentioned subscribed messages during a stream, including stop', async () => { + it('appends unmentioned subscribed messages during a stream and honors an unmentioned stop', async () => { codexApi.autoRespond = false const parent = await postUserMessage('Context before the long run.') @@ -2438,6 +2453,10 @@ describe('slackbotv2', () => { expect(followUpResponse.status).toBe(200) await Promise.all(followUpWaits) + // The unmentioned reply is durably appended while the stream is active, but + // does not start a second execution. + await waitFor(() => codexApi.appends.length === 2) + expect(codexApi.executes).toHaveLength(1) const stop = await postUserMessage('stop', parent.ts) const stopWaits: Promise[] = [] @@ -2461,8 +2480,12 @@ describe('slackbotv2', () => { expect(stopResponse.status).toBe(200) await Promise.all(stopWaits) - expect(codexApi.creates).toHaveLength(1) - expect(codexApi.appends).toHaveLength(1) + // The unmentioned stop interrupts the active execution instead of being + // appended or executed. + expect(codexApi.interrupts).toHaveLength(1) + expect(codexApi.interrupts[0]!.threadKey).toBe(threadKey(parent.ts)) + expect(codexApi.interrupts[0]!.body.reason).toContain(USER_ID) + expect(codexApi.appends).toHaveLength(2) expect(codexApi.executes).toHaveLength(1) codexApi.closeStreams() @@ -5956,6 +5979,7 @@ type MockSessionApi = { close(): Promise closeStreams(): void creates: MockSessionRequest[] + interrupts: MockSessionRequest<{ reason: string }>[] emitOutputLine(threadKey: string, line: string, executionId?: string): void emitOutputLines(threadKey: string, lines: string[], executionId?: string): void emitSessionEvent(threadKey: string, event: string, data: unknown, executionId?: string): void @@ -5979,6 +6003,7 @@ async function startMockCodexApi(): Promise { const eventRequests: MockSessionEventRequest[] = [] const events: MockSessionEvent[] = [] const executes: MockSessionRequest[] = [] + const interrupts: MockSessionRequest<{ reason: string }>[] = [] const idempotentExecutions = new Map() const streams = new Set() const workflowEvents: MockWorkflowEventRequest[] = [] @@ -6002,6 +6027,7 @@ async function startMockCodexApi(): Promise { events, eventRequests, executes, + interrupts, get autoRespond() { return autoRespond }, @@ -6046,6 +6072,7 @@ async function startMockCodexApi(): Promise { creates, eventRequests, executes, + interrupts, reset() { appends.length = 0 createResponses.length = 0 @@ -6053,6 +6080,7 @@ async function startMockCodexApi(): Promise { eventRequests.length = 0 events.length = 0 executes.length = 0 + interrupts.length = 0 idempotentExecutions.clear() executeHoldRelease?.() executeHold = null @@ -6155,6 +6183,7 @@ async function handleMockCodexRequest( eventRequests: MockSessionEventRequest[] executeHold: Promise | null executes: MockSessionRequest[] + interrupts: MockSessionRequest<{ reason: string }>[] failNextExecuteAfterAccept: boolean failNextEvents: boolean failNextExecute: boolean @@ -6175,7 +6204,7 @@ async function handleMockCodexRequest( await sendWebResponse(res, Response.json({ ok: true })) return } - const match = /^\/api\/session\/([^/]+)(?:\/(messages|execute|events))?$/.exec(url.pathname) + const match = /^\/api\/session\/([^/]+)(?:\/(messages|execute|events|interrupt))?$/.exec(url.pathname) if (!match?.[1]) { await sendWebResponse(res, new Response('not found', { status: 404 })) return @@ -6241,6 +6270,12 @@ async function handleMockCodexRequest( } const request = await nodeRequestToWebRequest(req, url) + if (endpoint === 'interrupt') { + const body = (await request.json()) as { reason: string } + input.interrupts.push({ threadKey, body }) + await sendWebResponse(res, Response.json({ execution_id: 'exe-interrupted', interrupted: true })) + return + } if (endpoint === 'messages') { const body = (await request.json()) as SlackbotV2AppendMessagesRequest input.appends.push({ threadKey, body }) From 627be0786b32a05111013b065921d394f36ccbde Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 10 Aug 2026 21:47:41 +0800 Subject: [PATCH 4/8] ci: pin gh release creation to the fork repo The tag-fetch step adds an upstream remote to the checkout, so gh resolved the base repo to paradigmxyz/centaur and the release step failed with "tag has not been pushed" (c7e-0.1.110-2 needed a manual gh release create). GH_REPO pins resolution to this repository. Co-Authored-By: Claude Fable 5 --- .github/workflows/tag-release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 3168699c8a..008c726aab 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -160,6 +160,10 @@ jobs: if: ${{ !inputs.dry_run && inputs.create_release }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pin gh to this repo: the checkout has an `upstream` remote (added by + # the tag-fetch step), and gh otherwise resolves the base repo to + # paradigmxyz/centaur and fails with "tag ... has not been pushed". + GH_REPO: ${{ github.repository }} TAG: ${{ steps.version.outputs.tag }} PREV: ${{ steps.version.outputs.prev }} run: | From 5b81bc06ba67686ef9febdbbeea2d838edfc7380 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Wed, 12 Aug 2026 05:43:44 -0400 Subject: [PATCH 5/8] feat(slackbotv2): Slack file-event -> workflow-run trigger (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chat SDK's event dispatch drops file events (file_shared, file_change) before any callback fires, so nothing today can react to a file being created or edited. Add a raw-body sidecar (the channel_created / lateSlackFiles pattern) that, when enabled, creates a durable workflow run via POST /api/workflows/runs for matching file events. Deployment-neutral and default-off: the target workflow name, event types, channel allowlist, and files.info title-keyword filter all come from env config (SLACKBOTV2_FILE_EVENT_*). The bot stays timer-free — any settle/debounce behavior belongs in the target workflow, which can sleep durably. Per-edit event storms collapse at spawn time via the idempotency key slack-file-event:{workflow}:{file_id} (optionally time-bucketed); the dispatch task never throws into the webhook path. Co-authored-by: Claude Fable 5 --- docs/pages/deploying-in-production.mdx | 5 +- docs/pages/reference/configuration.mdx | 5 + .../crates/centaur-api-server/src/auth.rs | 28 ++ services/slackbotv2/src/file-event-trigger.ts | 185 ++++++++++++ services/slackbotv2/src/index.ts | 7 + services/slackbotv2/src/server.ts | 18 ++ services/slackbotv2/src/session-api.ts | 40 +++ services/slackbotv2/src/types.ts | 31 ++ .../test/file-event-trigger.test.ts | 272 ++++++++++++++++++ 9 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 services/slackbotv2/src/file-event-trigger.ts create mode 100644 services/slackbotv2/test/file-event-trigger.test.ts diff --git a/docs/pages/deploying-in-production.mdx b/docs/pages/deploying-in-production.mdx index 7d91c4c065..f1a1f0bd95 100644 --- a/docs/pages/deploying-in-production.mdx +++ b/docs/pages/deploying-in-production.mdx @@ -337,7 +337,10 @@ Use the app page to install the bot, copy the Bot User OAuth Token for 7. Subscribe to `app_mention` and to the message events you want Centaur to see: `message.channels`, `message.groups`, and `message.im`. To automatically join newly-created public channels, set `slackbotv2.autoJoinCreatedChannels` to - `true` and subscribe to `channel_created`. + `true` and subscribe to `channel_created`. When the file-event workflow + trigger is enabled (`SLACKBOTV2_FILE_EVENT_WORKFLOW_NAME`), also subscribe + to the file events it matches — `file_shared` and `file_change` by default — + and grant `files:read` if the title-keyword filter is configured. 8. Enable Interactivity and set its Request URL to the same `https:///api/webhooks/slack` URL. Block Kit actions are emitted to the workflow engine as `slack.block_action.` events. diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index 27aa7de51b..8ac864fabf 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -168,6 +168,11 @@ Execution tuning: | `SLACKBOTV2_AUTO_JOIN_CREATED_CHANNELS` | `slackbotv2.autoJoinCreatedChannels`. | Joins newly-created public channels after subscribed `channel_created` events. Requires `channels:read` and `channels:join`. Defaults to `false`. | | `SLACKBOTV2_STEERING_REACTION_ENABLED` | `slackbotv2.steeringReactionEnabled`. | Reacts to mentioned follow-up messages while an execution is active, then removes their reactions after the assistant response is posted. Requires `reactions:write`. Defaults to `false` and disables itself for the running process if Slack reports the scope is missing. | | `SLACKBOTV2_STEERING_REACTION` | `slackbotv2.steeringReaction`. | Slack emoji name used for steering acknowledgements. Defaults to `hourglass_flowing_sand`. | +| `SLACKBOTV2_FILE_EVENT_WORKFLOW_NAME` | `slackbotv2.extraEnv`. | Workflow started (via `POST /api/workflows/runs`) when a matching Slack file event arrives. Unset disables the file-event trigger. Requires subscribing the Slack app to the file events being matched (`file_shared`, `file_change`) and `files:read` when the title-keyword filter is used. | +| `SLACKBOTV2_FILE_EVENT_TYPES` | `slackbotv2.extraEnv`. | Comma-separated Slack event types matched by the file-event trigger. Defaults to `file_shared,file_change`. | +| `SLACKBOTV2_FILE_EVENT_CHANNELS` | `slackbotv2.extraEnv`. | Optional comma-separated channel-id allowlist for the file-event trigger. Only enforced on events that carry a channel (`file_change` does not). Empty allows all channels. | +| `SLACKBOTV2_FILE_EVENT_TITLE_KEYWORDS` | `slackbotv2.extraEnv`. | Optional comma-separated, case-insensitive substrings matched against the file's `files.info` title/name/pretty_type before dispatching. Empty skips the lookup and dispatches every matching file event. | +| `SLACKBOTV2_FILE_EVENT_DEDUPE_SECONDS` | `slackbotv2.extraEnv`. | Time-buckets the dispatch idempotency key (`slack-file-event::`). Unset: one run per file, ever — later edit events dedupe against the first run, which is expected to debounce internally. | | `SLACKBOTV2_DEFAULT_HARNESS` | `sandbox.harnessEngine`. | Base harness for new Slack threads without an explicit flag or channel default. | | `SLACKBOTV2_CODEX_NANOCODEX_ROLLOUT_PERCENT` | `slackbotv2.codexNanocodexRolloutPercent`. | Percentage of otherwise-default Codex Slack threads assigned to Nanocodex. Assignment is deterministic by thread key and recorded in session and execution metadata. Selecting a non-default model bypasses the rollout. When response metadata is enabled, Slack shows the resolved harness name. Defaults to `0`; increase it to enroll new Codex Slack threads. | | `SLACKBOTV2_CHANNEL_DEFAULTS` | `slackbotv2.channelDefaults`. | Per-channel default harness / model / provider / reasoning as a JSON object keyed by Slack conversation id, where each value is an object of optional `harness`/`model`/`provider`/`reasoning` fields (same vocabulary as the inline flags, so `harness: claude`, `provider: bedrock`, and Claude model aliases like `opus` all work), e.g. `{"C0ENG":{"harness":"claude","model":"opus","reasoning":"high"},"C0TRIAGE":{"reasoning":"low"}}`. A model is only meaningful within a harness, so name the harness alongside it. Applied when a message in that channel carries no explicit/sticky per-thread flag (below such a flag, above the deployment/baked default) and forwarded onto the harness input line so it takes effect; setting the harness restarts a thread onto it like a `--claude`/`--codex` flag. `reasoning` affects the Codex and Nanocodex harnesses. Malformed JSON and unrecognized field values are logged and ignored. | diff --git a/services/api-rs/crates/centaur-api-server/src/auth.rs b/services/api-rs/crates/centaur-api-server/src/auth.rs index ab3b09a8e1..5dd7b5453c 100644 --- a/services/api-rs/crates/centaur-api-server/src/auth.rs +++ b/services/api-rs/crates/centaur-api-server/src/auth.rs @@ -131,6 +131,9 @@ impl ApiAuthConfig { if spec.workflow_events { capabilities.push(Capability::WorkflowsEvents); } + if spec.workflow_runs { + capabilities.push(Capability::WorkflowsWrite); + } callers.push(static_caller( spec.identity, CallerClass::Ingress, @@ -171,6 +174,7 @@ impl ApiAuthConfig { Capability::SessionsRead, Capability::SessionsWrite, Capability::WorkflowsEvents, + Capability::WorkflowsWrite, ], Some(&["slack:"]), )]; @@ -296,12 +300,16 @@ const INGRESS_SPECS: &[IngressSpec] = &[ identity: "slackbot", platform_prefixes: &["slack:"], workflow_events: true, + // c7e fork: slackbotv2's Slack file-event trigger starts workflow runs + // (POST /api/workflows/runs), which needs WorkflowsWrite. + workflow_runs: true, }, IngressSpec { env_var: "DISCORDBOT_API_KEY", identity: "discordbot", platform_prefixes: &["discord:"], workflow_events: false, + workflow_runs: false, }, IngressSpec { env_var: "GITHUBBOT_API_KEY", @@ -313,18 +321,21 @@ const INGRESS_SPECS: &[IngressSpec] = &[ "github-review:", ], workflow_events: true, + workflow_runs: false, }, IngressSpec { env_var: "LINEARBOT_API_KEY", identity: "linearbot", platform_prefixes: &["linear:"], workflow_events: false, + workflow_runs: false, }, IngressSpec { env_var: "TEAMSBOT_API_KEY", identity: "teamsbot", platform_prefixes: &["teams:"], workflow_events: false, + workflow_runs: false, }, ]; @@ -334,6 +345,8 @@ struct IngressSpec { /// Every session thread-key prefix this ingress mints. platform_prefixes: &'static [&'static str], workflow_events: bool, + /// Whether the ingress may start and cancel workflow runs. + workflow_runs: bool, } fn static_caller( @@ -554,6 +567,21 @@ mod tests { ); } + #[test] + fn only_slackbot_ingress_can_start_workflow_runs() { + // services/slackbotv2/src/file-event-trigger.ts POSTs + // /api/workflows/runs with SLACKBOT_API_KEY; the other ingresses only + // emit workflow events. + for spec in INGRESS_SPECS { + assert_eq!( + spec.workflow_runs, + spec.identity == "slackbot", + "{} workflow_runs grant drifted", + spec.identity + ); + } + } + #[test] fn every_ingress_scopes_itself_to_at_least_one_prefix() { for spec in INGRESS_SPECS { diff --git a/services/slackbotv2/src/file-event-trigger.ts b/services/slackbotv2/src/file-event-trigger.ts new file mode 100644 index 0000000000..8b3fdce58a --- /dev/null +++ b/services/slackbotv2/src/file-event-trigger.ts @@ -0,0 +1,185 @@ +/** + * Generic Slack file-event → workflow-run trigger. + * + * The Chat SDK's event dispatch drops file events (file_shared, file_change, + * ...) before any callback fires, so this sidecar parses the raw webhook body + * itself — after the SDK has verified the Slack signature — and, when + * enabled, creates a durable workflow run through the session API + * (POST /api/workflows/runs). + * + * Deployment-neutral and default-off: the target workflow name, accepted + * event types, channel allowlist, and file-title keywords all come from + * configuration (see server.ts). The bot stays timer-free on purpose — any + * settle/debounce behavior belongs in the target workflow, which can sleep + * durably. Per-edit event storms collapse at spawn time via the idempotency + * key `slack-file-event:{workflow}:{file_id}`; with `fileEventDedupeSeconds` + * unset the key never varies (and the server never expires keys), so a file + * triggers at most one run, ever. + */ +import { dispatchWorkflowRun } from './session-api' +import type { SlackbotV2Options } from './types' +import { errorMessage, traceLog, traceWarn } from './utils' + +const DEFAULT_FILE_EVENT_TYPES = ['file_shared', 'file_change'] as const + +const KEYWORD_FILE_FIELDS = ['title', 'name', 'pretty_type'] as const + +export type SlackFileEventTriggerInput = { + channelId: string + eventTs: string + eventType: string + fileId: string + teamId: string +} + +export type SlackFileEventTriggerDeps = { + filesInfo: (fileId: string) => Promise | null> +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function firstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim() + } + return '' +} + +export function slackFileEventTriggerInput( + rawBody: string, + eventTypes: readonly string[] +): SlackFileEventTriggerInput | null { + let payload: unknown + try { + payload = JSON.parse(rawBody) + } catch { + return null + } + if (!isRecord(payload) || payload.type !== 'event_callback') return null + const event = payload.event + if (!isRecord(event)) return null + const eventType = typeof event.type === 'string' ? event.type : '' + if (!eventTypes.includes(eventType)) return null + const file = isRecord(event.file) ? event.file : undefined + const fileId = firstString(event.file_id, file?.id) + if (!fileId) return null + return { + // file_shared carries channel_id; file_change does not — the target + // workflow derives the channel from the file's shares in that case. + channelId: firstString(event.channel_id), + eventTs: firstString( + event.event_ts, + typeof payload.event_time === 'number' ? String(payload.event_time) : undefined + ), + eventType, + fileId, + teamId: firstString(payload.team_id) + } +} + +export function fileEventIdempotencyKey( + workflowName: string, + input: SlackFileEventTriggerInput, + dedupeSeconds?: number +): string { + const base = `slack-file-event:${workflowName}:${input.fileId}` + const bucketSeconds = dedupeSeconds ?? 0 + if (bucketSeconds <= 0) return base + const eventSeconds = Math.floor(Number.parseFloat(input.eventTs)) + if (!Number.isFinite(eventSeconds)) return base + return `${base}:${Math.floor(eventSeconds / bucketSeconds)}` +} + +/** + * Returns null when the event is not a matching file event (or the trigger is + * disabled); otherwise a promise for the caller's waitUntil. The returned + * promise never rejects — the webhook path must not see a throw from this + * sidecar. + */ +export function triggerFileEventWorkflow( + rawBody: string, + options: SlackbotV2Options, + deps: SlackFileEventTriggerDeps +): Promise | null { + const workflowName = options.fileEventWorkflowName?.trim() + if (!workflowName) return null + const eventTypes = options.fileEventTypes?.length + ? options.fileEventTypes + : DEFAULT_FILE_EVENT_TYPES + const input = slackFileEventTriggerInput(rawBody, eventTypes) + if (!input) return null + const allowlist = options.fileEventChannelAllowlist ?? [] + // Only enforced when the event carries a channel: file_change events have + // none, and the target workflow applies its own scoping. + if (allowlist.length && input.channelId && !allowlist.includes(input.channelId)) { + return null + } + return dispatchFileEventWorkflow(workflowName, input, options, deps) +} + +async function dispatchFileEventWorkflow( + workflowName: string, + input: SlackFileEventTriggerInput, + options: SlackbotV2Options, + deps: SlackFileEventTriggerDeps +): Promise { + try { + const keywords = (options.fileEventTitleKeywords ?? []) + .map(keyword => keyword.trim().toLowerCase()) + .filter(Boolean) + if (keywords.length) { + const file = await deps.filesInfo(input.fileId) + if (!file) { + traceWarn(options, 'slackbotv2_file_event_files_info_missing', undefined, { + slack_file_id: input.fileId + }) + return + } + const haystack = KEYWORD_FILE_FIELDS.map(key => + typeof file[key] === 'string' ? (file[key] as string) : '' + ) + .join(' ') + .toLowerCase() + if (!keywords.some(keyword => haystack.includes(keyword))) { + traceLog(options, 'slackbotv2_file_event_skipped_keyword_filter', undefined, { + event_type: input.eventType, + slack_file_id: input.fileId + }) + return + } + } + const idempotencyKey = fileEventIdempotencyKey( + workflowName, + input, + options.fileEventDedupeSeconds + ) + const run = await dispatchWorkflowRun(options, { + workflow_name: workflowName, + input: { + channel_id: input.channelId, + event_ts: input.eventTs, + event_type: input.eventType, + file_id: input.fileId, + team_id: input.teamId + }, + idempotency_key: idempotencyKey + }) + traceLog(options, 'slackbotv2_file_event_workflow_dispatched', undefined, { + created: run.created ?? null, + event_type: input.eventType, + idempotency_key: idempotencyKey, + run_id: run.run_id ?? null, + slack_file_id: input.fileId, + workflow_name: workflowName + }) + } catch (error) { + traceWarn(options, 'slackbotv2_file_event_workflow_dispatch_failed', undefined, { + error: errorMessage(error), + event_type: input.eventType, + slack_file_id: input.fileId, + workflow_name: workflowName + }) + } +} diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 874c8862b1..e57fce283a 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -31,6 +31,7 @@ import { } from '@centaur/rendering' import { conflateChatSdkStream } from './conflate' import { resolveHarnessRollout } from './harness-rollout' +import { triggerFileEventWorkflow } from './file-event-trigger' import { observeSeconds, slackbotMetrics } from './metrics' import { renderSlackDisplayText, @@ -510,6 +511,12 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { } const lateFileTask = lateSlackFiles.repairFromWebhook(rawBody) if (lateFileTask) waitUntil(c, lateFileTask) + // The Chat SDK drops file events before any callback, so the file-event + // workflow trigger parses the (signature-verified) raw body itself. + const fileEventTask = triggerFileEventWorkflow(rawBody, options, { + filesInfo: fileId => slackFilesInfo(options, fileId) + }) + if (fileEventTask) waitUntil(c, fileEventTask) outcome = response.ok ? 'success' : 'error' return new globalThis.Response(await response.text(), { headers: response.headers, diff --git a/services/slackbotv2/src/server.ts b/services/slackbotv2/src/server.ts index 208eb02664..fef616b600 100644 --- a/services/slackbotv2/src/server.ts +++ b/services/slackbotv2/src/server.ts @@ -63,6 +63,12 @@ const options: SlackbotV2Options = { responseMetadataMode: responseMetadataModeEnv('SLACKBOTV2_RESPONSE_METADATA_MODE'), responseServiceTierEnabled: booleanEnv('SLACKBOTV2_RESPONSE_SERVICE_TIER_ENABLED', false), defaultHarnessType: optionalEnv('SLACKBOTV2_DEFAULT_HARNESS'), + fileEventWorkflowName: optionalEnv('SLACKBOTV2_FILE_EVENT_WORKFLOW_NAME'), + fileEventTypes: commaListEnv('SLACKBOTV2_FILE_EVENT_TYPES'), + fileEventChannelAllowlist: commaListEnv('SLACKBOTV2_FILE_EVENT_CHANNELS'), + // Comma-separated only: keywords may contain spaces ("huddle notes"). + fileEventTitleKeywords: commaListEnv('SLACKBOTV2_FILE_EVENT_TITLE_KEYWORDS'), + fileEventDedupeSeconds: optionalNumberEnv('SLACKBOTV2_FILE_EVENT_DEDUPE_SECONDS'), // Same env vars deployers use to override the sandbox harness model // (sandbox.extraEnv); the chart mirrors them here so displayed defaults // track the deployment instead of the baked harness config. @@ -124,6 +130,8 @@ console.log( response_service_tier_enabled: options.responseServiceTierEnabled, steering_reaction_enabled: options.steeringReactionEnabled, steering_reaction_name: options.steeringReactionName, + file_event_trigger_enabled: Boolean(options.fileEventWorkflowName), + file_event_workflow: options.fileEventWorkflowName ?? null, port: server.port, api_url: apiUrl }) @@ -150,6 +158,16 @@ function numberEnv(name: string, fallback: number): number { return optionalNumberEnv(name) ?? fallback } +function commaListEnv(name: string): string[] | undefined { + const value = optionalEnv(name) + if (!value) return undefined + const parts = value + .split(',') + .map(part => part.trim()) + .filter(Boolean) + return parts.length ? parts : undefined +} + function booleanEnv(name: string, fallback: boolean): boolean { const value = optionalEnv(name) if (!value) return fallback diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index bada60f227..cea178372b 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -551,6 +551,46 @@ export async function forwardToSessionApi( return openSessionEventStream(options, input) } +export type SlackbotV2WorkflowRunRequest = { + workflow_name: string + input: Record + idempotency_key?: string +} + +export type SlackbotV2WorkflowRunResponse = { + ok?: boolean + run_id?: string + task_id?: string + status?: string + created?: boolean +} + +export async function dispatchWorkflowRun( + options: SlackbotV2Options, + request: SlackbotV2WorkflowRunRequest +): Promise { + const action = `dispatch workflow run ${request.workflow_name}` + const response = await recordSessionApiOperation( + 'create_workflow_run', + () => + fetchWithTimeout( + options.fetch ?? globalThis.fetch, + new URL('/api/workflows/runs', ensureTrailingSlash(options.apiUrl)), + { + body: JSON.stringify(request), + headers: apiHeaders(options), + method: 'POST' + }, + sessionApiTimeoutMs(options), + action + ), + sessionApiTimeoutMs(options), + action + ) + await ensureApiOk(response, action) + return (await response.json()) as SlackbotV2WorkflowRunResponse +} + export async function dispatchSlackBlockAction( options: SlackbotV2Options, payload: SlackbotV2BlockActionPayload diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 7217022c64..86716ac8e9 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -166,6 +166,37 @@ export type SlackbotV2Options = { */ defaultHarnessType?: string fetch?: SlackbotV2Fetch + /** + * Workflow name to run when a matching Slack file event arrives + * (SLACKBOTV2_FILE_EVENT_WORKFLOW_NAME). Unset disables the file-event + * trigger entirely. See file-event-trigger.ts. + */ + fileEventWorkflowName?: string + /** + * Slack event types that trigger the file-event workflow + * (SLACKBOTV2_FILE_EVENT_TYPES). Defaults to file_shared + file_change; the + * Slack app must be subscribed to each listed bot event. + */ + fileEventTypes?: readonly string[] + /** + * Channel ids whose file events may trigger the workflow + * (SLACKBOTV2_FILE_EVENT_CHANNELS). Only enforced when the event carries a + * channel — file_change events do not. Empty/unset allows all channels. + */ + fileEventChannelAllowlist?: readonly string[] + /** + * Case-insensitive substrings matched against the file's files.info + * title/name/pretty_type before dispatching + * (SLACKBOTV2_FILE_EVENT_TITLE_KEYWORDS). Empty/unset skips the lookup and + * dispatches for every matching file event. + */ + fileEventTitleKeywords?: readonly string[] + /** + * Time-bucket the dispatch idempotency key in seconds + * (SLACKBOTV2_FILE_EVENT_DEDUPE_SECONDS). Unset/0: one run per file, ever + * (workflow idempotency keys never expire server-side). + */ + fileEventDedupeSeconds?: number /** * Deployment-configured default model per harness wire value (claudecode | * codex), from the CLAUDE_MODEL / CODEX_MODEL env vars the chart mirrors diff --git a/services/slackbotv2/test/file-event-trigger.test.ts b/services/slackbotv2/test/file-event-trigger.test.ts new file mode 100644 index 0000000000..03a4cac302 --- /dev/null +++ b/services/slackbotv2/test/file-event-trigger.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from 'bun:test' +import { + fileEventIdempotencyKey, + slackFileEventTriggerInput, + triggerFileEventWorkflow, + type SlackFileEventTriggerDeps +} from '../src/file-event-trigger' +import type { SlackbotV2Options } from '../src/types' + +const DEFAULT_TYPES = ['file_shared', 'file_change'] as const + +function eventBody(event: Record, extra: Record = {}): string { + return JSON.stringify({ + type: 'event_callback', + team_id: 'T1', + event_time: 1_780_000_000, + event, + ...extra + }) +} + +const FILE_SHARED_BODY = eventBody({ + type: 'file_shared', + channel_id: 'C123', + file_id: 'F123', + file: { id: 'F123' }, + event_ts: '1780000000.100' +}) + +const FILE_CHANGE_BODY = eventBody({ + type: 'file_change', + file_id: 'F123', + file: { id: 'F123' }, + event_ts: '1780000100.200' +}) + +function options(overrides: Partial = {}): SlackbotV2Options { + return { + apiUrl: 'http://session.test', + apiKey: 'test-api-key', + botToken: 'xoxb-test', + signingSecret: 'test', + fileEventWorkflowName: 'file_review', + ...overrides + } +} + +function deps(file: Record | null = null): SlackFileEventTriggerDeps & { + calls: string[] +} { + const calls: string[] = [] + return { + calls, + filesInfo: async (fileId: string) => { + calls.push(fileId) + return file + } + } +} + +type CapturedRequest = { url: string; init: RequestInit } + +function captureFetch( + responses: Response[] = [] +): { fetch: SlackbotV2Options['fetch']; requests: CapturedRequest[] } { + const requests: CapturedRequest[] = [] + return { + requests, + fetch: async (input, init) => { + requests.push({ url: String(input), init: init ?? {} }) + return responses.shift() ?? Response.json({ ok: true, run_id: 'wfr_1', created: true }) + } + } +} + +describe('slackFileEventTriggerInput', () => { + it('parses file_shared with channel', () => { + const input = slackFileEventTriggerInput(FILE_SHARED_BODY, DEFAULT_TYPES) + expect(input).toEqual({ + channelId: 'C123', + eventTs: '1780000000.100', + eventType: 'file_shared', + fileId: 'F123', + teamId: 'T1' + }) + }) + + it('parses channel-less file_change', () => { + const input = slackFileEventTriggerInput(FILE_CHANGE_BODY, DEFAULT_TYPES) + expect(input?.channelId).toBe('') + expect(input?.eventType).toBe('file_change') + expect(input?.fileId).toBe('F123') + }) + + it('falls back to event.file.id when file_id is absent', () => { + const body = eventBody({ type: 'file_shared', file: { id: 'F9' } }) + expect(slackFileEventTriggerInput(body, DEFAULT_TYPES)?.fileId).toBe('F9') + }) + + it('rejects non-file events, other payload types, and invalid JSON', () => { + expect( + slackFileEventTriggerInput(eventBody({ type: 'message', text: 'hi' }), DEFAULT_TYPES) + ).toBeNull() + expect( + slackFileEventTriggerInput( + JSON.stringify({ type: 'url_verification', challenge: 'x' }), + DEFAULT_TYPES + ) + ).toBeNull() + expect(slackFileEventTriggerInput('not json', DEFAULT_TYPES)).toBeNull() + expect( + slackFileEventTriggerInput(eventBody({ type: 'file_shared' }), DEFAULT_TYPES) + ).toBeNull() // no file id anywhere + }) + + it('respects a custom event-type list', () => { + expect(slackFileEventTriggerInput(FILE_CHANGE_BODY, ['file_shared'])).toBeNull() + }) +}) + +describe('fileEventIdempotencyKey', () => { + const input = slackFileEventTriggerInput(FILE_SHARED_BODY, DEFAULT_TYPES)! + + it('is stable and unbucketed by default', () => { + expect(fileEventIdempotencyKey('file_review', input)).toBe( + 'slack-file-event:file_review:F123' + ) + expect(fileEventIdempotencyKey('file_review', input, 0)).toBe( + 'slack-file-event:file_review:F123' + ) + }) + + it('buckets by event time when dedupeSeconds is set', () => { + expect(fileEventIdempotencyKey('file_review', input, 3600)).toBe( + `slack-file-event:file_review:F123:${Math.floor(1_780_000_000 / 3600)}` + ) + }) + + it('falls back to the unbucketed key when event_ts is unparsable', () => { + expect( + fileEventIdempotencyKey('file_review', { ...input, eventTs: '' }, 3600) + ).toBe('slack-file-event:file_review:F123') + }) +}) + +describe('triggerFileEventWorkflow', () => { + it('is disabled without a configured workflow name', () => { + const { fetch, requests } = captureFetch() + expect( + triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch, fileEventWorkflowName: undefined }), + deps() + ) + ).toBeNull() + expect(requests).toEqual([]) + }) + + it('POSTs a workflow run with the expected shape', async () => { + const { fetch, requests } = captureFetch() + const task = triggerFileEventWorkflow(FILE_SHARED_BODY, options({ fetch }), deps()) + expect(task).not.toBeNull() + await task + + expect(requests).toHaveLength(1) + expect(requests[0]!.url).toBe('http://session.test/api/workflows/runs') + expect(requests[0]!.init.method).toBe('POST') + const headers = requests[0]!.init.headers as Record + expect(headers['content-type']).toBe('application/json') + expect(headers.authorization).toBe('Bearer test-api-key') + const body = JSON.parse(String(requests[0]!.init.body)) + expect(body).toEqual({ + workflow_name: 'file_review', + input: { + channel_id: 'C123', + event_ts: '1780000000.100', + event_type: 'file_shared', + file_id: 'F123', + team_id: 'T1' + }, + idempotency_key: 'slack-file-event:file_review:F123' + }) + }) + + it('dispatches channel-less file_change events even with a channel allowlist', async () => { + const { fetch, requests } = captureFetch() + const task = triggerFileEventWorkflow( + FILE_CHANGE_BODY, + options({ fetch, fileEventChannelAllowlist: ['COTHER'] }), + deps() + ) + await task + expect(requests).toHaveLength(1) + const body = JSON.parse(String(requests[0]!.init.body)) + expect(body.input.channel_id).toBe('') + }) + + it('drops file_shared events outside the channel allowlist', () => { + const { fetch, requests } = captureFetch() + expect( + triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch, fileEventChannelAllowlist: ['COTHER'] }), + deps() + ) + ).toBeNull() + expect(requests).toEqual([]) + }) + + it('applies the title-keyword filter through files.info', async () => { + const matching = deps({ title: 'Huddle notes: 8/12/26 in #general' }) + const { fetch, requests } = captureFetch() + await triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch, fileEventTitleKeywords: ['huddle notes', 'huddle transcript'] }), + matching + ) + expect(matching.calls).toEqual(['F123']) + expect(requests).toHaveLength(1) + + const nonMatching = deps({ title: 'Q3 planning doc' }) + const { fetch: fetch2, requests: requests2 } = captureFetch() + await triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch: fetch2, fileEventTitleKeywords: ['huddle notes'] }), + nonMatching + ) + expect(requests2).toEqual([]) + }) + + it('swallows files.info failures and missing files', async () => { + const failing: SlackFileEventTriggerDeps = { + filesInfo: async () => { + throw new Error('files.info exploded') + } + } + const { fetch, requests } = captureFetch() + await triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch, fileEventTitleKeywords: ['huddle notes'] }), + failing + ) + expect(requests).toEqual([]) + + const { fetch: fetch2, requests: requests2 } = captureFetch() + await triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch: fetch2, fileEventTitleKeywords: ['huddle notes'] }), + deps(null) + ) + expect(requests2).toEqual([]) + }) + + it('swallows non-2xx workflow API responses', async () => { + const { fetch } = captureFetch([new Response('nope', { status: 500 })]) + const task = triggerFileEventWorkflow(FILE_SHARED_BODY, options({ fetch }), deps()) + expect(task).not.toBeNull() + await task // must not reject + }) + + it('ignores non-file payloads', () => { + const { fetch, requests } = captureFetch() + expect( + triggerFileEventWorkflow( + eventBody({ type: 'message', text: 'hello' }), + options({ fetch }), + deps() + ) + ).toBeNull() + expect(requests).toEqual([]) + }) +}) From 25decdb79096e0a42c7716ae9a8cb7df67e9f53a Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Wed, 26 Aug 2026 22:39:52 +0800 Subject: [PATCH 6/8] fix(rls): let the company-context reader see memory notes (#41) Allow the company-context reader role to see company-scoped memory notes when public Slack context is enabled, while keeping other c7e_memory document types and user-scoped notes hidden. The embeddings policy delegates to the document policy, so the vector search lane follows without a separate change. Rebase note (upstream dafff4cd): renumbered the fork migration from 0053 to 0055 because upstream owns 0053 and 0054. Production databases that already applied the fork migration as version 53 must reconcile/delete that _sqlx_migrations row before api-rs boots, then apply upstream 0053/0054 and the fork's 0055 in order; the policy DDL is idempotent. --- ...55_company_context_reader_memory_notes.sql | 40 +++++++++++++++++++ .../tests/etl_context_rls.rs | 24 ++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0055_company_context_reader_memory_notes.sql diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0055_company_context_reader_memory_notes.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0055_company_context_reader_memory_notes.sql new file mode 100644 index 0000000000..0a42d43269 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0055_company_context_reader_memory_notes.sql @@ -0,0 +1,40 @@ +-- Let the company-context reader see hand-curated memory notes. +-- +-- `centaur_cc_reader_documents_select` (0048/0050) admits only `source = 'slack'` +-- rows, so agent sessions searching company context never see documents +-- other writers put in `company_context_documents`. Memory notes +-- (`source = 'c7e_memory'`, `source_type = 'memory_note'`) are facts a user +-- explicitly asked the agent to remember, written with +-- `access_scope = 'company'`; hiding them from the agent that stored them +-- defeats the purpose. +-- +-- Visibility is tied to `centaur.slack_include_public`: a principal allowed +-- to read public Slack may read company-scoped notes. A connection with no +-- access settings still sees nothing (fail closed), matching the other +-- reader policies. Other `c7e_memory` document types (rollups, metric +-- snapshots) are unchanged. +-- +-- The embeddings policy delegates to this table +-- (`centaur_company_context_embedding_document_visible`), so the vector +-- search lane follows automatically. +drop policy if exists centaur_cc_reader_documents_select + on company_context_documents; +create policy centaur_cc_reader_documents_select + on company_context_documents + for select + to centaur_company_context_reader + using ( + ( + source = 'slack' + and metadata ->> 'channel_id' in ( + select channels.channel_id + from slack_sync_channels channels + ) + ) + or ( + source = 'c7e_memory' + and source_type = 'memory_note' + and access_scope = 'company' + and (select centaur_company_context_include_public_slack()) + ) + ); diff --git a/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs b/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs index e1d9c18973..abc2ae2caf 100644 --- a/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs +++ b/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs @@ -256,10 +256,16 @@ async fn assert_channel_visibility(conn: &mut PgConnection) -> Result<(), Box Result<(), Box> { + // Public-context readers see public Slack plus company-scoped memory notes; + // other c7e_memory document types and user-scoped notes stay hidden. let company_context_public = company_context_docs(conn, None, r#"[]"#, true).await?; assert_eq!( company_context_public, - vec!["doc_slack_alpha".to_owned(), "doc_slack_beta".to_owned(),] + vec![ + "doc_memory_note".to_owned(), + "doc_slack_alpha".to_owned(), + "doc_slack_beta".to_owned(), + ] ); let company_context_private_history = @@ -267,12 +273,14 @@ async fn assert_company_context_reader_search_behavior( assert_eq!( company_context_private_history, vec![ + "doc_memory_note".to_owned(), "doc_slack_alpha".to_owned(), "doc_slack_beta".to_owned(), "doc_slack_private".to_owned(), ] ); + // Without public context, memory notes are hidden too (fail closed). let company_context_history_no_public = company_context_docs(conn, None, r#"["C_ALPHA"]"#, false).await?; assert_eq!( @@ -292,6 +300,7 @@ async fn assert_company_context_reader_search_behavior( search_rows, CompanyContextSearchRows { company_context_docs: vec![ + "doc_memory_note".to_owned(), "doc_slack_alpha".to_owned(), "doc_slack_beta".to_owned(), "doc_slack_private".to_owned(), @@ -1172,7 +1181,15 @@ async fn insert_fixture_rows(conn: &mut PgConnection) -> Result<(), sqlx::Error> ('doc_slack_unknown_channel', 'slack', 'slack_thread', 'unknown', '{}'), ('doc_gdrive', 'google_drive', 'google_doc', 'gdrive_file', '{}'), ('doc_gcal', 'google_calendar', 'calendar_event', 'gcal_event', '{}'), - ('doc_linear', 'linear', 'linear_issue', 'linear_issue', '{}'); + ('doc_linear', 'linear', 'linear_issue', 'linear_issue', '{}'), + ('doc_memory_note', 'c7e_memory', 'memory_note', 'note', '{}'), + ('doc_memory_update', 'c7e_memory', 'memory_update', 'update', '{}'); + -- A user-scoped note must stay hidden from the company-context reader + -- even when public context is enabled. + insert into company_context_documents + (document_id, source, source_type, source_document_id, metadata, access_scope) + values + ('doc_memory_note_user', 'c7e_memory', 'memory_note', 'note_user', '{}', 'user:U_OTHER'); insert into google_drive_sync_runs (run_id, status) values ('gdrive_run', 'succeeded'); insert into google_drive_sync_files (file_id) values ('gdrive_file'); @@ -1613,6 +1630,9 @@ fn public_visible_rows() -> VisibleRows { "doc_gcal".to_owned(), "doc_gdrive".to_owned(), "doc_linear".to_owned(), + "doc_memory_note".to_owned(), + "doc_memory_note_user".to_owned(), + "doc_memory_update".to_owned(), "doc_slack_alpha".to_owned(), "doc_slack_beta".to_owned(), ], From d8b3766a981f8326e1b8dabc2ab88ba743acd843 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Fri, 28 Aug 2026 17:30:17 +0800 Subject: [PATCH 7/8] feat(gsuite): Google Meet links, attendee notifications, and event delete (#43) Three gaps in the calendar surface, found while exercising the tool against a real calendar. No way to create a Meet. calendar_create_event built a plain event body and called events().insert() without conferenceDataVersion, so an agent could book the slot but not the meeting. Meet is minted through the conferencing subresource: the body carries a createRequest with a fresh requestId and the call must opt in with conferenceDataVersion=1, or Calendar drops conferenceData and returns an event with no link. Create and update now take `conference`, return `meet_link`, and re-fetch once when Calendar answers before the mint settles. On update the flag only adds a conference to an event that lacks one; leaving conferenceDataVersion at its 0 default elsewhere is what keeps an existing Meet intact on an ordinary update. Edits reached attendees as silence. calendar_update_event passed sendUpdates="all" only when add_attendees was set, so moving an event, renaming it, or changing where it happens notified nobody: attendees' copies moved, their invite emails did not. Verified against a real event -- the reschedule produced no mail at all. `notify` now defaults to notifying when the event has attendees and the edit is material: when, where, what it is called, who else is coming, or a Meet just added. Description-only edits stay quiet on purpose, so the notices that land keep meaning something. Materiality is deliberately narrow. The field tests are truthiness, matching how the function applies them -- it writes a field only when the value is truthy, so `location=""` edits nothing and must not send mail. Attendee notification keys off whether the merge actually appended somebody, not whether a list was passed, so re-inviting an existing guest stays quiet. That merge also now records the emails it appends, so a repeated address in one call is added once rather than duplicated. No way to cancel. calendar_delete_event is new; the tool could create and update events but never delete one. It notifies by default, since a cancellation nobody hears about just leaves the meeting on every calendar. Submitted upstream as paradigmxyz/centaur#1522; the calendar functions, CLI commands, and tests here are byte-identical to that submission. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011nvUztTAb5Kvkb9PhCnCbT --- tools/productivity/gsuite/cli.py | 41 +++ tools/productivity/gsuite/client.py | 186 +++++++++++- tools/productivity/gsuite/test_client.py | 362 +++++++++++++++++++++++ 3 files changed, 584 insertions(+), 5 deletions(-) diff --git a/tools/productivity/gsuite/cli.py b/tools/productivity/gsuite/cli.py index f011bc5664..4ce60102dc 100644 --- a/tools/productivity/gsuite/cli.py +++ b/tools/productivity/gsuite/cli.py @@ -376,6 +376,7 @@ def calendar_create( description: str = typer.Option(None, "--description", "-d", help="Event description"), location: str = typer.Option(None, "--location", "-l", help="Event location"), attendees: str = typer.Option(None, "--attendees", "-a", help="Comma-separated emails"), + meet: bool = typer.Option(False, "--meet", "-m", help="Add a Google Meet link"), ): """Create a calendar event. @@ -383,6 +384,7 @@ def calendar_create( gsuite calendar create "Team Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z" gsuite calendar create "All-day event" "2024-01-15" "2024-01-16" gsuite calendar create "Meeting" "..." "..." -a "a@b.com,c@d.com" -l "Room 1" + gsuite calendar create "Standup" "..." "..." --meet """ from .client import calendar_create_event @@ -397,9 +399,12 @@ def calendar_create( description=description, location=location, attendees=attendee_list, + conference=meet, ) console.print("[green]✓ Event created[/]") console.print(f"[dim]{result['html_link']}[/]") + if result.get("meet_link"): + console.print(f"[dim]{result['meet_link']}[/]") except Exception as e: console.print(f"[red]Error: {e}[/]") raise typer.Exit(1) @@ -415,6 +420,12 @@ def calendar_update( description: str = typer.Option(None, "--description", "-d", help="New description"), location: str = typer.Option(None, "--location", "-l", help="New location"), add_attendees: str = typer.Option(None, "--add", "-a", help="Comma-separated emails to add"), + meet: bool = typer.Option(False, "--meet", "-m", help="Add a Google Meet link"), + notify: bool = typer.Option( + None, + "--notify/--no-notify", + help="Email attendees (default: on a material change to an event with attendees)", + ), ): """Update a calendar event. @@ -422,6 +433,8 @@ def calendar_update( gsuite calendar update "event_id" --summary "New Title" gsuite calendar update "event_id" --start "2024-01-15T14:00:00Z" --end "2024-01-15T15:00:00Z" gsuite calendar update "event_id" --add "a@b.com,c@d.com" + gsuite calendar update "event_id" --meet + gsuite calendar update "event_id" --start "..." --end "..." --no-notify """ from .client import calendar_update_event @@ -437,14 +450,42 @@ def calendar_update( description=description, location=location, add_attendees=attendee_list, + conference=meet, + notify=notify, ) console.print("[green]✓ Event updated[/]") console.print(f"[dim]{result['html_link']}[/]") + if result.get("meet_link"): + console.print(f"[dim]{result['meet_link']}[/]") except Exception as e: console.print(f"[red]Error: {e}[/]") raise typer.Exit(1) +@calendar_app.command("delete") +def calendar_delete( + event_id: str = typer.Argument(..., help="Event ID"), + calendar: str = typer.Option("primary", "--calendar", "-c", help="Calendar ID"), + notify: bool = typer.Option( + True, "--notify/--no-notify", help="Email attendees that the event was cancelled" + ), +): + """Delete a calendar event. + + Examples: + gsuite calendar delete "event_id" + gsuite calendar delete "event_id" --no-notify + """ + from .client import calendar_delete_event + + try: + calendar_delete_event(event_id=event_id, calendar_id=calendar, notify=notify) + console.print("[green]✓ Event deleted[/]") + except Exception as e: + console.print(f"[red]Error: {e}[/]") + raise typer.Exit(1) from e + + @calendar_app.command("rsvp") def calendar_rsvp_cmd( event_id: str = typer.Argument(..., help="Event ID"), diff --git a/tools/productivity/gsuite/client.py b/tools/productivity/gsuite/client.py index d896456940..25dfa51507 100644 --- a/tools/productivity/gsuite/client.py +++ b/tools/productivity/gsuite/client.py @@ -6,6 +6,7 @@ import os import re import urllib.request +import uuid from email.mime.text import MIMEText from pathlib import Path from urllib.parse import quote, urljoin, urlparse, urlsplit @@ -469,6 +470,84 @@ def calendar_events( return events +# Google Meet is not a plain event field: the body carries a conferencing +# createRequest and the call must opt in with conferenceDataVersion=1, or +# Calendar silently drops conferenceData and returns an event with no link. +_MEET_SOLUTION_KEY = "hangoutsMeet" + + +def _conference_create_request() -> dict: + """Body fragment asking Calendar to mint a Google Meet conference. + + ``requestId`` is the mint's idempotency key: Calendar reuses the existing + conference when the same id is replayed, so it must be fresh per event. + """ + return { + "createRequest": { + "requestId": uuid.uuid4().hex, + "conferenceSolutionKey": {"type": _MEET_SOLUTION_KEY}, + } + } + + +def _is_material_change( + *, + summary: str | None, + start: str | None, + end: str | None, + location: str | None, + attendees_added: bool, + conference_added: bool, +) -> bool: + """Whether an edit is one an attendee needs to be told about. + + When, where, what it is called, who else is coming, and whether there is + now a call to join. Description-only edits are deliberately excluded: they + are the common case for tidying wording, and mailing everyone for that + trains people to ignore the notices that matter. + + The field tests are truthiness, not ``is not None``, to match how + calendar_update_event applies them: it writes a field only when the value + is truthy, so an empty string changes nothing and must not send mail. + Likewise ``attendees_added`` is whether the merge actually appended + somebody, not whether the caller passed a list -- re-passing an existing + guest is a no-op nobody needs to hear about. + """ + return ( + any(field for field in (summary, start, end, location)) + or attendees_added + or conference_added + ) + + +def _meet_link(event: dict) -> str: + """Return the event's Google Meet URL, or "" when it has none.""" + link = event.get("hangoutLink") + if link: + return link + + for entry in event.get("conferenceData", {}).get("entryPoints", []): + if entry.get("entryPointType") == "video" and entry.get("uri"): + return entry["uri"] + + return "" + + +def _await_meet_link(service, calendar_id: str, event: dict) -> str: + """Return the Meet URL, re-fetching the event once if the mint is pending. + + Calendar can answer the write before the conference exists, leaving a + pending createRequest and no entry points. One re-fetch settles it in + practice and spares callers from polling. + """ + link = _meet_link(event) + if link or not event.get("id"): + return link + + refreshed = service.events().get(calendarId=calendar_id, eventId=event["id"]).execute() + return _meet_link(refreshed) + + def calendar_create_event( summary: str, start: str, @@ -477,6 +556,7 @@ def calendar_create_event( description: str | None = None, location: str | None = None, attendees: list[str] | None = None, + conference: bool = False, ) -> dict: """Create a calendar event. @@ -488,9 +568,10 @@ def calendar_create_event( description: Event description location: Event location attendees: List of attendee emails + conference: Attach a Google Meet conference to the event Returns: - Dict with id, html_link + Dict with id, html_link, meet_link (meet_link is "" without conference) """ service = get_calendar_service() @@ -512,12 +593,18 @@ def calendar_create_event( if attendees: event["attendees"] = [{"email": email} for email in attendees] + insert_kwargs = {} + if conference: + event["conferenceData"] = _conference_create_request() + insert_kwargs["conferenceDataVersion"] = 1 + result = ( service.events() .insert( calendarId=calendar_id, body=event, sendUpdates="all" if attendees else "none", + **insert_kwargs, ) .execute() ) @@ -525,6 +612,7 @@ def calendar_create_event( return { "id": result.get("id", ""), "html_link": result.get("htmlLink", ""), + "meet_link": _await_meet_link(service, calendar_id, result) if conference else "", } @@ -537,6 +625,8 @@ def calendar_update_event( description: str | None = None, location: str | None = None, add_attendees: list[str] | None = None, + conference: bool = False, + notify: bool | None = None, ) -> dict: """Update a calendar event. @@ -549,9 +639,13 @@ def calendar_update_event( description: New description location: New location add_attendees: List of attendee emails to add + conference: Attach a Google Meet conference if the event lacks one + notify: Email the attendees. Defaults to notifying when the event has + attendees and the change is material (see _is_material_change); + pass True or False to decide explicitly. Returns: - Dict with id, html_link + Dict with id, html_link, meet_link ("" when the event has no Meet) """ service = get_calendar_service() @@ -575,21 +669,46 @@ def calendar_update_event( else: event["end"] = {"date": end} + attendees_added = False if add_attendees: existing = event.get("attendees", []) existing_emails = {a.get("email", "").lower() for a in existing} for email in add_attendees: if email.lower() not in existing_emails: existing.append({"email": email}) + existing_emails.add(email.lower()) + attendees_added = True event["attendees"] = existing + # conferenceDataVersion defaults to 0, under which Calendar ignores the + # conferenceData we just read back -- that is what keeps an existing Meet + # intact on an ordinary update. + update_kwargs = {} + conference_added = False + if conference: + update_kwargs["conferenceDataVersion"] = 1 + if not event.get("conferenceData"): + event["conferenceData"] = _conference_create_request() + conference_added = True + + if notify is None: + notify = bool(event.get("attendees")) and _is_material_change( + summary=summary, + start=start, + end=end, + location=location, + attendees_added=attendees_added, + conference_added=conference_added, + ) + result = ( service.events() .update( calendarId=calendar_id, eventId=event_id, body=event, - sendUpdates="all" if add_attendees else "none", + sendUpdates="all" if notify else "none", + **update_kwargs, ) .execute() ) @@ -597,9 +716,38 @@ def calendar_update_event( return { "id": result.get("id", ""), "html_link": result.get("htmlLink", ""), + "meet_link": _await_meet_link(service, calendar_id, result) if conference else "", } +def calendar_delete_event( + event_id: str, + calendar_id: str = "primary", + notify: bool = True, +) -> dict: + """Delete a calendar event. + + Args: + event_id: Event ID to delete + calendar_id: Calendar ID (default: primary) + notify: Email the attendees that the event was cancelled. Defaults to + True: a cancellation nobody is told about just leaves the meeting + sitting on everyone's calendar. + + Returns: + Dict with id, deleted + """ + service = get_calendar_service() + + service.events().delete( + calendarId=calendar_id, + eventId=event_id, + sendUpdates="all" if notify else "none", + ).execute() + + return {"id": event_id, "deleted": True} + + def calendar_rsvp( event_id: str, response: str, @@ -2812,6 +2960,7 @@ def calendar_create_event( description: str | None = None, location: str | None = None, attendees: list[str] | None = None, + conference: bool = False, ) -> dict: """Create a calendar event. @@ -2823,9 +2972,10 @@ def calendar_create_event( description: Event description location: Event location attendees: List of attendee emails + conference: Attach a Google Meet conference to the event Returns: - Dict with id, html_link + Dict with id, html_link, meet_link """ return calendar_create_event( summary, @@ -2835,6 +2985,7 @@ def calendar_create_event( description=description, location=location, attendees=attendees, + conference=conference, ) def calendar_update_event( @@ -2847,6 +2998,8 @@ def calendar_update_event( description: str | None = None, location: str | None = None, add_attendees: list[str] | None = None, + conference: bool = False, + notify: bool | None = None, ) -> dict: """Update a calendar event. @@ -2859,9 +3012,12 @@ def calendar_update_event( description: New description location: New location add_attendees: List of attendee emails to add + conference: Attach a Google Meet conference if the event lacks one + notify: Email the attendees; defaults to notifying on a material + change to an event that has attendees Returns: - Dict with id, html_link + Dict with id, html_link, meet_link """ return calendar_update_event( event_id, @@ -2872,8 +3028,28 @@ def calendar_update_event( description=description, location=location, add_attendees=add_attendees, + conference=conference, + notify=notify, ) + def calendar_delete_event( + self, + event_id: str, + calendar_id: str = "primary", + notify: bool = True, + ) -> dict: + """Delete a calendar event. + + Args: + event_id: Event ID to delete + calendar_id: Calendar ID (default: primary) + notify: Email the attendees that the event was cancelled + + Returns: + Dict with id, deleted + """ + return calendar_delete_event(event_id, calendar_id=calendar_id, notify=notify) + def calendar_rsvp( self, event_id: str, diff --git a/tools/productivity/gsuite/test_client.py b/tools/productivity/gsuite/test_client.py index 86b458962b..9192871987 100644 --- a/tools/productivity/gsuite/test_client.py +++ b/tools/productivity/gsuite/test_client.py @@ -1,5 +1,6 @@ import base64 import tomllib +import uuid from pathlib import Path import pytest @@ -1147,3 +1148,364 @@ def test_docs_insert_passes_expected_revision_id_through(monkeypatch): assert len(calls) == 2 assert "writeControl" not in calls[0]["body"] assert calls[1]["body"]["writeControl"] == {"requiredRevisionId": "rev-99"} + + +class _FakeEventsApi: + def __init__(self, insert_result=None, update_result=None, get_results=None): + self.insert_calls: list[dict] = [] + self.update_calls: list[dict] = [] + self.get_calls: list[dict] = [] + self.delete_calls: list[dict] = [] + self._insert_result = insert_result or {} + self._update_result = update_result or {} + self._get_results = list(get_results or []) + + def insert(self, **kwargs): + self.insert_calls.append(kwargs) + return _CreateRequest(self._insert_result) + + def update(self, **kwargs): + self.update_calls.append(kwargs) + return _CreateRequest(self._update_result) + + def get(self, **kwargs): + self.get_calls.append(kwargs) + return _CreateRequest(self._get_results.pop(0) if self._get_results else {}) + + def delete(self, **kwargs): + self.delete_calls.append(kwargs) + return _CreateRequest({}) + + +class _FakeCalendarService: + def __init__(self, **kwargs): + self.events_api = _FakeEventsApi(**kwargs) + + def events(self): + return self.events_api + + +def test_calendar_create_event_without_conference_leaves_body_untouched(monkeypatch): + fake_service = _FakeCalendarService( + insert_result={"id": "event-123", "htmlLink": "https://calendar.google.com/event-123"} + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_create_event("Standup", "2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z") + + insert_call = fake_service.events_api.insert_calls[0] + assert "conferenceData" not in insert_call["body"] + assert "conferenceDataVersion" not in insert_call + assert fake_service.events_api.get_calls == [] + assert result == { + "id": "event-123", + "html_link": "https://calendar.google.com/event-123", + "meet_link": "", + } + + +def test_calendar_create_event_requests_google_meet(monkeypatch): + fake_service = _FakeCalendarService( + insert_result={ + "id": "event-123", + "htmlLink": "https://calendar.google.com/event-123", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + } + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_create_event( + "Standup", "2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z", conference=True + ) + + insert_call = fake_service.events_api.insert_calls[0] + # Without conferenceDataVersion=1 Calendar drops conferenceData silently. + assert insert_call["conferenceDataVersion"] == 1 + create_request = insert_call["body"]["conferenceData"]["createRequest"] + assert create_request["conferenceSolutionKey"] == {"type": "hangoutsMeet"} + assert create_request["requestId"] == uuid.UUID(create_request["requestId"]).hex + assert result["meet_link"] == "https://meet.google.com/abc-defg-hij" + + +def test_calendar_create_event_mints_a_fresh_request_id_per_event(monkeypatch): + fake_service = _FakeCalendarService(insert_result={"id": "event-123"}) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_create_event( + "A", "2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z", conference=True + ) + client.calendar_create_event( + "B", "2026-09-02T09:00:00Z", "2026-09-02T09:30:00Z", conference=True + ) + + request_ids = { + call["body"]["conferenceData"]["createRequest"]["requestId"] + for call in fake_service.events_api.insert_calls + } + assert len(request_ids) == 2 + + +def test_calendar_create_event_refetches_a_pending_meet_link(monkeypatch): + fake_service = _FakeCalendarService( + insert_result={ + "id": "event-123", + "conferenceData": {"createRequest": {"status": {"statusCode": "pending"}}}, + }, + get_results=[ + { + "id": "event-123", + "conferenceData": { + "entryPoints": [ + {"entryPointType": "phone", "uri": "tel:+1-650-555-0100"}, + {"entryPointType": "video", "uri": "https://meet.google.com/abc-defg-hij"}, + ] + }, + } + ], + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_create_event( + "Standup", "2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z", conference=True + ) + + assert len(fake_service.events_api.get_calls) == 1 + assert result["meet_link"] == "https://meet.google.com/abc-defg-hij" + + +def test_calendar_update_event_adds_meet_when_event_has_none(monkeypatch): + fake_service = _FakeCalendarService( + get_results=[{"id": "event-123", "summary": "Standup"}], + update_result={ + "id": "event-123", + "htmlLink": "https://calendar.google.com/event-123", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + }, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_update_event("event-123", conference=True) + + update_call = fake_service.events_api.update_calls[0] + assert update_call["conferenceDataVersion"] == 1 + assert update_call["body"]["conferenceData"]["createRequest"]["conferenceSolutionKey"] == { + "type": "hangoutsMeet" + } + assert result["meet_link"] == "https://meet.google.com/abc-defg-hij" + + +def test_calendar_update_event_keeps_an_existing_conference(monkeypatch): + existing = { + "id": "event-123", + "summary": "Standup", + "conferenceData": { + "conferenceId": "abc-defg-hij", + "entryPoints": [ + {"entryPointType": "video", "uri": "https://meet.google.com/abc-defg-hij"} + ], + }, + } + fake_service = _FakeCalendarService( + get_results=[existing], + update_result={ + "id": "event-123", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + }, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", summary="Standup v2", conference=True) + + conference_data = fake_service.events_api.update_calls[0]["body"]["conferenceData"] + assert "createRequest" not in conference_data + assert conference_data["conferenceId"] == "abc-defg-hij" + + +def test_calendar_update_event_without_conference_omits_the_version_flag(monkeypatch): + fake_service = _FakeCalendarService( + get_results=[{"id": "event-123", "summary": "Standup"}], + update_result={"id": "event-123", "htmlLink": "https://calendar.google.com/event-123"}, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_update_event("event-123", summary="Standup v2") + + assert "conferenceDataVersion" not in fake_service.events_api.update_calls[0] + assert result["meet_link"] == "" + + +def _calendar_service_with_attendee(**kwargs): + defaults = { + "get_results": [ + { + "id": "event-123", + "summary": "Standup", + "attendees": [{"email": "outside@example.com"}], + } + ], + "update_result": {"id": "event-123", "htmlLink": "https://calendar.google.com/event-123"}, + } + defaults.update(kwargs) + return _FakeCalendarService(**defaults) + + +def test_calendar_update_event_notifies_attendees_of_a_time_change(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event( + "event-123", start="2026-09-01T10:00:00Z", end="2026-09-01T11:00:00Z" + ) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_stays_quiet_for_a_description_edit(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", description="typo fixed") + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "none" + + +def test_calendar_update_event_stays_quiet_when_there_are_no_attendees(monkeypatch): + fake_service = _FakeCalendarService( + get_results=[{"id": "event-123", "summary": "Solo focus block"}], + update_result={"id": "event-123"}, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", start="2026-09-01T10:00:00Z") + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "none" + + +def test_calendar_update_event_notify_false_silences_a_material_change(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", start="2026-09-01T10:00:00Z", notify=False) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "none" + + +def test_calendar_update_event_notify_true_announces_a_trivial_change(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", description="typo fixed", notify=True) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_stays_quiet_when_a_field_is_blank(monkeypatch): + # calendar_update_event writes a field only when it is truthy, so "" edits + # nothing. Notifying here would mail everyone about a no-op. + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", location="", summary="") + + update_call = fake_service.events_api.update_calls[0] + assert update_call["sendUpdates"] == "none" + assert update_call["body"]["summary"] == "Standup" + assert "location" not in update_call["body"] + + +def test_calendar_update_event_stays_quiet_when_the_guest_is_already_invited(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", add_attendees=["OUTSIDE@example.com"]) + + update_call = fake_service.events_api.update_calls[0] + assert update_call["sendUpdates"] == "none" + assert update_call["body"]["attendees"] == [{"email": "outside@example.com"}] + + +def test_calendar_update_event_notifies_the_first_guest_on_a_solo_event(monkeypatch): + # The decision reads the merged attendee list, so an event that had nobody + # still notifies the guest it just gained. + fake_service = _FakeCalendarService( + get_results=[{"id": "event-123", "summary": "Solo focus block"}], + update_result={"id": "event-123"}, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", add_attendees=["new@example.com"]) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_appends_a_repeated_new_guest_once(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", add_attendees=["new@example.com", "NEW@example.com"]) + + assert fake_service.events_api.update_calls[0]["body"]["attendees"] == [ + {"email": "outside@example.com"}, + {"email": "new@example.com"}, + ] + + +def test_calendar_update_event_still_notifies_when_adding_attendees(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", add_attendees=["new@example.com"]) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_notifies_when_a_meet_is_added(monkeypatch): + fake_service = _calendar_service_with_attendee( + update_result={"id": "event-123", "hangoutLink": "https://meet.google.com/abc-defg-hij"} + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", conference=True) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_stays_quiet_when_the_meet_already_existed(monkeypatch): + fake_service = _calendar_service_with_attendee( + get_results=[ + { + "id": "event-123", + "summary": "Standup", + "attendees": [{"email": "outside@example.com"}], + "conferenceData": {"conferenceId": "abc-defg-hij"}, + } + ] + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", conference=True) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "none" + + +def test_calendar_delete_event_notifies_attendees_by_default(monkeypatch): + fake_service = _FakeCalendarService() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_delete_event("event-123") + + assert fake_service.events_api.delete_calls == [ + {"calendarId": "primary", "eventId": "event-123", "sendUpdates": "all"} + ] + assert result == {"id": "event-123", "deleted": True} + + +def test_calendar_delete_event_can_cancel_silently(monkeypatch): + fake_service = _FakeCalendarService() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_delete_event("event-123", calendar_id="team@example.com", notify=False) + + assert fake_service.events_api.delete_calls == [ + {"calendarId": "team@example.com", "eventId": "event-123", "sendUpdates": "none"} + ] From cee2a9db85da5c10d7f6ad53dec1e33a329f1bd4 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Thu, 10 Sep 2026 17:04:43 +0800 Subject: [PATCH 8/8] feat(db): c7e_testflight_submissions table for the overlay's feedback recovery One row per TestFlight submission the Cartridge overlay's feedback pipeline has seen: the parsed inbox comment (tester text, screenshots, device), where the inbox comment and the Slack post live, and the triage state the overlay's recovery scan queries by (status, attempts, the failure reason and its timestamps, the dispatch that last claimed it). The overlay writes the row at ingest and on every triage state save; its recovery scan (`c7e_dango_feedback_recovery_scan`, agent#379) reads it with one query instead of walking progress records and state documents. The per-submission state document in company_context_documents stays as the claim's lock target and what the shared readers use. The migration backfills a row per existing state document so earlier failures are visible the moment api-rs starts with it. Fork-only migration, like 0055. No upstream table changes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NCk4NGNcLa52UfFKAYevtH --- .../0056_c7e_testflight_submissions.sql | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0056_c7e_testflight_submissions.sql diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0056_c7e_testflight_submissions.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0056_c7e_testflight_submissions.sql new file mode 100644 index 0000000000..43548c30cf --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0056_c7e_testflight_submissions.sql @@ -0,0 +1,96 @@ +-- TestFlight feedback submissions for the Cartridge overlay +-- (cartridge-gg/agent, docs/dango-feedback-pipeline.md "Recovery"). +-- +-- One row per TestFlight submission the overlay's feedback pipeline has seen: +-- the parsed inbox comment (tester text, screenshots, device), where the +-- inbox comment and the Slack post live, and the triage state the overlay's +-- recovery scan (`c7e_dango_feedback_recovery_scan`) queries by: status, +-- attempts, the failure reason and its timestamps, and the dispatch that +-- last claimed it. The overlay's webhook feature writes the row at ingest, +-- its triage projects every state-document save into it, and the recovery +-- scan reads it with one query. +-- +-- The per-submission state DOCUMENT in company_context_documents stays: it +-- is the claim's advisory-lock target and what the issue resolver, the +-- PostHog loop's shared machinery and the pipeline-monitor board read. This +-- table is the submission registry and the query index, written from the +-- same code path (`_c7e_dango_feedback_common.project_submission_state`). +-- +-- Fork-only migration (like 0055): no upstream table is touched. The +-- backfill seeds a row for every submission that already has a state +-- document, so earlier failures are visible the moment api-rs starts with +-- this migration; the tester text and screenshots of those rows are null and +-- the scan re-reads the inbox comment for them. + +create table if not exists c7e_testflight_submissions ( + submission_id text primary key, + repo text not null default '', + kind text not null default 'feedback', + comment text, -- tester text (null: not captured) + screenshots jsonb, -- list of signed Apple urls + device_model text not null default '', + os_version text not null default '', + app_platform text not null default '', + build_bundle_id text not null default '', + build_id text not null default '', + tester_id text not null default '', + submitted_at text not null default '', -- Apple's createdDate, as posted + inbox_issue_number integer, + inbox_comment_id bigint, + inbox_comment_url text not null default '', + slack_channel_id text not null default '', + slack_message_ts text not null default '', + slack_permalink text not null default '', + status text not null default '', -- '' | in_flight | failed | filed | not_filed + attempts integer not null default 0, + run_id text not null default '', -- the run that last claimed it + dispatch_id text not null default '', -- recovery re-dispatch that superseded a failure + claimed_at timestamptz, + first_failed_at timestamptz, + last_failed_at timestamptz, + last_dispatched_at timestamptz, -- the recovery scan's last re-dispatch + reason text not null default '', + verdict text not null default '', + issue_url text not null default '', + issue_urls jsonb not null default '[]'::jsonb, + received_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists c7e_testflight_submissions_status_idx + on c7e_testflight_submissions (status, updated_at desc); + +-- Backfill from the state documents (never overwrites a row that exists). +insert into c7e_testflight_submissions ( + submission_id, repo, inbox_issue_number, inbox_comment_id, inbox_comment_url, + slack_channel_id, slack_message_ts, slack_permalink, + status, attempts, run_id, dispatch_id, claimed_at, first_failed_at, last_failed_at, + reason, verdict, issue_url, issue_urls, received_at, updated_at +) +select + metadata->>'submission_id', + coalesce(metadata->>'repo', ''), + nullif(metadata->>'inbox_issue_number', '')::integer, + nullif(metadata->>'inbox_comment_id', '')::bigint, + coalesce(metadata->>'inbox_comment_url', ''), + coalesce(metadata->>'slack_channel_id', ''), + coalesce(metadata->>'slack_message_ts', ''), + coalesce(metadata->>'slack_permalink', ''), + coalesce(metadata->>'status', ''), + coalesce(nullif(metadata->>'attempts', '')::integer, 0), + coalesce(metadata->>'run_id', ''), + coalesce(metadata->>'dispatch_id', ''), + nullif(metadata->>'claimed_at', '')::timestamptz, + nullif(metadata->>'first_failed_at', '')::timestamptz, + case when metadata->>'status' = 'failed' + then nullif(metadata->>'state_updated_at', '')::timestamptz end, + coalesce(metadata->>'reason', ''), + coalesce(metadata->>'verdict', ''), + coalesce(metadata->>'issue_url', ''), + coalesce(metadata->'issue_urls', '[]'::jsonb), + coalesce(nullif(metadata->>'source_received_at', '')::timestamptz, occurred_at, now()), + coalesce(nullif(metadata->>'state_updated_at', '')::timestamptz, updated_at, now()) +from company_context_documents +where source_type = 'testflight_feedback_state' + and metadata->>'submission_id' is not null +on conflict (submission_id) do nothing;