Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c183abd
feat(bindings): expose portable-v2 and streaming sinks for #744
DecisionNerd Aug 20, 2026
fbcc62e
feat(cli): expose portable-v2, OCI, sinks, and import sessions
DecisionNerd Aug 20, 2026
4e5306f
fix(ci): unblock #851 surface gate, clippy, and binding parity
DecisionNerd Aug 20, 2026
e20ad55
merge(main): sync feat/744 with latest main for #851
DecisionNerd Aug 20, 2026
fa29338
fix(ci): resolve ruff and prettier failures on #851
DecisionNerd Aug 20, 2026
998f8a3
fix(bindings): triage #851 review threads and unlock CI
cursoragent Aug 20, 2026
ce54d1d
fix(bindings): update Node async-errors Task count for import session
cursoragent Aug 20, 2026
6cca45b
fix(api): assert empty-project subset preview fails closed
DecisionNerd Aug 20, 2026
61bba67
fix(bindings): triage #851 CodeRabbit threads on green head
cursoragent Aug 20, 2026
531a6d0
merge(main): sync feat/744 with latest main for #851
cursoragent Aug 20, 2026
92a84aa
fix(ci): absolute gf binary path and drop unused ruff noqa
cursoragent Aug 20, 2026
b5a0c02
merge main into feat/744-bindings-interchange-parity
DecisionNerd Aug 20, 2026
db4601b
Merge branch 'feat/744-bindings-interchange-parity' of https://github…
DecisionNerd Aug 20, 2026
75ab61e
fix(bindings): stub progress callback and tighten ValidationError assert
cursoragent Aug 20, 2026
7697044
fix(bindings): harden portable verify tests and stub progress
DecisionNerd Aug 20, 2026
f232ee7
merge remote review fixes; keep GF_REPOSITORY verify coverage
DecisionNerd Aug 20, 2026
7739c8c
fix(ci): import Callable from collections.abc for ruff UP035
DecisionNerd Aug 20, 2026
e001a8d
Merge remote-tracking branch 'origin/main' into feat/744-bindings-int…
DecisionNerd Aug 20, 2026
e0f79ae
Merge remote-tracking branch 'origin/main' into feat/744-bindings-int…
DecisionNerd Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions crates/graphforge-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,24 @@ mod write_modes;

