Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Cargo.lock

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

13 changes: 13 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,19 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# completion_sound = "beep"
# sound_file = "E:\\google\\downloads\\notify.wav"

# Lifecycle event outbox: opt-in JSONL stream of session/turn/subagent
# lifecycle events for supervisors and automation harnesses. One JSON line
# per event (RuntimeEventEnvelope schema), appended and flushed on every
# emit; seq is monotonic per file and recovers from the last line on open.
# UNCOMMENT `path` TO ENABLE — unset/empty = OFF = behavior unchanged.
# Fires for interactive TUI sessions AND headless `codewhale exec` runs.
# See docs/CONFIGURATION.md → Lifecycle Outbox for the file contract.
# [lifecycle_outbox]
# path = "~/.codewhale/notifications/outbox.jsonl"
# webhook_url = "https://example.com/hooks/codewhale" # optional: POST {"at", "event"} JSON per event
# webhook_token = "" # optional: sent as `Authorization: Bearer <token>`
# # delivery is best-effort: failures are logged and dropped

# Opt-in per-event sound cues (#4817): deterministic, terminal-bell level
# (BEL bytes only — functional signals, platform-safe no-op when the terminal
# ignores BEL). Off by default. When completion_sound is active, turn-complete
Expand Down
29 changes: 29 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,10 @@ pub struct ConfigToml {
/// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
#[serde(default)]
pub hook_sinks: Option<HookSinksToml>,
/// Lifecycle event outbox (`[lifecycle_outbox]`). Opt-in: an unset or
/// empty `path` disables the feature and leaves behavior unchanged.
#[serde(default)]
pub lifecycle_outbox: Option<LifecycleOutboxToml>,
/// Agent Fleet trust and security policy (#3165). When absent, fleet
/// workers inherit conservative Sandbox defaults.
#[serde(default)]
Expand Down Expand Up @@ -1553,6 +1557,31 @@ pub struct HookSinksToml {
pub unix_socket_path: Option<PathBuf>,
}

/// On-disk schema for the `[lifecycle_outbox]` table.
///
/// Opt-in lifecycle event outbox: every emitted event is appended as one
/// JSONL line to `path` in the `RuntimeEventEnvelope` shape
/// (`schema_version, seq, event, kind, thread_id, turn_id, item_id,
/// timestamp, payload`), and optionally POSTed to `webhook_url`. An unset or
/// empty `path` disables the feature entirely — behavior is unchanged from a
/// release without the table.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LifecycleOutboxToml {
/// Path to the JSONL outbox file. Parent directories are created lazily
/// on the first event. Unset or empty = feature OFF.
#[serde(default)]
pub path: Option<PathBuf>,
/// Optional webhook URL. Events are POSTed as `{"at", "event"}` JSON
/// only when this is set (in addition to, never instead of, `path`).
/// Delivery is best-effort: failures are logged and dropped.
#[serde(default)]
pub webhook_url: Option<String>,
/// Optional bearer token sent as `Authorization: Bearer <token>` on
/// webhook POSTs. Ignored when `webhook_url` is unset.
#[serde(default)]
pub webhook_token: Option<String>,
}

/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
/// for documentation.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down
72 changes: 72 additions & 0 deletions crates/config/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,78 @@ fn verifier_config_rejects_unknown_verdict_policy() {
);
}

#[test]
fn lifecycle_outbox_toml_is_off_by_default_and_parses_when_configured() {
// Unset = feature OFF: the table is absent and the field is None.
let absent: ConfigToml = toml::from_str("model = \"demo\"\n").expect("minimal config");
assert!(
absent.lifecycle_outbox.is_none(),
"unset [lifecycle_outbox] must leave the feature off"
);

// An empty table is also off: no path means no outbox file.
let empty: ConfigToml =
toml::from_str("[lifecycle_outbox]\n").expect("empty lifecycle_outbox table");
let outbox = empty.lifecycle_outbox.expect("table should parse");
assert!(outbox.path.is_none());
assert!(outbox.webhook_url.is_none());
assert!(outbox.webhook_token.is_none());

// Full configuration: path plus optional webhook url and token.
let full: ConfigToml = toml::from_str(
r#"
[lifecycle_outbox]
path = "~/.codewhale/notifications/outbox.jsonl"
webhook_url = "https://example.com/hooks/codewhale"
webhook_token = "secret-token"
"#,
)
.expect("full lifecycle_outbox table");
let outbox = full.lifecycle_outbox.expect("table should parse");
assert_eq!(
outbox.path,
Some(PathBuf::from("~/.codewhale/notifications/outbox.jsonl"))
);
assert_eq!(
outbox.webhook_url.as_deref(),
Some("https://example.com/hooks/codewhale")
);
assert_eq!(outbox.webhook_token.as_deref(), Some("secret-token"));
}

