initial: forge v0.1.0
Forge CLI workspace — Chronicle query and governance tooling for Substrate/Guildhouse FFCs. Crates: - forge: CLI binary (forge log, forge query) - forge-core: Chronicle query engine, ClickHouse adapter - forge-workspace: workspace management 33 tests passing. Queries forge_events materialized view in ClickHouse.
This commit is contained in:
commit
d0c7429636
25 changed files with 5563 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/target/
|
||||||
|
**/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
.env
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
2699
Cargo.lock
generated
Normal file
2699
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["forge-core", "forge-workspace", "forge"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
uuid = { version = "1", features = ["v4", "v7", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
|
|
||||||
|
# Cross-workspace path dep.
|
||||||
|
# ceremony-engine is in the bascule workspace.
|
||||||
|
# Future: published to crates.io as version dep.
|
||||||
|
ceremony-engine = { path = "../bascule/ceremony-engine" }
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# Internal
|
||||||
|
forge-core = { path = "./forge-core" }
|
||||||
|
forge-workspace = { path = "./forge-workspace" }
|
||||||
|
|
||||||
|
# gRPC
|
||||||
|
tonic = "0.12"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
23
forge-core/Cargo.toml
Normal file
23
forge-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "forge-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Governed collaboration primitive for the Substrate governance fabric"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ceremony-engine = { workspace = true }
|
||||||
|
# Cross-workspace — CeremonyType/CeremonyReqs used by propose()
|
||||||
|
accord-core = { path = "../../guildhouse/services/accord-core" }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
mockito = "1"
|
||||||
417
forge-core/src/chronicle.rs
Normal file
417
forge-core/src/chronicle.rs
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// The kind of Chronicle event emitted by a ForgeSession.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum ForgeEventKind {
|
||||||
|
ForgeSessionOpened,
|
||||||
|
ForgeParticipantJoined,
|
||||||
|
ForgeParticipantLeft,
|
||||||
|
ForgeChangeProposed,
|
||||||
|
ForgeChangeApproved,
|
||||||
|
ForgeChangeDenied,
|
||||||
|
ForgeChangeExecuted,
|
||||||
|
ForgeInvitationCreated,
|
||||||
|
ForgeInvitationAccepted,
|
||||||
|
ForgeSessionClosed,
|
||||||
|
ForgeSessionAbandoned,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Chronicle event emitted by forge-core.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ForgeChronicleEvent {
|
||||||
|
pub event_id: Uuid,
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub kind: ForgeEventKind,
|
||||||
|
pub actor_did: String,
|
||||||
|
pub accord_hash: [u8; 32],
|
||||||
|
pub occurred_at: DateTime<Utc>,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeChronicleEvent {
|
||||||
|
pub fn new(
|
||||||
|
session_id: Uuid,
|
||||||
|
kind: ForgeEventKind,
|
||||||
|
actor_did: impl Into<String>,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
event_id: Uuid::now_v7(),
|
||||||
|
session_id,
|
||||||
|
kind,
|
||||||
|
actor_did: actor_did.into(),
|
||||||
|
accord_hash,
|
||||||
|
occurred_at: Utc::now(),
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chronicle warm-tier query client ──────────────────────
|
||||||
|
|
||||||
|
/// A single row from the `chronicle.forge_events` materialized view.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ForgeLogEntry {
|
||||||
|
pub entry_id: String,
|
||||||
|
pub occurred_at: String,
|
||||||
|
pub sequence: u64,
|
||||||
|
pub actor_did: String,
|
||||||
|
pub accord_hash: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub forge_session_id: Option<String>,
|
||||||
|
pub proposal_id: Option<String>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub merged_sha: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeLogEntry {
|
||||||
|
/// Human-readable display of this event.
|
||||||
|
pub fn display(&self) -> String {
|
||||||
|
let mut out = format!("{} {}\n actor: {}", self.occurred_at, self.kind, self.actor_did);
|
||||||
|
if let Some(ref msg) = self.message {
|
||||||
|
if !msg.is_empty() {
|
||||||
|
out.push_str(&format!("\n {}", msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref pid) = self.proposal_id {
|
||||||
|
if !pid.is_empty() {
|
||||||
|
out.push_str(&format!("\n proposal: {}", pid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref sha) = self.merged_sha {
|
||||||
|
if !sha.is_empty() {
|
||||||
|
out.push_str(&format!("\n sha: {}", sha));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for reading forge events from the Chronicle warm tier.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ForgeLogQuery {
|
||||||
|
/// Filter by forge session ID. None = all forge events.
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
/// Max entries. Default: 50.
|
||||||
|
pub limit: u64,
|
||||||
|
/// Kind prefix filter, e.g. `Some("APP_FORGE_CHANGE")`.
|
||||||
|
pub kind_filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ForgeLogQuery {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
session_id: None,
|
||||||
|
limit: 50,
|
||||||
|
kind_filter: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP client that queries the ClickHouse Chronicle warm tier directly.
|
||||||
|
///
|
||||||
|
/// This is a read-only client. The drain crate writes; forge reads.
|
||||||
|
/// Never returns errors — all failures produce `vec![]` + `eprintln!`.
|
||||||
|
pub struct ChronicleClient {
|
||||||
|
clickhouse_url: String,
|
||||||
|
database: String,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChronicleClient {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let clickhouse_url = std::env::var("CHRONICLE_DRAIN_CLICKHOUSE_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:8123".to_string());
|
||||||
|
let database = std::env::var("CHRONICLE_DRAIN_DB")
|
||||||
|
.unwrap_or_else(|_| "chronicle".to_string());
|
||||||
|
Self {
|
||||||
|
clickhouse_url,
|
||||||
|
database,
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query forge events from the Chronicle warm tier.
|
||||||
|
/// NEVER returns Err — all failures produce an empty vec + stderr message.
|
||||||
|
pub async fn query_forge_log(&self, query: &ForgeLogQuery) -> Vec<ForgeLogEntry> {
|
||||||
|
let sql = self.build_query(query);
|
||||||
|
let resp = match self
|
||||||
|
.http
|
||||||
|
.post(&self.clickhouse_url)
|
||||||
|
.query(&[("database", &self.database)])
|
||||||
|
.body(sql)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("chronicle: ClickHouse unreachable: {e}");
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
eprintln!("chronicle: ClickHouse returned {status}: {body}");
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_query(&self, q: &ForgeLogQuery) -> String {
|
||||||
|
let mut conditions = Vec::new();
|
||||||
|
|
||||||
|
if let Some(ref sid) = q.session_id {
|
||||||
|
if is_safe_uuid(sid) {
|
||||||
|
conditions.push(format!("forge_session_id = '{sid}'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref kind) = q.kind_filter {
|
||||||
|
let safe: String = kind
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '_')
|
||||||
|
.collect();
|
||||||
|
if !safe.is_empty() {
|
||||||
|
conditions.push(format!("kind LIKE '{safe}%'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let where_clause = if conditions.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("WHERE {}", conditions.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"SELECT entry_id, toString(occurred_at) AS occurred_at, \
|
||||||
|
sequence, actor_did, accord_hash, kind, \
|
||||||
|
forge_session_id, proposal_id, message, merged_sha \
|
||||||
|
FROM forge_events \
|
||||||
|
{where_clause} \
|
||||||
|
ORDER BY sequence ASC \
|
||||||
|
LIMIT {} \
|
||||||
|
FORMAT JSONEachRow",
|
||||||
|
q.limit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_response(&self, resp: reqwest::Response) -> Vec<ForgeLogEntry> {
|
||||||
|
let body = match resp.text().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("chronicle: failed to read response body: {e}");
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
body.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.filter_map(|line| {
|
||||||
|
serde_json::from_str::<ForgeLogEntry>(line)
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("chronicle: skipping unparseable line: {e}");
|
||||||
|
e
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ChronicleClient {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQL injection prevention: only allow hex + hyphens, max 36 chars.
|
||||||
|
fn is_safe_uuid(s: &str) -> bool {
|
||||||
|
s.len() <= 36 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_serializes_to_json() {
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
ForgeEventKind::ForgeSessionOpened,
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
serde_json::json!({"artifact_type": "workspace"}),
|
||||||
|
);
|
||||||
|
let json = serde_json::to_string(&evt).unwrap();
|
||||||
|
assert!(json.contains("FORGE_SESSION_OPENED"));
|
||||||
|
assert!(json.contains("did:web:msp.local:eng:alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_kind_screaming_snake_case() {
|
||||||
|
let kind = ForgeEventKind::ForgeChangeProposed;
|
||||||
|
let json = serde_json::to_string(&kind).unwrap();
|
||||||
|
assert_eq!(json, "\"FORGE_CHANGE_PROPOSED\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChronicleClient tests ──────────────────
|
||||||
|
|
||||||
|
fn sample_entry(seq: u64, kind: &str) -> ForgeLogEntry {
|
||||||
|
ForgeLogEntry {
|
||||||
|
entry_id: format!("entry-{seq}"),
|
||||||
|
occurred_at: "2026-03-17 14:30:00".to_string(),
|
||||||
|
sequence: seq,
|
||||||
|
actor_did: "did:web:test".to_string(),
|
||||||
|
accord_hash: "bbb".to_string(),
|
||||||
|
kind: kind.to_string(),
|
||||||
|
forge_session_id: Some("550e8400-e29b-41d4-a716-446655440000".to_string()),
|
||||||
|
proposal_id: Some("prop-1".to_string()),
|
||||||
|
message: Some("add auth".to_string()),
|
||||||
|
merged_sha: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_json_line(seq: u64, kind: &str) -> String {
|
||||||
|
serde_json::to_string(&sample_entry(seq, kind)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_query_returns_two_entries() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let body = format!(
|
||||||
|
"{}\n{}\n",
|
||||||
|
sample_json_line(1, "APP_FORGE_CHANGE_PROPOSED"),
|
||||||
|
sample_json_line(2, "APP_FORGE_CHANGE_APPROVED"),
|
||||||
|
);
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("database".into(), "chronicle".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_body(&body)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = ChronicleClient {
|
||||||
|
clickhouse_url: server.url(),
|
||||||
|
database: "chronicle".to_string(),
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
};
|
||||||
|
let entries = client.query_forge_log(&ForgeLogQuery::default()).await;
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
assert_eq!(entries[0].kind, "APP_FORGE_CHANGE_PROPOSED");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_query_empty_response() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("database".into(), "chronicle".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_body("")
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = ChronicleClient {
|
||||||
|
clickhouse_url: server.url(),
|
||||||
|
database: "chronicle".to_string(),
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
};
|
||||||
|
let entries = client.query_forge_log(&ForgeLogQuery::default()).await;
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_query_clickhouse_unreachable() {
|
||||||
|
let client = ChronicleClient {
|
||||||
|
clickhouse_url: "http://127.0.0.1:19999".to_string(),
|
||||||
|
database: "chronicle".to_string(),
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
};
|
||||||
|
let entries = client.query_forge_log(&ForgeLogQuery::default()).await;
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_query_clickhouse_500() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("database".into(), "chronicle".into()))
|
||||||
|
.with_status(500)
|
||||||
|
.with_body("internal error")
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = ChronicleClient {
|
||||||
|
clickhouse_url: server.url(),
|
||||||
|
database: "chronicle".to_string(),
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
};
|
||||||
|
let entries = client.query_forge_log(&ForgeLogQuery::default()).await;
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_display_with_all_fields() {
|
||||||
|
let entry = ForgeLogEntry {
|
||||||
|
entry_id: "aaa".to_string(),
|
||||||
|
occurred_at: "2026-03-17 14:30:00".to_string(),
|
||||||
|
sequence: 1,
|
||||||
|
actor_did: "did:web:test".to_string(),
|
||||||
|
accord_hash: "bbb".to_string(),
|
||||||
|
kind: "APP_FORGE_CHANGE_PROPOSED".to_string(),
|
||||||
|
forge_session_id: Some("550e8400-e29b-41d4-a716-446655440000".to_string()),
|
||||||
|
proposal_id: Some("prop-1".to_string()),
|
||||||
|
message: Some("add auth middleware".to_string()),
|
||||||
|
merged_sha: Some("abc1234".to_string()),
|
||||||
|
};
|
||||||
|
let out = entry.display();
|
||||||
|
assert!(out.contains("add auth middleware"));
|
||||||
|
assert!(out.contains("proposal:"));
|
||||||
|
assert!(out.contains("sha:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_display_minimal_no_options() {
|
||||||
|
let entry = ForgeLogEntry {
|
||||||
|
entry_id: "aaa".to_string(),
|
||||||
|
occurred_at: "2026-03-17 14:30:00".to_string(),
|
||||||
|
sequence: 1,
|
||||||
|
actor_did: "did:web:test".to_string(),
|
||||||
|
accord_hash: "bbb".to_string(),
|
||||||
|
kind: "APP_FORGE_SESSION_OPENED".to_string(),
|
||||||
|
forge_session_id: None,
|
||||||
|
proposal_id: None,
|
||||||
|
message: None,
|
||||||
|
merged_sha: None,
|
||||||
|
};
|
||||||
|
let out = entry.display();
|
||||||
|
assert!(out.contains("APP_FORGE_SESSION_OPENED"));
|
||||||
|
assert!(!out.contains("proposal:"));
|
||||||
|
assert!(!out.contains("sha:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_safe_uuid_valid() {
|
||||||
|
assert!(is_safe_uuid("550e8400-e29b-41d4-a716-446655440000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_safe_uuid_blocks_injection() {
|
||||||
|
assert!(!is_safe_uuid("'; DROP TABLE forge_events; --"));
|
||||||
|
}
|
||||||
|
}
|
||||||
125
forge-core/src/invitation.rs
Normal file
125
forge-core/src/invitation.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::participant::ForgeRole;
|
||||||
|
|
||||||
|
/// An invitation for a principal to join a ForgeSession.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ForgeInvitation {
|
||||||
|
pub invitation_id: Uuid,
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub invitee_did: String,
|
||||||
|
pub role: ForgeRole,
|
||||||
|
pub invited_by: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub accepted: bool,
|
||||||
|
/// Phase A: None (no signature yet).
|
||||||
|
/// Phase B: DID signature from invited_by.
|
||||||
|
pub signature: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeInvitation {
|
||||||
|
pub fn new(
|
||||||
|
session_id: Uuid,
|
||||||
|
invitee_did: impl Into<String>,
|
||||||
|
role: ForgeRole,
|
||||||
|
invited_by: impl Into<String>,
|
||||||
|
expires_in_secs: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
invitation_id: Uuid::now_v7(),
|
||||||
|
session_id,
|
||||||
|
invitee_did: invitee_did.into(),
|
||||||
|
role,
|
||||||
|
invited_by: invited_by.into(),
|
||||||
|
expires_at: Utc::now() + chrono::Duration::seconds(expires_in_secs as i64),
|
||||||
|
accepted: false,
|
||||||
|
signature: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
Utc::now() > self.expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accept(&mut self) -> Result<(), InvitationError> {
|
||||||
|
if self.is_expired() {
|
||||||
|
return Err(InvitationError::Expired);
|
||||||
|
}
|
||||||
|
if self.accepted {
|
||||||
|
return Err(InvitationError::AlreadyAccepted);
|
||||||
|
}
|
||||||
|
self.accepted = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum InvitationError {
|
||||||
|
#[error("invitation has expired")]
|
||||||
|
Expired,
|
||||||
|
|
||||||
|
#[error("invitation already accepted")]
|
||||||
|
AlreadyAccepted,
|
||||||
|
|
||||||
|
#[error("invitation is for a different DID")]
|
||||||
|
WrongInvitee,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_invitation_is_not_expired() {
|
||||||
|
let inv = ForgeInvitation::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
ForgeRole::Collaborator,
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
assert!(!inv.is_expired());
|
||||||
|
assert!(!inv.accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accept_sets_accepted_flag() {
|
||||||
|
let mut inv = ForgeInvitation::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
ForgeRole::Collaborator,
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
inv.accept().unwrap();
|
||||||
|
assert!(inv.accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_accept_fails() {
|
||||||
|
let mut inv = ForgeInvitation::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
ForgeRole::Collaborator,
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
inv.accept().unwrap();
|
||||||
|
assert!(matches!(inv.accept(), Err(InvitationError::AlreadyAccepted)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_invitation_cannot_be_accepted() {
|
||||||
|
let mut inv = ForgeInvitation::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
ForgeRole::Collaborator,
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
0, // expires immediately
|
||||||
|
);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
assert!(matches!(inv.accept(), Err(InvitationError::Expired)));
|
||||||
|
}
|
||||||
|
}
|
||||||
33
forge-core/src/lib.rs
Normal file
33
forge-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//! forge-core — governed collaboration primitive
|
||||||
|
//! for the Substrate governance fabric.
|
||||||
|
//!
|
||||||
|
//! A ForgeSession is a multi-principal governed
|
||||||
|
//! workspace. Multiple participants with different
|
||||||
|
//! DIDs and different accords collaborate on the
|
||||||
|
//! same governed artifact in real time.
|
||||||
|
//!
|
||||||
|
//! Every action is Chronicle-attributed per
|
||||||
|
//! principal. The CeremonyEngine governs state
|
||||||
|
//! transitions. No change executes without the
|
||||||
|
//! correct ceremony completing.
|
||||||
|
//!
|
||||||
|
//! Design:
|
||||||
|
//! forge-core → ceremony-engine → accord-core
|
||||||
|
//!
|
||||||
|
//! Implementations:
|
||||||
|
//! forge-workspace — git worktree backend
|
||||||
|
//! forge-schematic — IaC artifact backend (future)
|
||||||
|
//! forge-dataset — ML dataset backend (future)
|
||||||
|
//! forge-policy — OPA policy backend (future)
|
||||||
|
|
||||||
|
pub mod chronicle;
|
||||||
|
pub mod invitation;
|
||||||
|
pub mod participant;
|
||||||
|
pub mod proposal;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
pub use chronicle::{ForgeChronicleEvent, ForgeEventKind};
|
||||||
|
pub use invitation::{ForgeInvitation, InvitationError};
|
||||||
|
pub use participant::{ForgeParticipant, ForgeRole, ParticipantCapability};
|
||||||
|
pub use proposal::{ForgeProposal, ForgeProposalState};
|
||||||
|
pub use session::{ForgeArtifact, ForgeError, ForgeSession, ForgeSessionState, ForgeSessionSummary};
|
||||||
139
forge-core/src/participant.rs
Normal file
139
forge-core/src/participant.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The role a principal plays in a ForgeSession.
|
||||||
|
///
|
||||||
|
/// Role determines the default capability ceiling.
|
||||||
|
/// Actual capability is:
|
||||||
|
/// min(role_ceiling, accord_capability_mask)
|
||||||
|
///
|
||||||
|
/// A Collaborator with a GUILD accord gets
|
||||||
|
/// Collaborator ceiling, not Owner ceiling.
|
||||||
|
/// The accord is the structural limit;
|
||||||
|
/// the role is the session-level limit.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ForgeRole {
|
||||||
|
/// Session owner. Full capability within their accord.
|
||||||
|
Owner,
|
||||||
|
/// Invited collaborator. Can propose but not execute directly.
|
||||||
|
Collaborator,
|
||||||
|
/// Read-only observer. Cannot propose or execute.
|
||||||
|
Observer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capability flags for a ForgeParticipant.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ParticipantCapability {
|
||||||
|
pub can_read: bool,
|
||||||
|
pub can_propose: bool,
|
||||||
|
pub can_execute: bool,
|
||||||
|
pub can_invite: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParticipantCapability {
|
||||||
|
pub fn for_role(role: &ForgeRole) -> Self {
|
||||||
|
match role {
|
||||||
|
ForgeRole::Owner => Self {
|
||||||
|
can_read: true,
|
||||||
|
can_propose: true,
|
||||||
|
can_execute: true,
|
||||||
|
can_invite: true,
|
||||||
|
},
|
||||||
|
ForgeRole::Collaborator => Self {
|
||||||
|
can_read: true,
|
||||||
|
can_propose: true,
|
||||||
|
can_execute: false,
|
||||||
|
can_invite: false,
|
||||||
|
},
|
||||||
|
ForgeRole::Observer => Self {
|
||||||
|
can_read: true,
|
||||||
|
can_propose: false,
|
||||||
|
can_execute: false,
|
||||||
|
can_invite: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A principal participating in a ForgeSession.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ForgeParticipant {
|
||||||
|
/// W3C DID of this participant.
|
||||||
|
pub did: String,
|
||||||
|
/// Role in this session.
|
||||||
|
pub role: ForgeRole,
|
||||||
|
/// Accord hash governing this participant's actions.
|
||||||
|
pub accord_hash: [u8; 32],
|
||||||
|
/// Derived capability for this session.
|
||||||
|
pub capability: ParticipantCapability,
|
||||||
|
/// When this participant joined.
|
||||||
|
pub joined_at: DateTime<Utc>,
|
||||||
|
/// When this participant left (None = still active).
|
||||||
|
pub left_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeParticipant {
|
||||||
|
pub fn new(did: impl Into<String>, role: ForgeRole, accord_hash: [u8; 32]) -> Self {
|
||||||
|
let capability = ParticipantCapability::for_role(&role);
|
||||||
|
Self {
|
||||||
|
did: did.into(),
|
||||||
|
role,
|
||||||
|
accord_hash,
|
||||||
|
capability,
|
||||||
|
joined_at: Utc::now(),
|
||||||
|
left_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.left_at.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave(&mut self) {
|
||||||
|
self.left_at = Some(Utc::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn owner_has_full_capability() {
|
||||||
|
let p = ForgeParticipant::new("did:web:msp.local:eng:alice", ForgeRole::Owner, [0u8; 32]);
|
||||||
|
assert!(p.capability.can_execute);
|
||||||
|
assert!(p.capability.can_invite);
|
||||||
|
assert!(p.capability.can_propose);
|
||||||
|
assert!(p.capability.can_read);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collaborator_cannot_execute() {
|
||||||
|
let p =
|
||||||
|
ForgeParticipant::new("did:web:isv.local:eng:bob", ForgeRole::Collaborator, [0u8; 32]);
|
||||||
|
assert!(!p.capability.can_execute);
|
||||||
|
assert!(!p.capability.can_invite);
|
||||||
|
assert!(p.capability.can_propose);
|
||||||
|
assert!(p.capability.can_read);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observer_can_only_read() {
|
||||||
|
let p =
|
||||||
|
ForgeParticipant::new("did:web:customer.local:audit", ForgeRole::Observer, [0u8; 32]);
|
||||||
|
assert!(!p.capability.can_execute);
|
||||||
|
assert!(!p.capability.can_invite);
|
||||||
|
assert!(!p.capability.can_propose);
|
||||||
|
assert!(p.capability.can_read);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn participant_leave_sets_left_at() {
|
||||||
|
let mut p =
|
||||||
|
ForgeParticipant::new("did:web:msp.local:eng:alice", ForgeRole::Owner, [0u8; 32]);
|
||||||
|
assert!(p.is_active());
|
||||||
|
p.leave();
|
||||||
|
assert!(!p.is_active());
|
||||||
|
assert!(p.left_at.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
138
forge-core/src/proposal.rs
Normal file
138
forge-core/src/proposal.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use ceremony_engine::GovernanceCeremonyRequest;
|
||||||
|
|
||||||
|
/// The lifecycle state of a ForgeProposal.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ForgeProposalState {
|
||||||
|
/// Proposal submitted, awaiting approval.
|
||||||
|
Pending,
|
||||||
|
/// Approved — ready to execute (merge).
|
||||||
|
Approved,
|
||||||
|
/// Denied — cannot be executed. A new proposal may be submitted.
|
||||||
|
Denied,
|
||||||
|
/// Executed — change merged to canonical.
|
||||||
|
Executed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A governed change proposal within a ForgeSession.
|
||||||
|
///
|
||||||
|
/// Ties together: the git commit, the ceremony request,
|
||||||
|
/// the proposer's DID and accord, and the approval outcome.
|
||||||
|
///
|
||||||
|
/// Only one proposal can be active at a time in a ForgeSession.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ForgeProposal {
|
||||||
|
pub proposal_id: Uuid,
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub proposer_did: String,
|
||||||
|
pub accord_hash: [u8; 32],
|
||||||
|
pub message: String,
|
||||||
|
pub commit_sha: String,
|
||||||
|
pub state: ForgeProposalState,
|
||||||
|
pub proposed_at: DateTime<Utc>,
|
||||||
|
pub resolved_at: Option<DateTime<Utc>>,
|
||||||
|
pub approver_did: Option<String>,
|
||||||
|
pub denial_reason: Option<String>,
|
||||||
|
/// The backing CeremonyEngine request for this proposal.
|
||||||
|
pub ceremony: GovernanceCeremonyRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeProposal {
|
||||||
|
pub fn new(
|
||||||
|
session_id: Uuid,
|
||||||
|
proposer_did: impl Into<String>,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
message: impl Into<String>,
|
||||||
|
commit_sha: impl Into<String>,
|
||||||
|
ceremony: GovernanceCeremonyRequest,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
proposal_id: Uuid::now_v7(),
|
||||||
|
session_id,
|
||||||
|
proposer_did: proposer_did.into(),
|
||||||
|
accord_hash,
|
||||||
|
message: message.into(),
|
||||||
|
commit_sha: commit_sha.into(),
|
||||||
|
state: ForgeProposalState::Pending,
|
||||||
|
proposed_at: Utc::now(),
|
||||||
|
resolved_at: None,
|
||||||
|
approver_did: None,
|
||||||
|
denial_reason: None,
|
||||||
|
ceremony,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use accord_core::schema::{CeremonyReqs, CeremonyType};
|
||||||
|
use ceremony_engine::{CeremonyEngine, CeremonySubject};
|
||||||
|
|
||||||
|
fn test_ceremony() -> GovernanceCeremonyRequest {
|
||||||
|
CeremonyEngine::create_request(
|
||||||
|
Uuid::now_v7().to_string(),
|
||||||
|
&CeremonyType::SingleApproval,
|
||||||
|
&CeremonyReqs::default(),
|
||||||
|
CeremonySubject::Custom {
|
||||||
|
subject_type: "forge-proposal".to_string(),
|
||||||
|
reference_id: "test".to_string(),
|
||||||
|
description: "test proposal".to_string(),
|
||||||
|
},
|
||||||
|
24,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_proposal_is_pending() {
|
||||||
|
let p = ForgeProposal::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
"fix: timeout",
|
||||||
|
"abc1234",
|
||||||
|
test_ceremony(),
|
||||||
|
);
|
||||||
|
assert_eq!(p.state, ForgeProposalState::Pending);
|
||||||
|
assert!(p.resolved_at.is_none());
|
||||||
|
assert!(p.approver_did.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proposal_fields_preserved() {
|
||||||
|
let p = ForgeProposal::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
"fix: timeout",
|
||||||
|
"deadbeef",
|
||||||
|
test_ceremony(),
|
||||||
|
);
|
||||||
|
assert_eq!(p.message, "fix: timeout");
|
||||||
|
assert_eq!(p.commit_sha, "deadbeef");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proposal_id_is_unique() {
|
||||||
|
let p1 = ForgeProposal::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
"did:web:test",
|
||||||
|
[0u8; 32],
|
||||||
|
"a",
|
||||||
|
"a",
|
||||||
|
test_ceremony(),
|
||||||
|
);
|
||||||
|
let p2 = ForgeProposal::new(
|
||||||
|
Uuid::now_v7(),
|
||||||
|
"did:web:test",
|
||||||
|
[0u8; 32],
|
||||||
|
"b",
|
||||||
|
"b",
|
||||||
|
test_ceremony(),
|
||||||
|
);
|
||||||
|
assert_ne!(p1.proposal_id, p2.proposal_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
768
forge-core/src/session.rs
Normal file
768
forge-core/src/session.rs
Normal file
|
|
@ -0,0 +1,768 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use accord_core::schema::{CeremonyReqs, CeremonyType};
|
||||||
|
use ceremony_engine::{ApprovalDecision, CeremonyEngine, CeremonySubject};
|
||||||
|
|
||||||
|
use crate::chronicle::{ForgeChronicleEvent, ForgeEventKind};
|
||||||
|
use crate::invitation::{ForgeInvitation, InvitationError};
|
||||||
|
use crate::participant::{ForgeParticipant, ForgeRole, ParticipantCapability};
|
||||||
|
use crate::proposal::{ForgeProposal, ForgeProposalState};
|
||||||
|
|
||||||
|
/// The lifecycle state of a ForgeSession.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ForgeSessionState {
|
||||||
|
Active,
|
||||||
|
CeremonyInProgress,
|
||||||
|
/// Ceremony approved — awaiting merge execution.
|
||||||
|
ReadyToExecute,
|
||||||
|
Closed,
|
||||||
|
Abandoned,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of artifact this ForgeSession governs.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ForgeArtifact {
|
||||||
|
Workspace {
|
||||||
|
worktree_path: String,
|
||||||
|
branch_name: String,
|
||||||
|
base_commit: String,
|
||||||
|
},
|
||||||
|
Schematic {
|
||||||
|
schematic_hash: [u8; 32],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A governed multi-principal collaboration session.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ForgeSession {
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub artifact: ForgeArtifact,
|
||||||
|
pub state: ForgeSessionState,
|
||||||
|
pub participants: Vec<ForgeParticipant>,
|
||||||
|
pub invitations: Vec<ForgeInvitation>,
|
||||||
|
pub chronicle: Vec<ForgeChronicleEvent>,
|
||||||
|
/// The currently active proposal (if any).
|
||||||
|
pub active_proposal: Option<ForgeProposal>,
|
||||||
|
/// History of all proposals (for Chronicle).
|
||||||
|
pub proposals: Vec<ForgeProposal>,
|
||||||
|
pub opened_at: DateTime<Utc>,
|
||||||
|
pub closed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary for listing sessions.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ForgeSessionSummary {
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub state: ForgeSessionState,
|
||||||
|
pub participant_count: usize,
|
||||||
|
pub opened_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ForgeError {
|
||||||
|
#[error("session is not active")]
|
||||||
|
NotActive,
|
||||||
|
|
||||||
|
#[error("participant not found: {0}")]
|
||||||
|
ParticipantNotFound(String),
|
||||||
|
|
||||||
|
#[error("insufficient capability for this action")]
|
||||||
|
InsufficientCapability,
|
||||||
|
|
||||||
|
#[error("invitation error: {0}")]
|
||||||
|
Invitation(#[from] InvitationError),
|
||||||
|
|
||||||
|
#[error("session already closed")]
|
||||||
|
AlreadyClosed,
|
||||||
|
|
||||||
|
#[error("ceremony already in progress")]
|
||||||
|
CeremonyInProgress,
|
||||||
|
|
||||||
|
#[error("no active proposal in this session")]
|
||||||
|
NoActiveProposal,
|
||||||
|
|
||||||
|
#[error("a proposal is already pending — approve or deny it first")]
|
||||||
|
ProposalAlreadyPending,
|
||||||
|
|
||||||
|
#[error("proposal is not approved — run 'forge approve' first")]
|
||||||
|
ProposalNotApproved,
|
||||||
|
|
||||||
|
#[error("ceremony error: {0}")]
|
||||||
|
Ceremony(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeSession {
|
||||||
|
/// Open a new ForgeSession. The opener becomes the Owner.
|
||||||
|
pub fn open(
|
||||||
|
name: impl Into<String>,
|
||||||
|
artifact: ForgeArtifact,
|
||||||
|
owner_did: impl Into<String>,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
) -> (Self, ForgeChronicleEvent) {
|
||||||
|
let session_id = Uuid::now_v7();
|
||||||
|
let owner_did = owner_did.into();
|
||||||
|
let name = name.into();
|
||||||
|
|
||||||
|
let owner = ForgeParticipant::new(owner_did.clone(), ForgeRole::Owner, accord_hash);
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
session_id,
|
||||||
|
ForgeEventKind::ForgeSessionOpened,
|
||||||
|
&owner_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"name": &name,
|
||||||
|
"artifact_type": artifact_type_str(&artifact),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let session = Self {
|
||||||
|
session_id,
|
||||||
|
name,
|
||||||
|
artifact,
|
||||||
|
state: ForgeSessionState::Active,
|
||||||
|
participants: vec![owner],
|
||||||
|
invitations: vec![],
|
||||||
|
chronicle: vec![evt.clone()],
|
||||||
|
active_proposal: None,
|
||||||
|
proposals: vec![],
|
||||||
|
opened_at: Utc::now(),
|
||||||
|
closed_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(session, evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invite a principal to join. Only Owner can invite.
|
||||||
|
pub fn invite(
|
||||||
|
&mut self,
|
||||||
|
inviter_did: &str,
|
||||||
|
invitee_did: impl Into<String>,
|
||||||
|
role: ForgeRole,
|
||||||
|
expires_in: u64,
|
||||||
|
) -> Result<(ForgeInvitation, ForgeChronicleEvent), ForgeError> {
|
||||||
|
self.require_active()?;
|
||||||
|
self.require_capability(inviter_did, |c| c.can_invite)?;
|
||||||
|
|
||||||
|
let invitee_did = invitee_did.into();
|
||||||
|
let inv = ForgeInvitation::new(
|
||||||
|
self.session_id,
|
||||||
|
invitee_did.clone(),
|
||||||
|
role,
|
||||||
|
inviter_did,
|
||||||
|
expires_in,
|
||||||
|
);
|
||||||
|
|
||||||
|
let accord_hash = self.participant_accord(inviter_did);
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeInvitationCreated,
|
||||||
|
inviter_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"invitee_did": &invitee_did,
|
||||||
|
"role": inv.role,
|
||||||
|
"invitation_id": inv.invitation_id.to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.invitations.push(inv.clone());
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
Ok((inv, evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept an invitation and join the session.
|
||||||
|
pub fn join(
|
||||||
|
&mut self,
|
||||||
|
invitee_did: &str,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
invitation_id: Uuid,
|
||||||
|
) -> Result<ForgeChronicleEvent, ForgeError> {
|
||||||
|
self.require_active()?;
|
||||||
|
|
||||||
|
let inv = self
|
||||||
|
.invitations
|
||||||
|
.iter_mut()
|
||||||
|
.find(|i| i.invitation_id == invitation_id && i.invitee_did == invitee_did)
|
||||||
|
.ok_or_else(|| ForgeError::ParticipantNotFound(invitee_did.to_string()))?;
|
||||||
|
|
||||||
|
inv.accept()?;
|
||||||
|
|
||||||
|
let role = inv.role.clone();
|
||||||
|
let participant = ForgeParticipant::new(invitee_did, role, accord_hash);
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeParticipantJoined,
|
||||||
|
invitee_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({"role": participant.role}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.participants.push(participant);
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A participant leaves the session.
|
||||||
|
pub fn leave(&mut self, participant_did: &str) -> Result<ForgeChronicleEvent, ForgeError> {
|
||||||
|
let p = self
|
||||||
|
.participants
|
||||||
|
.iter_mut()
|
||||||
|
.find(|p| p.did == participant_did && p.is_active())
|
||||||
|
.ok_or_else(|| ForgeError::ParticipantNotFound(participant_did.to_string()))?;
|
||||||
|
|
||||||
|
let accord_hash = p.accord_hash;
|
||||||
|
p.leave();
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeParticipantLeft,
|
||||||
|
participant_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the session normally.
|
||||||
|
pub fn close(&mut self, closer_did: &str) -> Result<ForgeChronicleEvent, ForgeError> {
|
||||||
|
self.require_active()?;
|
||||||
|
self.require_capability(closer_did, |c| c.can_execute)?;
|
||||||
|
|
||||||
|
self.state = ForgeSessionState::Closed;
|
||||||
|
self.closed_at = Some(Utc::now());
|
||||||
|
|
||||||
|
let accord_hash = self.participant_accord(closer_did);
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeSessionClosed,
|
||||||
|
closer_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"participant_count": self.participants.len(),
|
||||||
|
"chronicle_events": self.chronicle.len(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon the session — no changes executed, clean teardown.
|
||||||
|
pub fn abandon(&mut self, abandoner_did: &str) -> Result<ForgeChronicleEvent, ForgeError> {
|
||||||
|
self.require_active()?;
|
||||||
|
self.require_capability(abandoner_did, |c| c.can_execute)?;
|
||||||
|
|
||||||
|
self.state = ForgeSessionState::Abandoned;
|
||||||
|
self.closed_at = Some(Utc::now());
|
||||||
|
|
||||||
|
let accord_hash = self.participant_accord(abandoner_did);
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeSessionAbandoned,
|
||||||
|
abandoner_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summary(&self) -> ForgeSessionSummary {
|
||||||
|
ForgeSessionSummary {
|
||||||
|
session_id: self.session_id,
|
||||||
|
name: self.name.clone(),
|
||||||
|
state: self.state.clone(),
|
||||||
|
participant_count: self.active_participants().count(),
|
||||||
|
opened_at: self.opened_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_participants(&self) -> impl Iterator<Item = &ForgeParticipant> {
|
||||||
|
self.participants.iter().filter(|p| p.is_active())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ceremony methods ─────────────────────
|
||||||
|
|
||||||
|
/// Propose a change. Creates a CeremonyEngine request.
|
||||||
|
/// Transitions: Active → CeremonyInProgress.
|
||||||
|
pub fn propose(
|
||||||
|
&mut self,
|
||||||
|
proposer_did: &str,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
message: impl Into<String>,
|
||||||
|
commit_sha: impl Into<String>,
|
||||||
|
) -> Result<(ForgeProposal, ForgeChronicleEvent), ForgeError> {
|
||||||
|
// Check for pending proposal first — gives a more specific error
|
||||||
|
// than "session is not active" when state is CeremonyInProgress.
|
||||||
|
if let Some(ref p) = self.active_proposal {
|
||||||
|
if matches!(p.state, ForgeProposalState::Pending | ForgeProposalState::Approved) {
|
||||||
|
return Err(ForgeError::ProposalAlreadyPending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.require_active()?;
|
||||||
|
self.require_capability(proposer_did, |c| c.can_propose)?;
|
||||||
|
|
||||||
|
let message = message.into();
|
||||||
|
let commit_sha = commit_sha.into();
|
||||||
|
|
||||||
|
// Create ceremony request via CeremonyEngine
|
||||||
|
let ceremony = CeremonyEngine::create_request(
|
||||||
|
Uuid::now_v7().to_string(),
|
||||||
|
&CeremonyType::SingleApproval,
|
||||||
|
&CeremonyReqs {
|
||||||
|
approver_roles: Some(vec!["owner".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
CeremonySubject::Custom {
|
||||||
|
subject_type: "forge-proposal".to_string(),
|
||||||
|
reference_id: self.session_id.to_string(),
|
||||||
|
description: message.clone(),
|
||||||
|
},
|
||||||
|
24, // 24 hour TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
let proposal = ForgeProposal::new(
|
||||||
|
self.session_id,
|
||||||
|
proposer_did,
|
||||||
|
accord_hash,
|
||||||
|
&message,
|
||||||
|
&commit_sha,
|
||||||
|
ceremony,
|
||||||
|
);
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeChangeProposed,
|
||||||
|
proposer_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"proposal_id": proposal.proposal_id.to_string(),
|
||||||
|
"commit_sha": &commit_sha,
|
||||||
|
"message": &message,
|
||||||
|
"ceremony_id": &proposal.ceremony.ceremony_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.state = ForgeSessionState::CeremonyInProgress;
|
||||||
|
self.active_proposal = Some(proposal.clone());
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
|
||||||
|
Ok((proposal, evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approve the active proposal. Requires Owner (can_execute).
|
||||||
|
/// Transitions: CeremonyInProgress → ReadyToExecute.
|
||||||
|
pub fn approve(
|
||||||
|
&mut self,
|
||||||
|
approver_did: &str,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
) -> Result<ForgeChronicleEvent, ForgeError> {
|
||||||
|
self.require_capability(approver_did, |c| c.can_execute)?;
|
||||||
|
|
||||||
|
let proposal = self.active_proposal.as_mut().ok_or(ForgeError::NoActiveProposal)?;
|
||||||
|
|
||||||
|
if proposal.state != ForgeProposalState::Pending {
|
||||||
|
return Err(ForgeError::NoActiveProposal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record approval in the ceremony request
|
||||||
|
proposal
|
||||||
|
.ceremony
|
||||||
|
.record_decision(approver_did, "owner", ApprovalDecision::Approve, None)
|
||||||
|
.map_err(|e| ForgeError::Ceremony(e.to_string()))?;
|
||||||
|
|
||||||
|
// Evaluate — should transition to Approved (SingleApproval needs 1)
|
||||||
|
CeremonyEngine::evaluate(&mut proposal.ceremony);
|
||||||
|
|
||||||
|
proposal.state = ForgeProposalState::Approved;
|
||||||
|
proposal.resolved_at = Some(Utc::now());
|
||||||
|
proposal.approver_did = Some(approver_did.to_string());
|
||||||
|
|
||||||
|
self.state = ForgeSessionState::ReadyToExecute;
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeChangeApproved,
|
||||||
|
approver_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"proposal_id": proposal.proposal_id.to_string(),
|
||||||
|
"approver": approver_did,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deny the active proposal. Returns session to Active.
|
||||||
|
pub fn deny(
|
||||||
|
&mut self,
|
||||||
|
denier_did: &str,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
reason: Option<String>,
|
||||||
|
) -> Result<ForgeChronicleEvent, ForgeError> {
|
||||||
|
self.require_capability(denier_did, |c| c.can_execute)?;
|
||||||
|
|
||||||
|
let proposal = self.active_proposal.as_mut().ok_or(ForgeError::NoActiveProposal)?;
|
||||||
|
|
||||||
|
if proposal.state != ForgeProposalState::Pending {
|
||||||
|
return Err(ForgeError::NoActiveProposal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record denial in ceremony
|
||||||
|
proposal
|
||||||
|
.ceremony
|
||||||
|
.record_decision(
|
||||||
|
denier_did,
|
||||||
|
"owner",
|
||||||
|
ApprovalDecision::Deny,
|
||||||
|
reason.clone(),
|
||||||
|
)
|
||||||
|
.map_err(|e| ForgeError::Ceremony(e.to_string()))?;
|
||||||
|
|
||||||
|
CeremonyEngine::evaluate(&mut proposal.ceremony);
|
||||||
|
|
||||||
|
proposal.state = ForgeProposalState::Denied;
|
||||||
|
proposal.resolved_at = Some(Utc::now());
|
||||||
|
proposal.denial_reason = reason.clone();
|
||||||
|
|
||||||
|
// Move to history, clear active
|
||||||
|
self.proposals.push(proposal.clone());
|
||||||
|
self.active_proposal = None;
|
||||||
|
self.state = ForgeSessionState::Active;
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeChangeDenied,
|
||||||
|
denier_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"reason": reason.as_deref().unwrap_or(""),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark approved proposal as executed (called after merge succeeds).
|
||||||
|
/// Transitions: ReadyToExecute → Active.
|
||||||
|
pub fn mark_executed(
|
||||||
|
&mut self,
|
||||||
|
executor_did: &str,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
merged_sha: &str,
|
||||||
|
) -> Result<ForgeChronicleEvent, ForgeError> {
|
||||||
|
if self.state != ForgeSessionState::ReadyToExecute {
|
||||||
|
return Err(ForgeError::ProposalNotApproved);
|
||||||
|
}
|
||||||
|
|
||||||
|
let proposal = self.active_proposal.as_mut().ok_or(ForgeError::NoActiveProposal)?;
|
||||||
|
|
||||||
|
if proposal.state != ForgeProposalState::Approved {
|
||||||
|
return Err(ForgeError::ProposalNotApproved);
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal.state = ForgeProposalState::Executed;
|
||||||
|
|
||||||
|
// Move to history, clear active
|
||||||
|
self.proposals.push(proposal.clone());
|
||||||
|
self.active_proposal = None;
|
||||||
|
self.state = ForgeSessionState::Active;
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session_id,
|
||||||
|
ForgeEventKind::ForgeChangeExecuted,
|
||||||
|
executor_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"merged_sha": merged_sha,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.chronicle.push(evt.clone());
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────
|
||||||
|
|
||||||
|
fn require_active(&self) -> Result<(), ForgeError> {
|
||||||
|
if self.state != ForgeSessionState::Active {
|
||||||
|
Err(ForgeError::NotActive)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_capability(
|
||||||
|
&self,
|
||||||
|
did: &str,
|
||||||
|
check: impl Fn(&ParticipantCapability) -> bool,
|
||||||
|
) -> Result<(), ForgeError> {
|
||||||
|
let p = self
|
||||||
|
.participants
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.did == did && p.is_active())
|
||||||
|
.ok_or_else(|| ForgeError::ParticipantNotFound(did.to_string()))?;
|
||||||
|
|
||||||
|
if check(&p.capability) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ForgeError::InsufficientCapability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn participant_accord(&self, did: &str) -> [u8; 32] {
|
||||||
|
self.participants
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.did == did)
|
||||||
|
.map(|p| p.accord_hash)
|
||||||
|
.unwrap_or([0u8; 32])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artifact_type_str(a: &ForgeArtifact) -> &str {
|
||||||
|
match a {
|
||||||
|
ForgeArtifact::Workspace { .. } => "workspace",
|
||||||
|
ForgeArtifact::Schematic { .. } => "schematic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn workspace_artifact() -> ForgeArtifact {
|
||||||
|
ForgeArtifact::Workspace {
|
||||||
|
worktree_path: "/forge/sessions/test".into(),
|
||||||
|
branch_name: "session-test".into(),
|
||||||
|
base_commit: "abc1234".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_creates_owner_participant() {
|
||||||
|
let (session, evt) = ForgeSession::open(
|
||||||
|
"test-session",
|
||||||
|
workspace_artifact(),
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
);
|
||||||
|
assert_eq!(session.participants.len(), 1);
|
||||||
|
assert_eq!(session.participants[0].role, ForgeRole::Owner);
|
||||||
|
assert_eq!(evt.kind, ForgeEventKind::ForgeSessionOpened);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invite_and_join_flow() {
|
||||||
|
let (mut session, _) = ForgeSession::open(
|
||||||
|
"test-session",
|
||||||
|
workspace_artifact(),
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
);
|
||||||
|
|
||||||
|
let (inv, _) = session
|
||||||
|
.invite(
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
ForgeRole::Collaborator,
|
||||||
|
3600,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
session
|
||||||
|
.join("did:web:isv.local:eng:bob", [1u8; 32], inv.invitation_id)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(session.active_participants().count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collaborator_cannot_invite() {
|
||||||
|
let (mut session, _) = ForgeSession::open(
|
||||||
|
"test-session",
|
||||||
|
workspace_artifact(),
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
);
|
||||||
|
|
||||||
|
let (inv, _) = session
|
||||||
|
.invite(
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
ForgeRole::Collaborator,
|
||||||
|
3600,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
session
|
||||||
|
.join("did:web:isv.local:eng:bob", [1u8; 32], inv.invitation_id)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = session.invite(
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
"did:web:isv.local:eng:carol",
|
||||||
|
ForgeRole::Observer,
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(ForgeError::InsufficientCapability)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_emits_chronicle_event() {
|
||||||
|
let (mut session, _) = ForgeSession::open(
|
||||||
|
"test-session",
|
||||||
|
workspace_artifact(),
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
);
|
||||||
|
|
||||||
|
let evt = session.close("did:web:msp.local:eng:alice").unwrap();
|
||||||
|
assert_eq!(evt.kind, ForgeEventKind::ForgeSessionClosed);
|
||||||
|
assert_eq!(session.state, ForgeSessionState::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cannot_act_on_closed_session() {
|
||||||
|
let (mut session, _) = ForgeSession::open(
|
||||||
|
"test-session",
|
||||||
|
workspace_artifact(),
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
);
|
||||||
|
|
||||||
|
session.close("did:web:msp.local:eng:alice").unwrap();
|
||||||
|
|
||||||
|
let result = session.invite(
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
ForgeRole::Observer,
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(ForgeError::NotActive)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chronicle_grows_with_each_event() {
|
||||||
|
let (mut session, _) = ForgeSession::open(
|
||||||
|
"test-session",
|
||||||
|
workspace_artifact(),
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
);
|
||||||
|
assert_eq!(session.chronicle.len(), 1);
|
||||||
|
|
||||||
|
let (inv, _) = session
|
||||||
|
.invite(
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
"did:web:isv.local:eng:bob",
|
||||||
|
ForgeRole::Collaborator,
|
||||||
|
3600,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session.chronicle.len(), 2);
|
||||||
|
|
||||||
|
session
|
||||||
|
.join("did:web:isv.local:eng:bob", [1u8; 32], inv.invitation_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session.chronicle.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ceremony tests ──────────────────────
|
||||||
|
|
||||||
|
const ALICE: &str = "did:web:msp.local:eng:alice";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propose_transitions_to_ceremony_in_progress() {
|
||||||
|
let (mut s, _) = ForgeSession::open("test", workspace_artifact(), ALICE, [0u8; 32]);
|
||||||
|
|
||||||
|
let (proposal, evt) = s.propose(ALICE, [0u8; 32], "fix: timeout", "abc1234").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.state, ForgeSessionState::CeremonyInProgress);
|
||||||
|
assert!(s.active_proposal.is_some());
|
||||||
|
assert_eq!(proposal.state, ForgeProposalState::Pending);
|
||||||
|
assert_eq!(evt.kind, ForgeEventKind::ForgeChangeProposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propose_requires_active_state() {
|
||||||
|
let (mut s, _) = ForgeSession::open("test", workspace_artifact(), ALICE, [0u8; 32]);
|
||||||
|
|
||||||
|
s.propose(ALICE, [0u8; 32], "first", "abc").unwrap();
|
||||||
|
let result = s.propose(ALICE, [0u8; 32], "second", "def");
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(ForgeError::ProposalAlreadyPending)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approve_transitions_to_ready_to_execute() {
|
||||||
|
let (mut s, _) = ForgeSession::open("test", workspace_artifact(), ALICE, [0u8; 32]);
|
||||||
|
|
||||||
|
s.propose(ALICE, [0u8; 32], "fix", "abc").unwrap();
|
||||||
|
let evt = s.approve(ALICE, [0u8; 32]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.state, ForgeSessionState::ReadyToExecute);
|
||||||
|
assert_eq!(s.active_proposal.as_ref().unwrap().state, ForgeProposalState::Approved);
|
||||||
|
assert_eq!(evt.kind, ForgeEventKind::ForgeChangeApproved);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deny_returns_session_to_active() {
|
||||||
|
let (mut s, _) = ForgeSession::open("test", workspace_artifact(), ALICE, [0u8; 32]);
|
||||||
|
|
||||||
|
s.propose(ALICE, [0u8; 32], "fix", "abc").unwrap();
|
||||||
|
let evt = s.deny(ALICE, [0u8; 32], None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.state, ForgeSessionState::Active);
|
||||||
|
assert!(s.active_proposal.is_none());
|
||||||
|
assert_eq!(s.proposals.len(), 1);
|
||||||
|
assert_eq!(s.proposals[0].state, ForgeProposalState::Denied);
|
||||||
|
assert_eq!(evt.kind, ForgeEventKind::ForgeChangeDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mark_executed_requires_ready_to_execute() {
|
||||||
|
let (mut s, _) = ForgeSession::open("test", workspace_artifact(), ALICE, [0u8; 32]);
|
||||||
|
|
||||||
|
s.propose(ALICE, [0u8; 32], "fix", "abc").unwrap();
|
||||||
|
// Try to execute without approving
|
||||||
|
let result = s.mark_executed(ALICE, [0u8; 32], "merged123");
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(ForgeError::ProposalNotApproved)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_propose_approve_execute_flow() {
|
||||||
|
let (mut s, _) = ForgeSession::open("test", workspace_artifact(), ALICE, [0u8; 32]);
|
||||||
|
assert_eq!(s.chronicle.len(), 1); // opened
|
||||||
|
|
||||||
|
s.propose(ALICE, [0u8; 32], "fix: timeout", "abc1234").unwrap();
|
||||||
|
assert_eq!(s.chronicle.len(), 2); // + proposed
|
||||||
|
|
||||||
|
s.approve(ALICE, [0u8; 32]).unwrap();
|
||||||
|
assert_eq!(s.chronicle.len(), 3); // + approved
|
||||||
|
|
||||||
|
s.mark_executed(ALICE, [0u8; 32], "merged5678").unwrap();
|
||||||
|
assert_eq!(s.chronicle.len(), 4); // + executed
|
||||||
|
|
||||||
|
assert_eq!(s.state, ForgeSessionState::Active);
|
||||||
|
assert!(s.active_proposal.is_none());
|
||||||
|
assert_eq!(s.proposals.len(), 1);
|
||||||
|
assert_eq!(s.proposals[0].state, ForgeProposalState::Executed);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
forge-workspace/Cargo.toml
Normal file
23
forge-workspace/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "forge-workspace"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Git worktree backend for forge-core governed collaboration"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
forge-core = { workspace = true }
|
||||||
|
|
||||||
|
# Cross-workspace gRPC client dep.
|
||||||
|
# workspace-controller proto lives in guildhouse.
|
||||||
|
guildhouse-proto = { path = "../../guildhouse/services/guildhouse-proto" }
|
||||||
|
|
||||||
|
tonic = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
190
forge-workspace/src/client.rs
Normal file
190
forge-workspace/src/client.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
//! Workspace-controller gRPC client trait + real implementation.
|
||||||
|
//!
|
||||||
|
//! The trait-based design allows MockWorkspaceClient in tests
|
||||||
|
//! without needing a running workspace-controller.
|
||||||
|
|
||||||
|
use crate::error::WorkspaceError;
|
||||||
|
|
||||||
|
/// Abstraction over workspace-controller gRPC calls.
|
||||||
|
/// Implemented by `GrpcWorkspaceClient` (real) and
|
||||||
|
/// `MockWorkspaceClient` (tests).
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait WorkspaceClient: Send + Sync {
|
||||||
|
/// Create a new workspace. Returns (worktree_path, branch_name, base_commit).
|
||||||
|
async fn create(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
base_branch: &str,
|
||||||
|
author_name: &str,
|
||||||
|
author_email: &str,
|
||||||
|
read_only: bool,
|
||||||
|
) -> Result<(String, String, String), WorkspaceError>;
|
||||||
|
|
||||||
|
/// Get current diff as unified text.
|
||||||
|
async fn get_diff(&mut self, session_id: &str) -> Result<String, WorkspaceError>;
|
||||||
|
|
||||||
|
/// Stage all files and commit. Returns commit SHA.
|
||||||
|
async fn commit(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
message: &str,
|
||||||
|
author_did: &str,
|
||||||
|
) -> Result<String, WorkspaceError>;
|
||||||
|
|
||||||
|
/// Merge session branch to canonical. Returns merged commit SHA.
|
||||||
|
async fn merge_to_canonical(&mut self, session_id: &str) -> Result<String, WorkspaceError>;
|
||||||
|
|
||||||
|
/// Destroy the workspace (worktree + branch cleanup).
|
||||||
|
async fn destroy(&mut self, session_id: &str) -> Result<(), WorkspaceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real gRPC client connecting to workspace-controller.
|
||||||
|
pub struct GrpcWorkspaceClient {
|
||||||
|
inner: guildhouse_proto::workspace::v1::workspace_controller_client::WorkspaceControllerClient<
|
||||||
|
tonic::transport::Channel,
|
||||||
|
>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrpcWorkspaceClient {
|
||||||
|
pub async fn connect(addr: &str) -> Result<Self, WorkspaceError> {
|
||||||
|
let inner = guildhouse_proto::workspace::v1::workspace_controller_client::WorkspaceControllerClient::connect(
|
||||||
|
addr.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Self { inner })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl WorkspaceClient for GrpcWorkspaceClient {
|
||||||
|
async fn create(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
base_branch: &str,
|
||||||
|
author_name: &str,
|
||||||
|
author_email: &str,
|
||||||
|
read_only: bool,
|
||||||
|
) -> Result<(String, String, String), WorkspaceError> {
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.create_workspace(guildhouse_proto::workspace::v1::CreateWorkspaceRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
base_branch: base_branch.to_string(),
|
||||||
|
author_name: author_name.to_string(),
|
||||||
|
author_email: author_email.to_string(),
|
||||||
|
read_only,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_grpc_error)?
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
Ok((resp.workspace_path, resp.branch_name, resp.base_commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_diff(&mut self, session_id: &str) -> Result<String, WorkspaceError> {
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.get_diff(guildhouse_proto::workspace::v1::GetDiffRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
uncommitted_only: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_grpc_error)?
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
let combined: String = resp
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("--- {}\n{}", f.path, f.diff_text))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
Ok(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
message: &str,
|
||||||
|
author_did: &str,
|
||||||
|
) -> Result<String, WorkspaceError> {
|
||||||
|
// Stage all files first
|
||||||
|
self.inner
|
||||||
|
.stage_files(guildhouse_proto::workspace::v1::StageFilesRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
paths: vec![], // empty = stage all
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_grpc_error)?;
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.commit(guildhouse_proto::workspace::v1::CommitRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
author_name: author_did.to_string(),
|
||||||
|
author_email: String::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_grpc_error)?
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
Ok(resp.commit_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn merge_to_canonical(&mut self, session_id: &str) -> Result<String, WorkspaceError> {
|
||||||
|
// Get the branch name for this session
|
||||||
|
let status = self
|
||||||
|
.inner
|
||||||
|
.get_workspace_status(
|
||||||
|
guildhouse_proto::workspace::v1::GetWorkspaceStatusRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_grpc_error)?
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.merge_to_canonical(guildhouse_proto::workspace::v1::MergeToCanonicalRequest {
|
||||||
|
branch_name: status.branch_name,
|
||||||
|
merge_message: format!("forge: merge session {session_id}"),
|
||||||
|
author_name: String::new(),
|
||||||
|
author_email: String::new(),
|
||||||
|
ceremony_id: String::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_grpc_error)?
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
if !resp.success {
|
||||||
|
if resp.error.contains("conflict") {
|
||||||
|
return Err(WorkspaceError::MergeConflict);
|
||||||
|
}
|
||||||
|
return Err(WorkspaceError::Unavailable(resp.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp.new_head)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn destroy(&mut self, session_id: &str) -> Result<(), WorkspaceError> {
|
||||||
|
self.inner
|
||||||
|
.destroy_workspace(guildhouse_proto::workspace::v1::DestroyWorkspaceRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_grpc_error)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_grpc_error(status: tonic::Status) -> WorkspaceError {
|
||||||
|
match status.code() {
|
||||||
|
tonic::Code::NotFound => WorkspaceError::NotFound(status.message().to_string()),
|
||||||
|
tonic::Code::AlreadyExists => WorkspaceError::AlreadyExists(status.message().to_string()),
|
||||||
|
tonic::Code::FailedPrecondition => WorkspaceError::MergeConflict,
|
||||||
|
_ => WorkspaceError::Grpc(status),
|
||||||
|
}
|
||||||
|
}
|
||||||
23
forge-workspace/src/error.rs
Normal file
23
forge-workspace/src/error.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum WorkspaceError {
|
||||||
|
#[error("workspace-controller unavailable: {0}")]
|
||||||
|
Unavailable(String),
|
||||||
|
|
||||||
|
#[error("workspace not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("workspace already exists for session: {0}")]
|
||||||
|
AlreadyExists(String),
|
||||||
|
|
||||||
|
#[error("merge conflict — manual resolution required")]
|
||||||
|
MergeConflict,
|
||||||
|
|
||||||
|
#[error("gRPC error: {0}")]
|
||||||
|
Grpc(#[from] tonic::Status),
|
||||||
|
|
||||||
|
#[error("transport error: {0}")]
|
||||||
|
Transport(#[from] tonic::transport::Error),
|
||||||
|
|
||||||
|
#[error("forge error: {0}")]
|
||||||
|
Forge(#[from] forge_core::session::ForgeError),
|
||||||
|
}
|
||||||
16
forge-workspace/src/lib.rs
Normal file
16
forge-workspace/src/lib.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//! forge-workspace — git worktree backend for forge-core.
|
||||||
|
//!
|
||||||
|
//! Wraps workspace-controller gRPC to provide governed git worktree
|
||||||
|
//! sessions as the backing artifact for ForgeSession.
|
||||||
|
//!
|
||||||
|
//! forge-workspace is the bridge between:
|
||||||
|
//! forge-core (governance state machine)
|
||||||
|
//! workspace-controller (git operations)
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
pub mod error;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
pub use client::{GrpcWorkspaceClient, WorkspaceClient};
|
||||||
|
pub use error::WorkspaceError;
|
||||||
|
pub use session::WorkspaceForgeSession;
|
||||||
408
forge-workspace/src/session.rs
Normal file
408
forge-workspace/src/session.rs
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
//! WorkspaceForgeSession — drives a ForgeSession backed by a git worktree
|
||||||
|
//! via workspace-controller gRPC.
|
||||||
|
|
||||||
|
use forge_core::{ForgeArtifact, ForgeChronicleEvent, ForgeEventKind, ForgeSession};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::client::WorkspaceClient;
|
||||||
|
use crate::error::WorkspaceError;
|
||||||
|
|
||||||
|
/// A ForgeSession backed by a git worktree.
|
||||||
|
pub struct WorkspaceForgeSession<C: WorkspaceClient> {
|
||||||
|
pub session: ForgeSession,
|
||||||
|
client: C,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: WorkspaceClient> WorkspaceForgeSession<C> {
|
||||||
|
/// Open a new governed workspace session.
|
||||||
|
pub async fn open(
|
||||||
|
mut client: C,
|
||||||
|
name: impl Into<String>,
|
||||||
|
owner_did: impl Into<String>,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
base_branch: &str,
|
||||||
|
author_name: &str,
|
||||||
|
author_email: &str,
|
||||||
|
read_only: bool,
|
||||||
|
) -> Result<(Self, ForgeChronicleEvent), WorkspaceError> {
|
||||||
|
let owner_did = owner_did.into();
|
||||||
|
let name = name.into();
|
||||||
|
let session_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let (worktree_path, branch_name, base_commit) = client
|
||||||
|
.create(
|
||||||
|
&session_id.to_string(),
|
||||||
|
base_branch,
|
||||||
|
author_name,
|
||||||
|
&owner_did,
|
||||||
|
read_only,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %session_id,
|
||||||
|
worktree = %worktree_path,
|
||||||
|
branch = %branch_name,
|
||||||
|
"workspace created"
|
||||||
|
);
|
||||||
|
|
||||||
|
let artifact = ForgeArtifact::Workspace {
|
||||||
|
worktree_path,
|
||||||
|
branch_name,
|
||||||
|
base_commit,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (forge_session, evt) = ForgeSession::open(name, artifact, &owner_did, accord_hash);
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Self {
|
||||||
|
session: forge_session,
|
||||||
|
client,
|
||||||
|
},
|
||||||
|
evt,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current diff.
|
||||||
|
pub async fn diff(&mut self) -> Result<String, WorkspaceError> {
|
||||||
|
self.client
|
||||||
|
.get_diff(&self.session.session_id.to_string())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit current changes. Returns commit SHA + Chronicle event.
|
||||||
|
pub async fn commit(
|
||||||
|
&mut self,
|
||||||
|
message: &str,
|
||||||
|
author_did: &str,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
) -> Result<(String, ForgeChronicleEvent), WorkspaceError> {
|
||||||
|
let commit_sha = self
|
||||||
|
.client
|
||||||
|
.commit(&self.session.session_id.to_string(), message, author_did)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session.session_id,
|
||||||
|
ForgeEventKind::ForgeChangeProposed,
|
||||||
|
author_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"commit_sha": &commit_sha,
|
||||||
|
"message": message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.session.chronicle.push(evt.clone());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %self.session.session_id,
|
||||||
|
commit = %commit_sha,
|
||||||
|
author = %author_did,
|
||||||
|
"change committed"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((commit_sha, evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge to canonical branch. Returns merge commit SHA + Chronicle event.
|
||||||
|
pub async fn execute(
|
||||||
|
&mut self,
|
||||||
|
executor_did: &str,
|
||||||
|
accord_hash: [u8; 32],
|
||||||
|
) -> Result<(String, ForgeChronicleEvent), WorkspaceError> {
|
||||||
|
let merged_sha = self
|
||||||
|
.client
|
||||||
|
.merge_to_canonical(&self.session.session_id.to_string())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let evt = ForgeChronicleEvent::new(
|
||||||
|
self.session.session_id,
|
||||||
|
ForgeEventKind::ForgeChangeExecuted,
|
||||||
|
executor_did,
|
||||||
|
accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"merged_sha": &merged_sha,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.session.chronicle.push(evt.clone());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %self.session.session_id,
|
||||||
|
merged_sha = %merged_sha,
|
||||||
|
executor = %executor_did,
|
||||||
|
"change executed — merged to canonical"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((merged_sha, evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the session and destroy the worktree.
|
||||||
|
pub async fn close(&mut self, closer_did: &str) -> Result<ForgeChronicleEvent, WorkspaceError> {
|
||||||
|
let evt = self
|
||||||
|
.session
|
||||||
|
.close(closer_did)
|
||||||
|
.map_err(WorkspaceError::Forge)?;
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.destroy(&self.session.session_id.to_string())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %self.session.session_id,
|
||||||
|
"session closed, worktree destroyed"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon the session — no changes merged. Destroys worktree.
|
||||||
|
pub async fn abandon(
|
||||||
|
&mut self,
|
||||||
|
abandoner_did: &str,
|
||||||
|
) -> Result<ForgeChronicleEvent, WorkspaceError> {
|
||||||
|
let evt = self
|
||||||
|
.session
|
||||||
|
.abandon(abandoner_did)
|
||||||
|
.map_err(WorkspaceError::Forge)?;
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.destroy(&self.session.session_id.to_string())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %self.session.session_id,
|
||||||
|
"session abandoned, worktree destroyed"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Chronicle log.
|
||||||
|
pub fn chronicle(&self) -> &[ForgeChronicleEvent] {
|
||||||
|
&self.session.chronicle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current worktree path.
|
||||||
|
pub fn worktree_path(&self) -> Option<&str> {
|
||||||
|
match &self.session.artifact {
|
||||||
|
ForgeArtifact::Workspace { worktree_path, .. } => Some(worktree_path),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current branch name.
|
||||||
|
pub fn branch_name(&self) -> Option<&str> {
|
||||||
|
match &self.session.artifact {
|
||||||
|
ForgeArtifact::Workspace { branch_name, .. } => Some(branch_name),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::WorkspaceClient;
|
||||||
|
use forge_core::{ForgeEventKind, ForgeSessionState};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Mock workspace-controller client for testing.
|
||||||
|
struct MockWorkspaceClient {
|
||||||
|
calls: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockWorkspaceClient {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_log(&self) -> Vec<String> {
|
||||||
|
self.calls.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl WorkspaceClient for MockWorkspaceClient {
|
||||||
|
async fn create(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
_base_branch: &str,
|
||||||
|
_author_name: &str,
|
||||||
|
_author_email: &str,
|
||||||
|
_read_only: bool,
|
||||||
|
) -> Result<(String, String, String), WorkspaceError> {
|
||||||
|
self.calls.lock().unwrap().push(format!("create:{session_id}"));
|
||||||
|
Ok((
|
||||||
|
"/mock/worktrees/test".to_string(),
|
||||||
|
"session-test".to_string(),
|
||||||
|
"abc1234".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_diff(&mut self, session_id: &str) -> Result<String, WorkspaceError> {
|
||||||
|
self.calls.lock().unwrap().push(format!("get_diff:{session_id}"));
|
||||||
|
Ok("mock diff output".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
_message: &str,
|
||||||
|
_author_did: &str,
|
||||||
|
) -> Result<String, WorkspaceError> {
|
||||||
|
self.calls.lock().unwrap().push(format!("commit:{session_id}"));
|
||||||
|
Ok("deadbeef1234".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn merge_to_canonical(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<String, WorkspaceError> {
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("merge_to_canonical:{session_id}"));
|
||||||
|
Ok("merged5678abcd".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn destroy(&mut self, session_id: &str) -> Result<(), WorkspaceError> {
|
||||||
|
self.calls.lock().unwrap().push(format!("destroy:{session_id}"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_open_creates_session_with_worktree() {
|
||||||
|
let mock = MockWorkspaceClient::new();
|
||||||
|
|
||||||
|
let (ws, evt) = WorkspaceForgeSession::open(
|
||||||
|
mock,
|
||||||
|
"test-session",
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
"main",
|
||||||
|
"Alice",
|
||||||
|
"alice@test.com",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(ws.session.state, ForgeSessionState::Active);
|
||||||
|
assert_eq!(ws.worktree_path(), Some("/mock/worktrees/test"));
|
||||||
|
assert_eq!(ws.branch_name(), Some("session-test"));
|
||||||
|
assert_eq!(ws.chronicle().len(), 1);
|
||||||
|
assert_eq!(evt.kind, ForgeEventKind::ForgeSessionOpened);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_commit_emits_chronicle_event() {
|
||||||
|
let mock = MockWorkspaceClient::new();
|
||||||
|
let (mut ws, _) = WorkspaceForgeSession::open(
|
||||||
|
mock,
|
||||||
|
"test-session",
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
"main",
|
||||||
|
"Alice",
|
||||||
|
"alice@test.com",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (sha, evt) = ws
|
||||||
|
.commit("fix: thing", "did:web:msp.local:eng:alice", [0u8; 32])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sha, "deadbeef1234");
|
||||||
|
assert_eq!(evt.kind, ForgeEventKind::ForgeChangeProposed);
|
||||||
|
assert_eq!(ws.chronicle().len(), 2);
|
||||||
|
assert!(evt.payload.get("commit_sha").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_execute_emits_chronicle_event() {
|
||||||
|
let mock = MockWorkspaceClient::new();
|
||||||
|
let (mut ws, _) = WorkspaceForgeSession::open(
|
||||||
|
mock,
|
||||||
|
"test-session",
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
"main",
|
||||||
|
"Alice",
|
||||||
|
"alice@test.com",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ws.commit("fix: thing", "did:web:msp.local:eng:alice", [0u8; 32])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (sha, evt) = ws
|
||||||
|
.execute("did:web:msp.local:eng:alice", [0u8; 32])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sha, "merged5678abcd");
|
||||||
|
assert_eq!(evt.kind, ForgeEventKind::ForgeChangeExecuted);
|
||||||
|
assert!(evt.payload.get("merged_sha").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_close_destroys_worktree() {
|
||||||
|
let mock = MockWorkspaceClient::new();
|
||||||
|
let calls = mock.calls.clone();
|
||||||
|
|
||||||
|
let (mut ws, _) = WorkspaceForgeSession::open(
|
||||||
|
mock,
|
||||||
|
"test-session",
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
"main",
|
||||||
|
"Alice",
|
||||||
|
"alice@test.com",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ws.close("did:web:msp.local:eng:alice").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(ws.session.state, ForgeSessionState::Closed);
|
||||||
|
let log = calls.lock().unwrap();
|
||||||
|
assert!(log.iter().any(|c| c.starts_with("destroy:")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_abandon_destroys_worktree() {
|
||||||
|
let mock = MockWorkspaceClient::new();
|
||||||
|
let calls = mock.calls.clone();
|
||||||
|
|
||||||
|
let (mut ws, _) = WorkspaceForgeSession::open(
|
||||||
|
mock,
|
||||||
|
"test-session",
|
||||||
|
"did:web:msp.local:eng:alice",
|
||||||
|
[0u8; 32],
|
||||||
|
"main",
|
||||||
|
"Alice",
|
||||||
|
"alice@test.com",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ws.abandon("did:web:msp.local:eng:alice").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(ws.session.state, ForgeSessionState::Abandoned);
|
||||||
|
let log = calls.lock().unwrap();
|
||||||
|
assert!(log.iter().any(|c| c.starts_with("destroy:")));
|
||||||
|
}
|
||||||
|
}
|
||||||
24
forge/Cargo.toml
Normal file
24
forge/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
[package]
|
||||||
|
name = "forge"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Governed collaboration CLI — the Substrate equivalent of git"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "forge"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
forge-core = { workspace = true }
|
||||||
|
forge-workspace = { workspace = true }
|
||||||
|
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tonic = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
31
forge/src/approve.rs
Normal file
31
forge/src/approve.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
use crate::ForgeContext;
|
||||||
|
|
||||||
|
pub async fn run(ctx: ForgeContext, proposal_id: Option<uuid::Uuid>) -> anyhow::Result<()> {
|
||||||
|
// Phase A: approve = immediately ready to merge (ceremony stub).
|
||||||
|
// Phase B: validate approver role, call CeremonyEngine::approve(),
|
||||||
|
// check accord permits approval.
|
||||||
|
|
||||||
|
let evt = forge_core::ForgeChronicleEvent::new(
|
||||||
|
uuid::Uuid::parse_str(&ctx.session_id)?,
|
||||||
|
forge_core::ForgeEventKind::ForgeChangeApproved,
|
||||||
|
&ctx.caller_did,
|
||||||
|
ctx.accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"proposal_id": proposal_id.map(|id| id.to_string()).unwrap_or_else(|| "latest".to_string()),
|
||||||
|
"approver": &ctx.caller_did,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Approved.\n\
|
||||||
|
Session: {}\n\
|
||||||
|
Approver: {}\n\
|
||||||
|
\n\
|
||||||
|
Ready to merge. Run:\n forge merge",
|
||||||
|
ctx.session_id, ctx.caller_did,
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::info!(event_id = %evt.event_id, "FORGE_CHANGE_APPROVED emitted");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
34
forge/src/deny.rs
Normal file
34
forge/src/deny.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
use crate::ForgeContext;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
ctx: ForgeContext,
|
||||||
|
proposal_id: Option<uuid::Uuid>,
|
||||||
|
reason: String,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let evt = forge_core::ForgeChronicleEvent::new(
|
||||||
|
uuid::Uuid::parse_str(&ctx.session_id)?,
|
||||||
|
forge_core::ForgeEventKind::ForgeChangeDenied,
|
||||||
|
&ctx.caller_did,
|
||||||
|
ctx.accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"proposal_id": proposal_id.map(|id| id.to_string()).unwrap_or_else(|| "latest".to_string()),
|
||||||
|
"reason": &reason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Denied.\n\
|
||||||
|
Session: {}\n\
|
||||||
|
Denier: {}\n\
|
||||||
|
Reason: {}\n\
|
||||||
|
Chronicle: {}",
|
||||||
|
ctx.session_id,
|
||||||
|
ctx.caller_did,
|
||||||
|
if reason.is_empty() { "(no reason given)" } else { &reason },
|
||||||
|
evt.event_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::info!(event_id = %evt.event_id, "FORGE_CHANGE_DENIED emitted");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
63
forge/src/invite.rs
Normal file
63
forge/src/invite.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use crate::ForgeContext;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
ctx: ForgeContext,
|
||||||
|
did: String,
|
||||||
|
role_str: String,
|
||||||
|
expires_in: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let role = parse_role(&role_str)?;
|
||||||
|
|
||||||
|
// Phase A: create invitation locally.
|
||||||
|
// Phase B: send via bascule-gateway to notify the invitee.
|
||||||
|
let inv = forge_core::ForgeInvitation::new(
|
||||||
|
uuid::Uuid::parse_str(&ctx.session_id)?,
|
||||||
|
&did,
|
||||||
|
role,
|
||||||
|
&ctx.caller_did,
|
||||||
|
expires_in,
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Invitation created.\n\
|
||||||
|
Invitee: {}\n\
|
||||||
|
Role: {}\n\
|
||||||
|
Expires: {} seconds\n\
|
||||||
|
ID: {}\n\n\
|
||||||
|
Share this invitation ID with the invitee.\n\
|
||||||
|
They run:\n forge join --invitation-id {}",
|
||||||
|
did, role_str, expires_in, inv.invitation_id, inv.invitation_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_role(s: &str) -> anyhow::Result<forge_core::ForgeRole> {
|
||||||
|
match s {
|
||||||
|
"owner" => Ok(forge_core::ForgeRole::Owner),
|
||||||
|
"collaborator" => Ok(forge_core::ForgeRole::Collaborator),
|
||||||
|
"observer" => Ok(forge_core::ForgeRole::Observer),
|
||||||
|
other => anyhow::bail!(
|
||||||
|
"Unknown role: '{}'. Valid roles: owner, collaborator, observer",
|
||||||
|
other
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_role_collaborator() {
|
||||||
|
let role = parse_role("collaborator").unwrap();
|
||||||
|
assert_eq!(role, forge_core::ForgeRole::Collaborator);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_role_invalid_returns_error() {
|
||||||
|
let result = parse_role("admin");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("Unknown role"));
|
||||||
|
}
|
||||||
|
}
|
||||||
41
forge/src/log.rs
Normal file
41
forge/src/log.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
use forge_core::chronicle::{ChronicleClient, ForgeLogQuery};
|
||||||
|
|
||||||
|
use crate::ForgeContext;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
ctx: ForgeContext,
|
||||||
|
session: Option<String>,
|
||||||
|
limit: u64,
|
||||||
|
json: bool,
|
||||||
|
kind: Option<String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let client = ChronicleClient::new();
|
||||||
|
let query = ForgeLogQuery {
|
||||||
|
// Default to the current session if --session not provided.
|
||||||
|
session_id: session.or_else(|| Some(ctx.session_id.clone())),
|
||||||
|
limit,
|
||||||
|
kind_filter: kind,
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = client.query_forge_log(&query).await;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
);
|
||||||
|
} else if entries.is_empty() {
|
||||||
|
println!(
|
||||||
|
"No forge events found.\n\
|
||||||
|
(Chronicle warm tier running?)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for entry in &entries {
|
||||||
|
println!("{}", entry.display());
|
||||||
|
println!("---");
|
||||||
|
}
|
||||||
|
println!("{} event(s)", entries.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
203
forge/src/main.rs
Normal file
203
forge/src/main.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
mod approve;
|
||||||
|
mod deny;
|
||||||
|
mod invite;
|
||||||
|
mod log;
|
||||||
|
mod merge;
|
||||||
|
mod propose;
|
||||||
|
mod review;
|
||||||
|
mod status;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
/// Session context loaded from environment.
|
||||||
|
/// Set by `bascule attach workspace/{name}`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ForgeContext {
|
||||||
|
pub session_id: String,
|
||||||
|
pub token: String,
|
||||||
|
pub gateway: String,
|
||||||
|
pub workspace_path: Option<String>,
|
||||||
|
pub workspace_addr: String,
|
||||||
|
pub caller_did: String,
|
||||||
|
pub accord_hash: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeContext {
|
||||||
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
session_id: require_env("BASCULE_SESSION_ID")?,
|
||||||
|
token: require_env("BASCULE_TOKEN")?,
|
||||||
|
gateway: require_env("BASCULE_GATEWAY")?,
|
||||||
|
workspace_path: std::env::var("BASCULE_WORKSPACE").ok(),
|
||||||
|
workspace_addr: std::env::var("FORGE_WORKSPACE_ADDR").unwrap_or_else(|_| {
|
||||||
|
"http://workspace-controller.guildhouse-system.svc.cluster.local:50051".to_string()
|
||||||
|
}),
|
||||||
|
// Phase A: DID from env. Phase B: resolve from token.
|
||||||
|
caller_did: std::env::var("BASCULE_DID")
|
||||||
|
.unwrap_or_else(|_| "did:web:unknown".to_string()),
|
||||||
|
// Phase A: zeroed hash. Phase B: from active accord.
|
||||||
|
accord_hash: [0u8; 32],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_env(key: &str) -> anyhow::Result<String> {
|
||||||
|
std::env::var(key).map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Missing required env var: {}\nRun 'bascule attach workspace/{{name}}' first.",
|
||||||
|
key
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "forge", about = "Governed collaboration — the Substrate equivalent of git", version)]
|
||||||
|
struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum Commands {
|
||||||
|
/// Propose a change in the current workspace session.
|
||||||
|
/// Commits staged changes and initiates the ceremony approval flow.
|
||||||
|
Propose {
|
||||||
|
/// Commit message describing the change.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Approve a pending proposal. Requires Owner role.
|
||||||
|
Approve {
|
||||||
|
/// Specific proposal ID to approve (defaults to most recent).
|
||||||
|
#[arg(long)]
|
||||||
|
proposal_id: Option<uuid::Uuid>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Deny a pending proposal.
|
||||||
|
Deny {
|
||||||
|
#[arg(long)]
|
||||||
|
proposal_id: Option<uuid::Uuid>,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Merge an approved proposal to the canonical branch.
|
||||||
|
Merge,
|
||||||
|
|
||||||
|
/// Invite a principal to join this ForgeSession. Requires Owner role.
|
||||||
|
Invite {
|
||||||
|
/// DID of the principal to invite.
|
||||||
|
did: String,
|
||||||
|
|
||||||
|
/// Role to grant (owner, collaborator, observer).
|
||||||
|
#[arg(long, default_value = "collaborator")]
|
||||||
|
role: String,
|
||||||
|
|
||||||
|
/// Invitation expiry in seconds.
|
||||||
|
#[arg(long, default_value = "3600")]
|
||||||
|
expires_in: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show current diff for this session.
|
||||||
|
Review,
|
||||||
|
|
||||||
|
/// Show session status.
|
||||||
|
Status,
|
||||||
|
|
||||||
|
/// Show Chronicle event log for this session.
|
||||||
|
Log {
|
||||||
|
/// Filter by forge session ID (defaults to current session).
|
||||||
|
#[arg(long)]
|
||||||
|
session: Option<String>,
|
||||||
|
|
||||||
|
/// Maximum number of events to display.
|
||||||
|
#[arg(long, default_value = "50")]
|
||||||
|
limit: u64,
|
||||||
|
|
||||||
|
/// Output as newline-delimited JSON.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
|
||||||
|
/// Filter by event kind prefix (e.g. APP_FORGE_CHANGE).
|
||||||
|
#[arg(long)]
|
||||||
|
kind: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_env_filter(
|
||||||
|
std::env::var("RUST_LOG").unwrap_or_else(|_| "forge=info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
let ctx = ForgeContext::from_env()?;
|
||||||
|
|
||||||
|
match args.command {
|
||||||
|
Commands::Propose { message } => propose::run(ctx, message).await?,
|
||||||
|
Commands::Approve { proposal_id } => approve::run(ctx, proposal_id).await?,
|
||||||
|
Commands::Deny {
|
||||||
|
proposal_id,
|
||||||
|
reason,
|
||||||
|
} => deny::run(ctx, proposal_id, reason).await?,
|
||||||
|
Commands::Merge => merge::run(ctx).await?,
|
||||||
|
Commands::Invite {
|
||||||
|
did,
|
||||||
|
role,
|
||||||
|
expires_in,
|
||||||
|
} => invite::run(ctx, did, role, expires_in).await?,
|
||||||
|
Commands::Review => review::run(ctx).await?,
|
||||||
|
Commands::Status => status::run(ctx).await?,
|
||||||
|
Commands::Log {
|
||||||
|
session,
|
||||||
|
limit,
|
||||||
|
json,
|
||||||
|
kind,
|
||||||
|
} => log::run(ctx, session, limit, json, kind).await?,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_env_returns_clear_error() {
|
||||||
|
// Ensure the var is unset for this test
|
||||||
|
std::env::remove_var("BASCULE_SESSION_ID");
|
||||||
|
std::env::remove_var("BASCULE_TOKEN");
|
||||||
|
std::env::remove_var("BASCULE_GATEWAY");
|
||||||
|
|
||||||
|
let result = ForgeContext::from_env();
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err().to_string();
|
||||||
|
assert!(err.contains("BASCULE_SESSION_ID"), "error: {err}");
|
||||||
|
assert!(err.contains("bascule attach"), "error: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_addr_has_default() {
|
||||||
|
std::env::set_var("BASCULE_SESSION_ID", "test-id");
|
||||||
|
std::env::set_var("BASCULE_TOKEN", "test-token");
|
||||||
|
std::env::set_var("BASCULE_GATEWAY", "http://localhost:50052");
|
||||||
|
std::env::remove_var("FORGE_WORKSPACE_ADDR");
|
||||||
|
|
||||||
|
let ctx = ForgeContext::from_env().unwrap();
|
||||||
|
assert!(
|
||||||
|
ctx.workspace_addr.contains("workspace-controller"),
|
||||||
|
"addr: {}",
|
||||||
|
ctx.workspace_addr
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
std::env::remove_var("BASCULE_SESSION_ID");
|
||||||
|
std::env::remove_var("BASCULE_TOKEN");
|
||||||
|
std::env::remove_var("BASCULE_GATEWAY");
|
||||||
|
}
|
||||||
|
}
|
||||||
41
forge/src/merge.rs
Normal file
41
forge/src/merge.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
use crate::ForgeContext;
|
||||||
|
use forge_workspace::{GrpcWorkspaceClient, WorkspaceClient};
|
||||||
|
|
||||||
|
pub async fn run(ctx: ForgeContext) -> anyhow::Result<()> {
|
||||||
|
let mut client = GrpcWorkspaceClient::connect(&ctx.workspace_addr).await?;
|
||||||
|
|
||||||
|
// Phase A: merge without ceremony check.
|
||||||
|
// Phase B: verify ceremony is APPROVED before calling merge_to_canonical.
|
||||||
|
// The ceremony gate prevents unauthorized merges.
|
||||||
|
let merged_sha = client
|
||||||
|
.merge_to_canonical(&ctx.session_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Merge failed: {e}"))?;
|
||||||
|
|
||||||
|
let evt = forge_core::ForgeChronicleEvent::new(
|
||||||
|
uuid::Uuid::parse_str(&ctx.session_id)?,
|
||||||
|
forge_core::ForgeEventKind::ForgeChangeExecuted,
|
||||||
|
&ctx.caller_did,
|
||||||
|
ctx.accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"merged_sha": &merged_sha,
|
||||||
|
"session_id": &ctx.session_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Merged.\n\
|
||||||
|
Commit: {}\n\
|
||||||
|
Session: {}\n\
|
||||||
|
Actor: {}\n\
|
||||||
|
Chronicle: {}",
|
||||||
|
&merged_sha[..8.min(merged_sha.len())],
|
||||||
|
ctx.session_id,
|
||||||
|
ctx.caller_did,
|
||||||
|
evt.event_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::info!(event_id = %evt.event_id, merged_sha = %merged_sha, "FORGE_CHANGE_EXECUTED emitted");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
45
forge/src/propose.rs
Normal file
45
forge/src/propose.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use crate::ForgeContext;
|
||||||
|
use forge_workspace::{GrpcWorkspaceClient, WorkspaceClient};
|
||||||
|
|
||||||
|
pub async fn run(ctx: ForgeContext, message: String) -> anyhow::Result<()> {
|
||||||
|
let mut client = GrpcWorkspaceClient::connect(&ctx.workspace_addr).await?;
|
||||||
|
|
||||||
|
// Phase A: commit directly via workspace-controller.
|
||||||
|
// Phase B: open a WorkspaceForgeSession and go through
|
||||||
|
// the full propose → ceremony → execute flow.
|
||||||
|
|
||||||
|
// Stage all + commit
|
||||||
|
let commit_sha = client
|
||||||
|
.commit(&ctx.session_id, &message, &ctx.caller_did)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Commit failed: {e}"))?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Proposed: {}\n\
|
||||||
|
Commit: {}\n\
|
||||||
|
\n\
|
||||||
|
Ceremony approval required.\n\
|
||||||
|
Waiting for: Owner approval\n\
|
||||||
|
\n\
|
||||||
|
Notify approver to run:\n forge approve",
|
||||||
|
message,
|
||||||
|
&commit_sha[..8.min(commit_sha.len())],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase A: Chronicle event emitted to tracing only.
|
||||||
|
// Phase B: write to Chronicle via bascule-gateway.
|
||||||
|
let evt = forge_core::ForgeChronicleEvent::new(
|
||||||
|
uuid::Uuid::parse_str(&ctx.session_id)?,
|
||||||
|
forge_core::ForgeEventKind::ForgeChangeProposed,
|
||||||
|
&ctx.caller_did,
|
||||||
|
ctx.accord_hash,
|
||||||
|
serde_json::json!({
|
||||||
|
"commit_sha": &commit_sha,
|
||||||
|
"message": &message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::info!(event_id = %evt.event_id, "FORGE_CHANGE_PROPOSED emitted");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
22
forge/src/review.rs
Normal file
22
forge/src/review.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use crate::ForgeContext;
|
||||||
|
use forge_workspace::{GrpcWorkspaceClient, WorkspaceClient};
|
||||||
|
|
||||||
|
pub async fn run(ctx: ForgeContext) -> anyhow::Result<()> {
|
||||||
|
let mut client = GrpcWorkspaceClient::connect(&ctx.workspace_addr).await?;
|
||||||
|
|
||||||
|
let diff = client
|
||||||
|
.get_diff(&ctx.session_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Diff failed: {e}"))?;
|
||||||
|
|
||||||
|
if diff.is_empty() {
|
||||||
|
println!("No changes in this session.");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"Session: {}\nActor: {}\n\n--- diff ---\n{}\n--- end diff ---",
|
||||||
|
ctx.session_id, ctx.caller_did, diff,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
20
forge/src/status.rs
Normal file
20
forge/src/status.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
use crate::ForgeContext;
|
||||||
|
|
||||||
|
pub async fn run(ctx: ForgeContext) -> anyhow::Result<()> {
|
||||||
|
// Phase A: show context from env vars.
|
||||||
|
// Phase B: query ForgeSession state from forge-workspace or gateway.
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Session: {}\n\
|
||||||
|
Actor: {}\n\
|
||||||
|
Workspace: {}\n\
|
||||||
|
Gateway: {}\n\
|
||||||
|
State: Active (Phase A stub)",
|
||||||
|
ctx.session_id,
|
||||||
|
ctx.caller_did,
|
||||||
|
ctx.workspace_path.as_deref().unwrap_or("(not set)"),
|
||||||
|
ctx.gateway,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in a new issue