37186c3d95
- Convert root to Cargo workspace with crates/ layout - Add deepseek-* crates mirroring Codex architecture - Add parity CI workflow with snapshot/protocol/state tests - Update release workflow to build both deepseek and deepseek-tui binaries - Bump version to 0.3.28
171 lines
4.3 KiB
Rust
171 lines
4.3 KiB
Rust
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result};
|
|
use async_trait::async_trait;
|
|
use chrono::Utc;
|
|
use deepseek_protocol::EventFrame;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum HookEvent {
|
|
ResponseStart {
|
|
response_id: String,
|
|
},
|
|
ResponseDelta {
|
|
response_id: String,
|
|
delta: String,
|
|
},
|
|
ResponseEnd {
|
|
response_id: String,
|
|
},
|
|
ToolLifecycle {
|
|
response_id: String,
|
|
tool_name: String,
|
|
phase: String,
|
|
payload: Value,
|
|
},
|
|
JobLifecycle {
|
|
job_id: String,
|
|
phase: String,
|
|
progress: Option<u8>,
|
|
detail: Option<String>,
|
|
},
|
|
ApprovalLifecycle {
|
|
approval_id: String,
|
|
phase: String,
|
|
reason: Option<String>,
|
|
},
|
|
GenericEventFrame {
|
|
frame: EventFrame,
|
|
},
|
|
}
|
|
|
|
impl HookEvent {
|
|
pub fn to_json(&self) -> Value {
|
|
serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait HookSink: Send + Sync {
|
|
async fn emit(&self, event: &HookEvent) -> Result<()>;
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct StdoutHookSink;
|
|
|
|
#[async_trait]
|
|
impl HookSink for StdoutHookSink {
|
|
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
|
println!("{}", event.to_json());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct JsonlHookSink {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl JsonlHookSink {
|
|
pub fn new(path: PathBuf) -> Self {
|
|
Self { path }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl HookSink for JsonlHookSink {
|
|
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
|
if let Some(parent) = self.path.parent() {
|
|
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
|
format!("failed to create hook log directory {}", parent.display())
|
|
})?;
|
|
}
|
|
let mut file = tokio::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&self.path)
|
|
.await
|
|
.with_context(|| format!("failed to open hook log {}", self.path.display()))?;
|
|
let payload = json!({
|
|
"at": Utc::now().to_rfc3339(),
|
|
"event": event
|
|
});
|
|
let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
|
|
file.write_all(encoded.as_bytes())
|
|
.await
|
|
.context("failed to write hook event")?;
|
|
file.write_all(b"\n")
|
|
.await
|
|
.context("failed to write hook event newline")?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct WebhookHookSink {
|
|
url: String,
|
|
client: reqwest::Client,
|
|
}
|
|
|
|
impl WebhookHookSink {
|
|
pub fn new(url: String) -> Self {
|
|
Self {
|
|
url,
|
|
client: reqwest::Client::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl HookSink for WebhookHookSink {
|
|
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
|
let mut retries = 0usize;
|
|
loop {
|
|
let resp = self
|
|
.client
|
|
.post(&self.url)
|
|
.json(&json!({
|
|
"at": Utc::now().to_rfc3339(),
|
|
"event": event,
|
|
}))
|
|
.send()
|
|
.await;
|
|
match resp {
|
|
Ok(response) if response.status().is_success() => return Ok(()),
|
|
Ok(response) => {
|
|
if retries >= 2 {
|
|
anyhow::bail!("webhook returned non-success status {}", response.status());
|
|
}
|
|
}
|
|
Err(err) => {
|
|
if retries >= 2 {
|
|
return Err(err).context("webhook request failed");
|
|
}
|
|
}
|
|
}
|
|
retries += 1;
|
|
tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
pub struct HookDispatcher {
|
|
sinks: Vec<Arc<dyn HookSink>>,
|
|
}
|
|
|
|
impl HookDispatcher {
|
|
pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
|
|
self.sinks.push(sink);
|
|
}
|
|
|
|
pub async fn emit(&self, event: HookEvent) {
|
|
for sink in &self.sinks {
|
|
let _ = sink.emit(&event).await;
|
|
}
|
|
}
|
|
}
|