#[test]
fn lifecycle_outbox_toml_webhook_is_optional() {
// `path` alone enables the file outbox without any webhook.
let file_only: ConfigToml = toml::from_str(
r#"
[lifecycle_outbox]
path = "/tmp/outbox.jsonl"
"#,
)
.expect("file-only lifecycle_outbox table");
let outbox = file_only.lifecycle_outbox.expect("table should parse");
assert_eq!(outbox.path, Some(PathBuf::from("/tmp/outbox.jsonl")));
assert!(outbox.webhook_url.is_none());

// A webhook url without a path does not enable a file outbox; the
// consumer decides whether webhook-only delivery is meaningful, but the
// parse must stay lossless either way.
let webhook_only: ConfigToml = toml::from_str(
r#"
[lifecycle_outbox]
webhook_url = "https://example.com/hooks/codewhale"
"#,
)
.expect("webhook-only lifecycle_outbox table");
let outbox = webhook_only.lifecycle_outbox.expect("table should parse");
assert!(outbox.path.is_none());
assert_eq!(
outbox.webhook_url.as_deref(),
Some("https://example.com/hooks/codewhale")
);
assert!(outbox.webhook_token.is_none());
}

#[test]
fn permissions_toml_deserializes_typed_ask_rules() {
let permissions: PermissionsToml = toml::from_str(
Expand Down
5 changes: 5 additions & 0 deletions crates/hooks/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true

[dev-dependencies]
tempfile.workspace = true
wiremock = "0.6"
53 changes: 40 additions & 13 deletions crates/hooks/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::io::AsyncWriteExt;

mod lifecycle_outbox;

pub use lifecycle_outbox::{
LifecycleEvent, LifecycleOutbox, OUTBOX_DETAIL_MAX_CHARS, OUTBOX_HEADLINE_MAX_CHARS,
OUTBOX_PREVIEW_MAX_CHARS, OUTBOX_TRUNCATION_MARKER, bounded_text,
};

/// All events that can be emitted through the hook system.
///
/// Each variant represents a distinct lifecycle or streaming event. The enum is
Expand Down Expand Up @@ -196,16 +203,26 @@ impl HookSink for JsonlHookSink {
/// The request body is `{"at": "<ISO 8601 timestamp>", "event": {...}}`.
/// Failed requests are retried up to 2 times with exponential back-off
/// (200 ms, 400 ms). After exhausting retries the error is propagated.
#[derive(Clone)]
pub struct WebhookHookSink {
url: String,
/// Optional bearer token sent as `Authorization: Bearer <token>`.
bearer_token: Option<String>,
client: reqwest::Client,
}

impl WebhookHookSink {
/// Create a new sink that sends events to the given `url`.
pub fn new(url: String) -> Self {
Self::new_with_token(url, None)
}

/// Create a new sink that sends events to the given `url`, attaching
/// `Authorization: Bearer <token>` when a token is provided.
pub fn new_with_token(url: String, bearer_token: Option<String>) -> Self {
Self {
url,
bearer_token,
client: codewhale_release::platform_http_client_builder()
.timeout(std::time::Duration::from_secs(10))
.build()
Expand All @@ -216,22 +233,21 @@ impl WebhookHookSink {
}),
}
}
}

#[async_trait]
impl HookSink for WebhookHookSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
/// POST an arbitrary JSON payload to the configured endpoint.
///
/// This is the shared delivery path behind both [`HookSink::emit`] and
/// the lifecycle outbox fan-out. It is deliberately not part of the
/// [`HookSink`] trait: outbox events are runtime event envelopes, not
/// [`HookEvent`]s, and only the transport needs to be shared.
pub async fn post_payload(&self, payload: serde_json::Value) -> Result<()> {
let mut retries = 0usize;
loop {
let resp = self
.client
.post(&self.url)
.json(&json!({
"at": Utc::now().to_rfc3339(),
"event": event,
}))
.send()
.await;
let mut request = self.client.post(&self.url).json(&payload);
if let Some(token) = self.bearer_token.as_deref().filter(|t| !t.is_empty()) {
request = request.bearer_auth(token);
}
let resp = request.send().await;
match resp {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(response) => {
Expand All @@ -251,6 +267,17 @@ impl HookSink for WebhookHookSink {
}
}

#[async_trait]
impl HookSink for WebhookHookSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
self.post_payload(json!({
"at": Utc::now().to_rfc3339(),
"event": event,
}))
.await
}
}

/// A [`HookSink`] that sends events over a Unix domain socket.
///
/// Each event is serialized as a single JSON line (`{"at": "...", "event": {...}}\n`)
Expand Down
Loading
Loading