Skip to content

Commit 46cc439

Browse files
hyperpolymathclaude
andcommitted
feat(echidnabot): submitProofObligation + session persistence + port 9001
Closes B1/B3a/H2a session work: src/api/graphql.rs: - submit_proof_obligation mutation: ProofObligationInput { repo, claim, context, prover: Option<ProverKind> } → ProofObligationResult - Persists ProofObligationRecord FIRST (source of truth for claim/context), then creates ProofJob with opaque obligation reference, back-fills job_id src/fleet/mod.rs: fixed B1 session-persistence TODO — saves ctx via ContextStorage::default().save_context() with warn-level logging on failure src/store/{mod,models,sqlite}.rs: proof_obligations table DDL + ObligationRow helpers + create_obligation/get_obligation/link_obligation_to_job trait methods src/config.rs + src/main.rs: default port 8080 → 9001 (resolves collision with verisimdb + gitbot-fleet) tests/webhook_e2e_test.rs (new, 8 tests): real axum-test TestServer, real HMAC, in-memory SQLite, wiremock stubs for echidna + GitHub Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bcbf6f4 commit 46cc439

10 files changed

Lines changed: 882 additions & 9 deletions

File tree

bots/echidnabot/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bots/echidnabot/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ axum-test = "18"
8282
mockall = "0.14"
8383
tempfile = "3"
8484
wiremock = "0.6"
85+
bytes = "1"
8586

8687
[profile.release]
8788
lto = true

bots/echidnabot/src/api/graphql.rs

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use crate::dispatcher::{
1414
};
1515
use crate::dispatcher::echidna_client::ProverStatus as CoreProverStatus;
1616
use crate::scheduler::{JobPriority, JobScheduler};
17-
use crate::store::models::{ProofJobRecord, Repository as StoreRepository};
17+
use crate::store::models::{ProofJobRecord, ProofObligationRecord, Repository as StoreRepository};
1818
use crate::store::Store;
1919

2020
/// GraphQL schema type
@@ -277,6 +277,30 @@ pub struct RegisterRepoInput {
277277
pub enabled_provers: Option<Vec<ProverKind>>,
278278
}
279279