pub use graphforge_storage::{
PortableV2Authenticity, PortableV2Compatibility, PortableV2Error, PortableV2ErrorCode,
PortableV2Integrity, PortableV2Limits, PortableV2Mode, PortableV2PackageClass,
PortableV2Representation,
PortableV2ExportLimits, PortableV2ExportPlan, PortableV2ExportProgress,
PortableV2ExportReceipt, PortableV2GraphSelector, PortableV2GraphSubsetMeta,
PortableV2Integrity, PortableV2Limits, PortableV2Mode, PortableV2OciAuthenticityPolicy,
PortableV2OciPhase, PortableV2OciProgress, PortableV2OciPullReceipt, PortableV2OciReference,
PortableV2OciSignatureMaterial, PortableV2OciSignatureState, PortableV2Output,
PortableV2PackageClass, PortableV2ParticipantId, PortableV2PropertyProjection,
PortableV2Representation, PortableV2SelectionEntry, PortableV2SelectionPlan,
PortableV2SelectionProfile, PortableV2SelectionReason, PortableV2SelectionRequest,
PortableV2SubsetClosure, PortableV2SubsetPlan, PortableV2SubsetRequest,
};
pub use portable::{
PortableExportRequest, PortableExportResult, PortableImportRequest, PortableImportResult,
PortableSelection, PortableV2ImportRequest, PortableV2ImportResult,
PortableV2OciPublishFacadeRequest, PortableV2OciPullFacadeRequest, PortableVerifyRequest,
PortableVerifyResult, publish_portable_v2_oci, publish_portable_v2_oci_with_registry,
pull_portable_v2_oci, pull_portable_v2_oci_with_registry, verify_portable_v2,
PortableSelection, PortableV2ExportFacadeResult, PortableV2ExportRequest,
PortableV2ImportRequest, PortableV2ImportResult, PortableV2OciPublishFacadeRequest,
PortableV2OciPullFacadeRequest, PortableV2SelectionPreviewRequest,
PortableV2SubsetPreviewRequest, PortableVerifyRequest, PortableVerifyResult,
publish_portable_v2_oci, publish_portable_v2_oci_with_registry, pull_portable_v2_oci,
pull_portable_v2_oci_with_registry, verify_portable_v2,
};
pub use repository::{
GitProvenance, InfraCapabilityCompatibility, InfraNotChecked, InfraPlan, InfraStaticValidity,
Expand Down
284 changes: 275 additions & 9 deletions crates/graphforge-api/src/portable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,72 @@ pub struct PortableVerifyRequest {
/// Stable Rust-owned portable-v2 verification report.
pub type PortableVerifyResult = graphforge_storage::PortableV2Report;

/// Portable-v2 selection preview request against a pinned generation.
#[derive(Clone, Debug)]
pub struct PortableV2SelectionPreviewRequest {
/// Current or named-checkpoint selection.
pub selection: PortableSelection,
/// Selection profile / custom identities.
pub request: graphforge_storage::PortableV2SelectionRequest,
/// Caller-selected finite resource limits.
pub limits: graphforge_storage::PortableV2Limits,
}

/// Portable-v2 graph-subset preview request against a pinned generation.
#[derive(Clone, Debug)]
pub struct PortableV2SubsetPreviewRequest {
/// Current or named-checkpoint selection.
pub selection: PortableSelection,
/// Graph-subset selector and closure.
pub request: graphforge_storage::PortableV2SubsetRequest,
/// Caller-selected finite resource limits.
pub limits: graphforge_storage::PortableV2Limits,
}

/// Portable-v2 export request (expanded directory or canonical bundle).
#[derive(Clone, Debug)]
pub struct PortableV2ExportRequest {
/// Current or named-checkpoint selection.
pub selection: PortableSelection,
/// Destination path (new file or directory). Existing paths are rejected.
pub output_path: PathBuf,
/// Expanded directory or canonical `.gfpb` bundle.
pub representation: graphforge_storage::PortableV2Output,
/// Component selection profile. Ignored when `subset` is `Some`.
pub profile: graphforge_storage::PortableV2SelectionProfile,
/// Optional graph/data subset. When set, exports `graph-data-subset`.
pub subset: Option<graphforge_storage::PortableV2SubsetRequest>,
/// Caller-selected finite planner and streaming limits.
pub limits: graphforge_storage::PortableV2Limits,
}

/// Stable portable-v2 export receipt for bindings and CLI JSON.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct PortableV2ExportFacadeResult {
/// Contract name.
pub contract: &'static str,
/// Source selector kind.
pub source: &'static str,
/// Named checkpoint, when selected.
pub checkpoint: Option<String>,
/// Pinned source generation.
pub generation_uuid: Uuid,
/// Semantic package identity (`sha256:…`).
pub package_digest: String,
/// Representation-specific transport identity (`sha256:…`).
pub transport_digest: String,
/// Verified physical package entry count.
pub entry_count: usize,
/// Source payload bytes, excluding tags and manifest.
pub payload_bytes: u64,
/// Published representation token.
pub representation: &'static str,
/// Immutable content-free selection fingerprint.
pub selection_fingerprint: String,
/// Caller-selected output path.
pub output: PathBuf,
}

/// Stable export result.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct PortableExportResult {
Expand Down Expand Up @@ -237,27 +303,134 @@ pub fn pull_portable_v2_oci_with_registry(
}

impl GraphForge {
/// Export one pinned current/checkpoint generation without copying live layout metadata.
pub fn export_portable(
/// Resolve a pinned generation for portable export/preview helpers.
fn resolve_portable_generation(
&self,
request: PortableExportRequest,
) -> Result<PortableExportResult, GfError> {
selection: &PortableSelection,
) -> Result<
(
graphforge_storage::ResolvedProjectGeneration,
&'static str,
Option<String>,
),
GfError,
> {
let root = self.resolved_generation.container_root();
let (generation, source, checkpoint) = match request.selection {
PortableSelection::Current => (
match selection {
PortableSelection::Current => Ok((
graphforge_storage::resolve_project_generation(root)?,
"current",
None,
),
)),
PortableSelection::Checkpoint(name) => {
let (_, generation) = graphforge_storage::open_checkpoint_generation_with_mode(
root,
&name,
name,
self.lifecycle_mode,
)?;
(generation, "checkpoint", Some(name))
Ok((generation, "checkpoint", Some(name.clone())))
}
}
}

/// Preview one content-free portable-v2 component selection.
pub fn preview_portable_v2_selection(
&self,
request: &PortableV2SelectionPreviewRequest,
) -> Result<graphforge_storage::PortableV2SelectionPlan, graphforge_storage::PortableV2Error>
{
let (generation, _, _) = self
.resolve_portable_generation(&request.selection)
.map_err(portable_resolve_err)?;
graphforge_storage::preview_portable_v2_selection(
&generation,
&request.request,
request.limits,
)
}

/// Preview one content-free portable-v2 graph-data subset.
pub fn preview_portable_v2_graph_subset(
&self,
request: &PortableV2SubsetPreviewRequest,
) -> Result<graphforge_storage::PortableV2SubsetPlan, graphforge_storage::PortableV2Error> {
let (generation, _, _) = self
.resolve_portable_generation(&request.selection)
.map_err(portable_resolve_err)?;
graphforge_storage::preview_portable_v2_graph_subset(
&generation,
&request.request,
request.limits,
)
}

/// Export one pinned generation as an expanded or bundled portable-v2 package.
pub fn export_portable_v2(
&self,
request: &PortableV2ExportRequest,
cancelled: Option<&AtomicBool>,
progress: impl FnMut(graphforge_storage::PortableV2ExportProgress),
) -> Result<PortableV2ExportFacadeResult, graphforge_storage::PortableV2Error> {
let (generation, source, checkpoint) = self
.resolve_portable_generation(&request.selection)
.map_err(portable_resolve_err)?;
let plan = if let Some(subset) = &request.subset {
let preview = graphforge_storage::preview_portable_v2_graph_subset(
&generation,
subset,
request.limits,
)?;
graphforge_storage::plan_graph_subset_portable_v2(
&generation,
&preview,
request.limits,
)?
} else {
let selection = graphforge_storage::preview_portable_v2_selection(
&generation,
&graphforge_storage::PortableV2SelectionRequest {
profile: request.profile.clone(),
strict: false,
},
request.limits,
)?;
graphforge_storage::plan_selected_portable_v2(&generation, &selection, request.limits)?
};
let default_cancelled = AtomicBool::new(false);
let cancelled = cancelled.unwrap_or(&default_cancelled);
let receipt = graphforge_storage::export_complete_portable_v2(
&plan,
&request.output_path,
request.representation,
request.limits,
cancelled,
progress,
)?;
Ok(PortableV2ExportFacadeResult {
contract: "graphforge-portable-export/2",
source,
checkpoint,
generation_uuid: receipt.generation_uuid,
package_digest: format!("sha256:{}", hex(receipt.package_digest)),
transport_digest: format!("sha256:{}", hex(receipt.transport_digest)),
entry_count: receipt.entry_count,
payload_bytes: receipt.payload_bytes,
representation: match receipt.output {
graphforge_storage::PortableV2Output::Expanded => "expanded",
graphforge_storage::PortableV2Output::Bundle => "bundle",
},
selection_fingerprint: receipt.selection_fingerprint,
output: request.output_path.clone(),
})
}

/// Export one pinned current/checkpoint generation without copying live layout metadata.
pub fn export_portable(
&self,
request: PortableExportRequest,
) -> Result<PortableExportResult, GfError> {
let (generation, source, checkpoint) =
self.resolve_portable_generation(&request.selection)?;
let receipt = graphforge_storage::export_portable_project(
&generation,
&request.output,
Expand Down Expand Up @@ -357,6 +530,13 @@ impl GraphForge {
}
}

fn portable_resolve_err(_error: GfError) -> graphforge_storage::PortableV2Error {
graphforge_storage::PortableV2Error::new(
graphforge_storage::PortableV2ErrorCode::Io,
"pinned project generation is not exportable",
)
}

fn supported_capabilities() -> Vec<ProjectCapability> {
[
"epistemic",
Expand Down Expand Up @@ -452,6 +632,92 @@ mod tests {
GraphForge::new(target.to_str()).expect("imported CURRENT must reopen");
}

#[test]
fn public_v2_export_preview_and_verify_agree_on_package_digest() {
let root = tempfile::tempdir().unwrap();
let source = root.path().join("source");
std::fs::create_dir(&source).unwrap();
let graph = GraphForge::new(source.to_str()).unwrap();
let limits = graphforge_storage::PortableV2Limits::default();
let preview = graph
.preview_portable_v2_selection(&PortableV2SelectionPreviewRequest {
selection: PortableSelection::Current,
request: graphforge_storage::PortableV2SelectionRequest {
profile: graphforge_storage::PortableV2SelectionProfile::Complete,
strict: false,
},
limits,
})
.unwrap();
assert_eq!(preview.package_class, "complete");
let expanded = root.path().join("expanded");
let bundle = root.path().join("complete.gfpb");
let expanded_export = graph
.export_portable_v2(
&PortableV2ExportRequest {
selection: PortableSelection::Current,
output_path: expanded.clone(),
representation: graphforge_storage::PortableV2Output::Expanded,
profile: graphforge_storage::PortableV2SelectionProfile::Complete,
subset: None,
limits,
},
None,
|_| {},
)
.unwrap();
let bundle_export = graph
.export_portable_v2(
&PortableV2ExportRequest {
selection: PortableSelection::Current,
output_path: bundle.clone(),
representation: graphforge_storage::PortableV2Output::Bundle,
profile: graphforge_storage::PortableV2SelectionProfile::Complete,
subset: None,
limits,
},
None,
|_| {},
)
.unwrap();
assert_eq!(expanded_export.package_digest, bundle_export.package_digest);
assert_eq!(
expanded_export.selection_fingerprint,
preview.selection_fingerprint
);
let verified = verify_portable_v2(
&PortableVerifyRequest {
input: bundle,
mode: graphforge_storage::PortableV2Mode::Full,
limits,
},
None,
)
.unwrap();
assert_eq!(verified.package_digest, bundle_export.package_digest);
let subset_error = graph
.preview_portable_v2_graph_subset(&PortableV2SubsetPreviewRequest {
selection: PortableSelection::Current,
request: graphforge_storage::PortableV2SubsetRequest {
selector: graphforge_storage::PortableV2GraphSelector::default(),
closure: graphforge_storage::PortableV2SubsetClosure::InducedEdges,
projection: graphforge_storage::PortableV2PropertyProjection::default(),
},
limits,
})
.unwrap_err();
assert_eq!(
subset_error.code,
graphforge_storage::PortableV2ErrorCode::Incompatible
);
assert!(
subset_error
.to_string()
.contains("pinned generation has no graph tree"),
"empty projects must fail closed before subset planning: {subset_error}"
);
}

#[test]
fn public_v2_import_facade_verifies_publishes_and_reopens() {
let root = tempfile::tempdir().unwrap();
Expand Down
Loading