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:
Tyler King 2026-03-18 16:45:06 -04:00
commit d0c7429636
25 changed files with 5563 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target/
**/target/
**/*.rs.bk
.env
*.swp
*.swo

2699
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

31
Cargo.toml Normal file
View 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
View 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
View 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; --"));
}
}

View 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
View 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};

View 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
View 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
View 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);
}
}

View 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 }

View 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),
}
}

View 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),
}

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(())
}