Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions .github/workflows/voltage-agent-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ name: Voltage images
# buzz-agent voltage/images/buzz-agent/Dockerfile → voltage-buzz-agent-staging
# buzz-sandbox-reaper voltage/images/buzz-sandbox-reaper/Dockerfile → voltage-buzz-sandbox-reaper-staging
# buzz-relay the upstream root Dockerfile (target runtime) → buzz-relay
# buzz-ingest voltage/images/buzz-ingest/Dockerfile → buzz-ingest
#
# The changes job selects only the images whose inputs changed. Manual runs
# (workflow_dispatch) build all three, or only buzz-agent with agent_only.
# (workflow_dispatch) build all images, or only buzz-agent with agent_only.
#
# Trust model: this repository and its logs are public. Pull requests run
# without AWS access. Only the publish and merge jobs request an OIDC token,
Expand Down Expand Up @@ -131,6 +132,15 @@ jobs:
- 'examples/**'
- 'voltage/images/buzz-sandbox-reaper/**'
- '.github/workflows/voltage-agent-images.yml'
ingest:
- '.cargo/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'crates/**'
- 'examples/**'
- 'voltage/images/buzz-ingest/**'
- '.github/workflows/voltage-agent-images.yml'
relay:
- 'Dockerfile'
- '.dockerignore'
Expand All @@ -157,16 +167,19 @@ jobs:
AGENT: ${{ steps.filter.outputs.agent }}
REAPER: ${{ steps.filter.outputs.reaper }}
RELAY: ${{ steps.filter.outputs.relay }}
INGEST: ${{ steps.filter.outputs.ingest }}
run: |
set -euo pipefail
selected=()
if [ "$ALL" = true ] || [ "$AGENT_ONLY" = true ] || [ "$AGENT" = true ]; then selected+=(buzz-agent); fi
if [ "$ALL" = true ] || [ "$REAPER" = true ]; then selected+=(buzz-sandbox-reaper); fi
if [ "$ALL" = true ] || [ "$RELAY" = true ]; then selected+=(buzz-relay); fi
if [ "$ALL" = true ] || [ "$INGEST" = true ]; then selected+=(buzz-ingest); fi
images='{
"buzz-agent": {"file": "voltage/images/buzz-agent/Dockerfile", "target": "", "repository": "voltage-buzz-agent-staging"},
"buzz-sandbox-reaper": {"file": "voltage/images/buzz-sandbox-reaper/Dockerfile", "target": "", "repository": "voltage-buzz-sandbox-reaper-staging"},
"buzz-relay": {"file": "Dockerfile", "target": "runtime", "repository": "buzz-relay"}
"buzz-relay": {"file": "Dockerfile", "target": "runtime", "repository": "buzz-relay"},
"buzz-ingest": {"file": "voltage/images/buzz-ingest/Dockerfile", "target": "", "repository": "buzz-ingest"}
}'
platforms='[{"arch": "amd64", "runner": "ubuntu-latest"}, {"arch": "arm64", "runner": "ubuntu-24.04-arm"}]'
build=$(jq -cn --argjson images "$images" --argjson platforms "$platforms" \
Expand Down Expand Up @@ -208,7 +221,7 @@ jobs:
run: |
set -euo pipefail
sh voltage/images/buzz-agent/select-skills.sh --none "$RUNNER_TEMP/engineering-skills"
for file in voltage/images/buzz-agent/Dockerfile voltage/images/buzz-sandbox-reaper/Dockerfile Dockerfile; do
for file in voltage/images/buzz-agent/Dockerfile voltage/images/buzz-sandbox-reaper/Dockerfile voltage/images/buzz-ingest/Dockerfile Dockerfile; do
echo "==> $file"
docker buildx build --check --build-context engineering-skills="$RUNNER_TEMP/engineering-skills" -f "$file" .
done
Expand Down Expand Up @@ -261,6 +274,9 @@ jobs:
docker run --rm "$IMAGE" --help
docker run --rm "$IMAGE" reconcile --help
docker run --rm "$IMAGE" serve --help | grep -- '--mention-launch'
- name: Ingest smoke test
if: matrix.image == 'buzz-ingest'
run: bash voltage/images/buzz-ingest/test-image.sh "$IMAGE"
- name: Relay smoke test
if: matrix.image == 'buzz-relay'
run: bash voltage/images/test-relay-image.sh "$IMAGE"
Expand Down Expand Up @@ -414,6 +430,9 @@ jobs:
run: |
docker run --rm "$IMAGE" --help
docker run --rm "$IMAGE" serve --help | grep -- '--mention-launch'
- name: Ingest smoke test
if: matrix.image == 'buzz-ingest'
run: bash voltage/images/buzz-ingest/test-image.sh "$IMAGE"
- name: Relay smoke test
if: matrix.image == 'buzz-relay'
run: bash voltage/images/test-relay-image.sh "$IMAGE"
Expand Down
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ members = [
"crates/git-credential-nostr",
"crates/git-sign-nostr",
"crates/buzz-pair-relay",
"crates/buzz-ingest",
"crates/buzz-relay-mesh",
"crates/buzz-mesh-smoke",
"crates/buzz-dev-mcp",
Expand Down
210 changes: 210 additions & 0 deletions crates/buzz-core/src/integration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! NIP-IG integration event tags — the shared contract between integration
//! publishers (such as `buzz-ingest`) and consumers (the workflow engine).
//!
//! An integration event is an ordinary channel event (a kind:9 message, or a
//! kind:30623 [`crate::kind::KIND_INTEGRATION_RECORD`]) that mirrors an
//! occurrence in an external system: a Linear issue comment, a GitHub check
//! run, a Slack message. Its typed metadata lives in `ext-*` tags:
//!
//! ```text
//! ["ext-source", "linear"] provider
//! ["ext-action", "issue.labeled"] normalized "<object>.<verb>"
//! ["ext-id", "<provider object id>"]
//! ["ext-url", "https://linear.app/..."]
//! ["ext-actor", "<provider login or id>"]
//! ["ext-thread", "<thread key>"]
//! ["ext-key", "<dedup key>"]
//! ["ext-labels", "agent"] repeatable
//! ```
//!
//! Consumers read these generically: every `ext-<name>` tag becomes the field
//! `<name>` (dashes folded to underscores), and repeated tags join with `,`.
//! Nothing here knows about any provider. See `docs/nips/NIP-IG.md`.
//!
//! The tags are signer-controlled metadata. A consumer that acts on them must
//! first check that the signer is a trusted integration publisher.

use std::collections::BTreeMap;

/// Prefix shared by every integration metadata tag.
pub const EXT_TAG_PREFIX: &str = "ext-";
/// Provider name, for example `github`, `linear`, or `slack`.
pub const TAG_SOURCE: &str = "ext-source";
/// Normalized action, `<object>.<verb>`, for example `pull_request.opened`.
pub const TAG_ACTION: &str = "ext-action";
/// Provider-side identifier of the object the occurrence is about.
pub const TAG_ID: &str = "ext-id";
/// Canonical provider URL of the object.
pub const TAG_URL: &str = "ext-url";
/// Provider-side actor (login, user id) who caused the occurrence.
pub const TAG_ACTOR: &str = "ext-actor";
/// Key of the external thread (issue, pull request, Slack thread).
pub const TAG_THREAD: &str = "ext-thread";
/// Stable dedup key of the occurrence; equal to the record's `d` tag.
pub const TAG_KEY: &str = "ext-key";
/// Repository, as `owner/name` (GitHub).
pub const TAG_REPO: &str = "ext-repo";
/// Team or workspace key (Linear team key, Slack team id).
pub const TAG_TEAM: &str = "ext-team";
/// Human reference, for example `ENG-123` or `owner/repo#42`.
pub const TAG_REF: &str = "ext-ref";
/// Title of the object the thread mirrors.
pub const TAG_TITLE: &str = "ext-title";
/// Current workflow state (Linear state name, GitHub open/closed/merged).
pub const TAG_STATE: &str = "ext-state";
/// A label currently on the object. Repeatable.
pub const TAG_LABEL: &str = "ext-labels";
/// A label this occurrence added. Repeatable.
pub const TAG_ADDED_LABEL: &str = "ext-added-labels";
/// CI conclusion (`success`, `failure`, ...).
pub const TAG_CONCLUSION: &str = "ext-conclusion";
/// Review state (`approved`, `changes_requested`, `commented`).
pub const TAG_REVIEW_STATE: &str = "ext-review-state";
/// Provider channel id (Slack channel).
pub const TAG_CHANNEL: &str = "ext-channel";

/// On a kind:30623 record: the id of the kind:9 message that renders it.
///
/// Deliberately not an `e` tag. The record is written before its message
/// (so a retry can never orphan a message), and an `e` tag would make the
/// relay resolve NIP-10 thread metadata against an event that does not exist
/// yet.
pub const TAG_MESSAGE: &str = "message";

/// Most `ext-*` fields read from one event. Bounds work on a hostile event.
pub const MAX_FIELDS: usize = 32;

/// Most bytes kept per field value, after joining repeated tags.
pub const MAX_FIELD_BYTES: usize = 4096;

/// Read every `ext-*` tag of `tags` into a field map.
///
/// - `ext-added-labels` becomes the key `added_labels`.
/// - Repeated tags join their values with `,` in tag order.
/// - Names that would not be a plain identifier (ASCII letters, digits,
/// underscore after folding) are skipped, so every key is usable as an
/// evalexpr variable suffix.
/// - At most [`MAX_FIELDS`] keys; values are cut at [`MAX_FIELD_BYTES`] on a
/// character boundary.
pub fn ext_fields(tags: &nostr::Tags) -> BTreeMap<String, String> {
let mut fields: BTreeMap<String, String> = BTreeMap::new();
for tag in tags.iter() {
let parts = tag.as_slice();
let (Some(name), Some(value)) = (parts.first(), parts.get(1)) else {
continue;
};
let Some(raw_key) = name.strip_prefix(EXT_TAG_PREFIX) else {
continue;
};
let key = raw_key.replace('-', "_");
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
continue;
}
if !fields.contains_key(&key) && fields.len() >= MAX_FIELDS {
continue;
}
let slot = fields.entry(key).or_default();
if !slot.is_empty() {
slot.push(',');
}
slot.push_str(value);
truncate_on_char_boundary(slot, MAX_FIELD_BYTES);
}
fields
}

/// True when `tags` carry both an `ext-source` and an `ext-action` value.
pub fn is_integration_event(tags: &nostr::Tags) -> bool {
let has = |name: &str| {
tags.iter().any(|t| {
let parts = t.as_slice();
parts.first().map(String::as_str) == Some(name)
&& parts.get(1).is_some_and(|v| !v.is_empty())
})
};
has(TAG_SOURCE) && has(TAG_ACTION)
}

fn truncate_on_char_boundary(s: &mut String, max: usize) {
if s.len() <= max {
return;
}
let mut cut = max;
while !s.is_char_boundary(cut) {
cut -= 1;
}
s.truncate(cut);
}

#[cfg(test)]
mod tests {
use super::*;

fn tags(raw: &[&[&str]]) -> nostr::Tags {
nostr::Tags::from_list(
raw.iter()
.map(|t| nostr::Tag::parse(t.iter().copied()).unwrap())
.collect(),
)
}

#[test]
fn ext_fields_folds_names_and_joins_repeats() {
let t = tags(&[
&["h", "chan"],
&["ext-source", "linear"],
&["ext-action", "issue.labeled"],
&["ext-labels", "agent"],
&["ext-labels", "bug"],
&["ext-added-labels", "agent"],
&["message", "abc"],
]);
let f = ext_fields(&t);
assert_eq!(f.get("source").map(String::as_str), Some("linear"));
assert_eq!(f.get("action").map(String::as_str), Some("issue.labeled"));
assert_eq!(f.get("labels").map(String::as_str), Some("agent,bug"));
assert_eq!(f.get("added_labels").map(String::as_str), Some("agent"));
assert!(!f.contains_key("h"));
assert!(!f.contains_key("message"));
}

#[test]
fn ext_fields_skips_non_identifier_names_and_bounds_output() {
let mut raw: Vec<Vec<String>> = vec![
vec!["ext-".into(), "empty".into()],
vec!["ext-a.b".into(), "dot".into()],
vec!["ext-bad name".into(), "space".into()],
];
for i in 0..(MAX_FIELDS + 5) {
raw.push(vec![format!("ext-f{i}"), "v".into()]);
}
raw.push(vec!["ext-big".into(), "é".repeat(MAX_FIELD_BYTES)]);
let t = nostr::Tags::from_list(
raw.iter()
.map(|t| nostr::Tag::parse(t.iter().map(String::as_str)).unwrap())
.collect(),
);
let f = ext_fields(&t);
assert_eq!(f.len(), MAX_FIELDS);
assert!(f.keys().all(|k| !k.is_empty() && !k.contains('.')));

let big = tags(&[&["ext-big", &"é".repeat(MAX_FIELD_BYTES)]]);
let f = ext_fields(&big);
let v = f.get("big").unwrap();
assert!(v.len() <= MAX_FIELD_BYTES);
assert!(v.chars().all(|c| c == 'é'));
}

#[test]
fn is_integration_event_requires_source_and_action() {
assert!(is_integration_event(&tags(&[
&["ext-source", "github"],
&["ext-action", "pull_request.opened"],
])));
assert!(!is_integration_event(&tags(&[&["ext-source", "github"]])));
assert!(!is_integration_event(&tags(&[
&["ext-source", "github"],
&["ext-action", ""],
])));
}
}
14 changes: 14 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,18 @@ pub const KIND_WORKFLOW_DEF: u32 = 30620;
/// `hidden_at` per viewer; this is the only Nostr-visible projection of it.
pub const KIND_DM_VISIBILITY: u32 = 30622;

/// NIP-IG: integration record (parameterized replaceable, d=dedup key).
///
/// Published by an integration publisher such as `buzz-ingest` for every
/// external occurrence (a Linear comment, a GitHub check run, a Slack message)
/// and every external thread it mirrors. The `d` tag is the stable dedup key,
/// so a redelivered provider webhook resolves to the same record instead of a
/// second message. The `message` tag names the kind:9 event that renders the
/// occurrence in the channel. The record content holds a bounded base64
/// write-ahead plan; ordinary message searches select kind 9. Requires an `h` tag. See
/// `docs/nips/NIP-IG.md`.
pub const KIND_INTEGRATION_RECORD: u32 = 30623;

/// Lower bound of the NIP-33 parameterized replaceable range (30000–39999).
pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000;
/// Upper bound of the NIP-33 parameterized replaceable range (30000–39999).
Expand Down Expand Up @@ -842,6 +854,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
KIND_PROJECT,
KIND_INTEGRATION_RECORD,
];

/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
Expand Down Expand Up @@ -947,6 +960,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_INTEGRATION_RECORD)); // 30623 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999

Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub mod event;
pub mod filter;
/// Git permission types — ref patterns, protection rules, policy evaluation.
pub mod git_perms;
/// NIP-IG integration event tags (`ext-*`) shared by publishers and consumers.
pub mod integration;
/// Shared invite-link contract constants.
pub mod invite;
/// Buzz kind number registry — custom event type constants.
Expand Down
Loading
Loading