280+
/// Input for submitting a proof obligation from an external scanner (e.g. hypatia)
281+
#[derive(async_graphql::InputObject)]
282+
pub struct ProofObligationInput {
283+
/// Repository in "owner/name" format
284+
pub repo: String,
285+
/// Human-readable claim that must be proved
286+
pub claim: String,
287+
/// Original context/description from pattern detection
288+
pub context: String,
289+
/// Optional prover hint. When supplied (e.g. by hypatia's proof-strategy
290+
/// rule picking the historically-best prover for this obligation class),
291+
/// it overrides the default. Omitted → default Lean.
292+
pub prover: Option<ProverKind>,
293+
}
294+
295+
/// Result of submitting a proof obligation
296+
#[derive(SimpleObject, Clone)]
297+
pub struct ProofObligationResult {
298+
/// Whether the obligation was successfully recorded
299+
pub success: bool,
300+
/// Scheduler job ID assigned to this obligation (UUID string), or empty on failure
301+
pub proof_id: String,
302+
}
303+
280304
/// Input for repository settings
281305
#[derive(async_graphql::InputObject)]
282306
pub struct RepoSettingsInput {
@@ -371,6 +395,114 @@ impl MutationRoot {
371395
Ok(ProofJobRecord::from(job).into())
372396
}
373397

398+
/// Record a proof obligation submitted by an external tool (e.g. hypatia fleet_dispatcher).
399+
///
400+
/// This mutation RECORDS the obligation as a pending scheduler job — it does NOT immediately
401+
/// invoke a prover. The scheduler will pick it up on its normal cycle. The repo must already
402+
/// be registered with echidnabot; if it is not, this returns `success: false`.
403+
///
404+
/// The `repo` argument must be in "owner/name" format. The mutation searches all platforms
405+
/// for a matching registered repository and uses the first match found.
406+
async fn submit_proof_obligation(
407+
&self,
408+
ctx: &Context<'_>,
409+
input: ProofObligationInput,
410+
) -> async_graphql::Result<ProofObligationResult> {
411+
let state = ctx.data::<GraphQLState>()?;
412+
413+
// Parse "owner/name" format
414+
let (owner, name) = {
415+
let parts: Vec<&str> = input.repo.splitn(2, '/').collect();
416+
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
417+
return Ok(ProofObligationResult {
418+
success: false,
419+
proof_id: String::new(),
420+
});
421+
}
422+
(parts[0].to_string(), parts[1].to_string())
423+
};
424+
425+
// Find a matching registered repository across all platforms
426+
let repos = state
427+
.store
428+
.list_repositories(None)
429+
.await
430+
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
431+
432+
let repo = repos
433+
.into_iter()
434+
.find(|r| r.owner == owner && r.name == name);
435+
436+
let repo = match repo {
437+
Some(r) => r,
438+
None => {
439+
return Ok(ProofObligationResult {
440+
success: false,
441+
proof_id: String::new(),
442+
});
443+
}
444+
};
445+
446+
let chosen_prover = input
447+
.prover
448+
.map(map_prover_kind_to_core)
449+
.unwrap_or(CoreProverKind::Lean);
450+
451+
// Derive a human-readable hint string from the chosen prover so that
452+
// the obligation row is self-contained (e.g. a future query can show
453+
// "which prover was requested" without joining proof_jobs).
454+
let prover_hint = Some(format!("{:?}", chosen_prover).to_lowercase());
455+
456+
// 1. Persist the obligation FIRST — it is the source of truth for claim/context.
457+
let obligation = ProofObligationRecord::new(
458+
repo.id,
459+
input.claim,
460+
input.context,
461+
prover_hint,
462+
);
463+
state
464+
.store
465+
.create_obligation(&obligation)
466+
.await
467+
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
468+
469+
// 2. Create a scheduler job whose file_paths carry an opaque reference to the
470+
// obligation row. Nothing in file_paths encodes claim/context strings.
471+
let job = crate::scheduler::ProofJob::new(
472+
repo.id,
473+
"manual-obligation".to_string(),
474+
chosen_prover,
475+
vec![format!("obligation:{}", obligation.id)],
476+
)
477+
.with_priority(JobPriority::Normal);
478+
479+
// 3. Persist job record, then enqueue.
480+
let record = ProofJobRecord::from(job.clone());
481+
state
482+
.store
483+
.create_job(&record)
484+
.await
485+
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
486+
487+
let _ = state
488+
.scheduler
489+
.enqueue(job.clone())
490+
.await
491+
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
492+
493+
// 4. Back-fill the job ID onto the obligation row so it can be followed.
494+
state
495+
.store
496+
.link_obligation_to_job(&obligation.id.to_string(), &job.id.0.to_string())
497+
.await
498+
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
499+
500+
Ok(ProofObligationResult {
501+
success: true,
502+
proof_id: job.id.0.to_string(),
503+
})
504+
}
505+
374506
/// Request ML-powered tactic suggestions
375507
async fn request_suggestions(
376508
&self,

bots/echidnabot/src/config.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ fn default_host() -> String {
7171
}
7272

7373
fn default_port() -> u16 {
74-
8080
74+
// 9001 is echidnabot's canonical port in the hyperpolymath port map.
75+
// (9000 = echidna core, 9001 = echidnabot, 9090 = hypatia.)
76+
// Was 8080 which collided with verisimdb and gitbot-fleet.
77+
9001
7578
}
7679

7780
#[derive(Debug, Deserialize, Clone)]

bots/echidnabot/src/fleet/mod.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
1212
use crate::error::{Error, Result};
1313
use crate::scheduler::{JobResult, ProofJob};
14-
use gitbot_shared_context::{BotId, Context, Finding, Severity};
14+
use gitbot_shared_context::{BotId, Context, ContextStorage, Finding, Severity};
1515
use std::path::PathBuf;
16-
use tracing::{debug, info};
16+
use tracing::{debug, info, warn};
1717

1818
/// Fleet coordinator for echidnabot
1919
pub struct FleetCoordinator {
@@ -53,7 +53,19 @@ impl FleetCoordinator {
5353
ctx.complete_bot(BotId::Echidnabot, findings_count, errors_count, files_analyzed)
5454
.map_err(|e| Error::Internal(format!("Failed to complete bot: {}", e)))?;
5555

56-
// TODO: Persist context to ~/.gitbot-fleet/sessions/
56+
// Persist context to ~/.gitbot-fleet/sessions/<session_id>.json so the
57+
// fleet-coordinator and other bots can pick up echidnabot findings after
58+
// this bot's process has exited. Persistence failure is logged but does
59+
// NOT fail disconnect — losing a session log is less bad than crashing
60+
// a running bot. Callers that need strict durability should call the
61+
// persistence API directly.
62+
match ContextStorage::default() {
63+
Ok(storage) => match storage.save_context(ctx) {
64+
Ok(path) => info!(path = %path.display(), "Persisted session context"),
65+
Err(e) => warn!("Failed to persist session context: {}", e),
66+
},
67+
Err(e) => warn!("Failed to open session storage: {}", e),
68+
}
5769
}
5870

5971
self.context = None;

bots/echidnabot/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ enum Commands {
4747
#[arg(short = 'H', long, default_value = "0.0.0.0")]
4848
host: String,
4949

50-
/// Port to bind to
51-
#[arg(short, long, default_value = "8080")]
50+
/// Port to bind to (canonical: 9001)
51+
#[arg(short, long, default_value = "9001")]
5252
port: u16,
5353
},
5454

bots/echidnabot/src/store/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use uuid::Uuid;
1313
use crate::adapters::Platform;
1414
use crate::error::Result;
1515
use crate::scheduler::JobId;
16-
use models::{Repository, ProofJobRecord, ProofResultRecord};
16+
use models::{Repository, ProofJobRecord, ProofObligationRecord, ProofResultRecord};
1717

1818
/// Abstract store trait for different database backends
1919
#[async_trait]
@@ -42,6 +42,11 @@ pub trait Store: Send + Sync {
4242
async fn save_result(&self, result: &ProofResultRecord) -> Result<()>;
4343
async fn get_result_for_job(&self, job_id: JobId) -> Result<Option<ProofResultRecord>>;
4444

45+
// Proof obligation operations
46+
async fn create_obligation(&self, obligation: &ProofObligationRecord) -> Result<()>;
47+
async fn get_obligation(&self, obligation_id: &str) -> Result<Option<ProofObligationRecord>>;
48+
async fn link_obligation_to_job(&self, obligation_id: &str, job_id: &str) -> Result<()>;
49+
4550
// Utility
4651
async fn health_check(&self) -> Result<bool>;
4752
}

bots/echidnabot/src/store/models.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,55 @@ impl ProofResultRecord {
117117
}
118118
}
119119

120+
/// Proof obligation record — the canonical source of truth for claim/context/prover_hint.
121+
///
122+
/// Created before the scheduler job so the obligation is persisted even if job creation
123+
/// fails. Once a job is created, `proof_job_id` is back-filled via
124+
/// [`Store::link_obligation_to_job`].
125+
#[derive(Debug, Clone, Serialize, Deserialize)]
126+
pub struct ProofObligationRecord {
127+
/// UUID v4 assigned at creation time
128+
pub id: Uuid,
129+
/// FK to `repositories.id`
130+
pub repo_id: Uuid,
131+
/// Human-readable claim that must be proved (from hypatia / external scanner)
132+
pub claim: String,
133+
/// Original context/description from pattern detection
134+
pub context: String,
135+
/// Optional prover hint supplied by the caller (e.g. "lean", "coq")
136+
pub prover_hint: Option<String>,
137+
/// Lifecycle status: `pending` → `processing` → `completed` | `failed`
138+
pub status: String,
139+
/// Scheduler job UUID, populated by [`Store::link_obligation_to_job`]
140+
pub proof_job_id: Option<String>,
141+
/// Timestamp when this obligation was first recorded
142+
pub created_at: DateTime<Utc>,
143+
/// Timestamp when a final terminal status was reached (completed / failed)
144+
pub completed_at: Option<DateTime<Utc>>,
145+
}
146+
147+
impl ProofObligationRecord {
148+
/// Construct a new pending obligation.
149+
pub fn new(
150+
repo_id: Uuid,
151+
claim: String,
152+
context: String,
153+
prover_hint: Option<String>,
154+
) -> Self {
155+
Self {
156+
id: Uuid::new_v4(),
157+
repo_id,
158+
claim,
159+
context,
160+
prover_hint,
161+
status: "pending".to_string(),
162+
proof_job_id: None,
163+
created_at: Utc::now(),
164+
completed_at: None,
165+
}
166+
}
167+
}
168+
120169
/// Check run record (for tracking GitHub/GitLab status updates)
121170
#[derive(Debug, Clone, Serialize, Deserialize)]
122171
pub struct CheckRunRecord {

0 commit comments

Comments
 (0)