Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion 04-backend/harness-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ impl HarnessError {
Self::NotFound(_) => 404,
Self::TenantIsolation(_) | Self::SandboxDenied(_) => 403,
Self::NoAdapter(_) => 503,
Self::InvalidInput(_) | Self::InvalidModelId(_) => 400,
Self::InvalidInput(_) | Self::InvalidModelId(_) | Self::LicenseViolation(_) => 400,
Self::ModelNotWhitelisted(_) => 422,
Self::SlaViolation { .. } => 504,
_ => 500,
Expand Down
32 changes: 30 additions & 2 deletions 04-backend/harness-core/src/knowledge_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,6 @@ impl KnowledgeSourceRegistryService {
}
if let Some(source_url) = req.source_url {
validate_required("source_url", &source_url)?;
validate_github_trending_policy(&source_url, &source.refresh_policy)?;
source.source_url = source_url;
}
if let Some(license) = req.license {
Expand Down Expand Up @@ -428,7 +427,6 @@ impl KnowledgeSourceRegistryService {
source.owner = owner;
}
if let Some(refresh_policy) = req.refresh_policy {
validate_github_trending_policy(&source.source_url, &refresh_policy)?;
source.refresh_policy = refresh_policy;
}
if let Some(permission_policy) = req.permission_policy {
Expand All @@ -443,6 +441,7 @@ impl KnowledgeSourceRegistryService {
if let Some(citation_policy) = req.citation_policy {
source.citation_policy = citation_policy;
}
validate_github_trending_policy(&source.source_url, &source.refresh_policy)?;
if is_candidate_only(&source.license, source.vendor_id.as_deref()) {
source.status = KnowledgeSourceStatus::CandidateOnly;
source.production_enabled = false;
Expand Down Expand Up @@ -751,4 +750,33 @@ mod tests {
);
assert_eq!(source.default_route, "disabled");
}

#[test]
fn github_trending_source_url_and_policy_can_be_updated_atomically() {
let registry = KnowledgeSourceRegistryService::new();
registry
.create_source(create_request("standards-to-trending"))
.expect("source should create");

let source = registry
.update_source(
"standards-to-trending",
super::UpdateKnowledgeSourceRequest {
source_url: Some("https://github.com/trending".to_owned()),
refresh_policy: Some(
"scheduled network job required; no ranking is synthesized locally"
.to_owned(),
),
..Default::default()
},
)
.expect("source_url and refresh policy should validate together");

assert_eq!(source.source_url, "https://github.com/trending");
assert!(
source
.refresh_policy
.contains("scheduled network job required")
);
}
}
79 changes: 76 additions & 3 deletions 04-backend/harness-core/src/module_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,7 @@ impl ModuleGenerationService {
/// Returns [`HarnessError::NotFound`] when the job id is unknown and
/// [`HarnessError::InvalidInput`] when review is not allowed.
pub fn review_job(&self, job_id: Uuid, req: GenerationReviewRequest) -> Result<GenerationJob> {
let lifecycle = self.lifecycle.clone();
self.mutate_job(job_id, |job| {
ensure_status(job, &[GenerationJobStatus::PendingReview])?;
let review = GenerationReview {
Expand All @@ -909,17 +910,35 @@ impl ModuleGenerationService {
created_at: Utc::now(),
};
job.reviews.push(review);
if req.decision == GenerationReviewDecision::Rejected {
reject_lifecycle(
Comment on lines 912 to +914

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject lifecycle before mutating review state

When decision == Rejected, this method appends to job.reviews before calling reject_lifecycle. If reject_lifecycle returns an error (for example, the linked transaction is no longer in a rejectable state), mutate_job exits early with that error but does not roll back prior in-place mutations, so the job keeps the new review entry even though the API call failed. This creates inconsistent state and can cause duplicate/contradictory review history on retries.

Useful? React with 👍 / 👎.

&lifecycle,
job.lifecycle_transaction_id,
req.reviewer.clone(),
req.comment.clone(),
)?;
set_generated_artifact_status(job, ArtifactStatus::Rejected);
}
job.status = match req.decision {
GenerationReviewDecision::Approved => GenerationJobStatus::PendingApproval,
GenerationReviewDecision::NeedsChanges => GenerationJobStatus::PendingReview,
GenerationReviewDecision::Rejected => GenerationJobStatus::Rejected,
};
Ok(vec![AuditSpec::new(
let mut audits = vec![AuditSpec::new(
AuditEventKind::GenerationJobReviewed,
req.reviewer,
req.reviewer.clone(),
"generation job active review completed",
json!({ "decision": req.decision, "comment": req.comment }),
)])
)];
if req.decision == GenerationReviewDecision::Rejected {
audits.push(AuditSpec::new(
AuditEventKind::GenerationJobRejected,
req.reviewer,
"generation job rejected during active review",
json!({ "status": job.status }),
));
}
Ok(audits)
})
}

Expand Down Expand Up @@ -2020,6 +2039,60 @@ mod tests {
);
}

#[test]
fn active_review_reject_rejects_lifecycle_and_generated_artifacts() {
let (service, _audit, lifecycle) = service_with_lifecycle();
let job = service
.create_job(input(GenerationMode::ModelToLightweightScene))
.expect("job should be created");
let job = service
.plan_job(
job.id,
GenerationActionRequest {
actor: None,
comment: None,
},
)
.and_then(|planned| {
service.run_job(
planned.id,
GenerationActionRequest {
actor: None,
comment: None,
},
)
})
.expect("job should run");

let rejected = service
.review_job(
job.id,
GenerationReviewRequest {
reviewer: "reviewer".to_owned(),
decision: GenerationReviewDecision::Rejected,
comment: Some("active review rejected".to_owned()),
},
)
.expect("active review rejection should complete");

assert_eq!(rejected.status, GenerationJobStatus::Rejected);
assert!(
rejected
.artifacts
.iter()
.filter(|artifact| artifact.artifact_metadata.source_job_id == Some(job.id))
.all(|artifact| artifact.status == ArtifactStatus::Rejected)
);
let transaction = lifecycle
.get_transaction(
rejected
.lifecycle_transaction_id
.expect("transaction should exist"),
)
.expect("transaction should still exist");
assert_eq!(transaction.status, ModuleTransactionStatus::Rejected);
}

#[test]
fn pending_approval_reject_rejects_linked_lifecycle_transaction() {
let (service, _audit, lifecycle) = service_with_lifecycle();
Expand Down
107 changes: 107 additions & 0 deletions 04-backend/harness-core/src/viewer_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,113 @@ mod tests {
assert_eq!(acked.acknowledged_by.as_deref(), Some("viewer"));
}

#[test]
fn ack_rejects_queued_status_and_terminal_regression() {
let audit = Arc::new(ModuleAuditService::new());
let (generation, _lifecycle) = generation_service(audit);
let artifact_id = generated_artifact_id(&generation);
let service = ViewerCommandService::new(Arc::new(ModuleAuditService::new()), generation);
let command = service
.create_command(ViewerCommandCreateRequest {
adapter: ViewerAdapterHint::ThreeJs,
command: ViewerCommandKind::ZoomTo,
module_id: None,
artifact_id: Some(artifact_id),
element_ids: None,
arguments: Some(json!({ "fit": true })),
actor: None,
})
.expect("command creates");

assert!(
service
.ack_command(
command.id,
ViewerCommandAckRequest {
actor: "viewer".to_owned(),
status: ViewerCommandStatus::Queued,
comment: None,
result: None,
},
)
.is_err()
);

service
.ack_command(
command.id,
ViewerCommandAckRequest {
actor: "viewer".to_owned(),
status: ViewerCommandStatus::Executed,
comment: None,
result: None,
},
)
.expect("first terminal ack should work");

assert!(
service
.ack_command(
command.id,
ViewerCommandAckRequest {
actor: "viewer".to_owned(),
status: ViewerCommandStatus::Acknowledged,
comment: Some("regress".to_owned()),
result: None,
},
)
.is_err()
);
}

#[test]
fn required_viewer_command_kinds_are_accepted_as_contracts() {
let audit = Arc::new(ModuleAuditService::new());
let (generation, _lifecycle) = generation_service(audit.clone());
let artifact_id = generated_artifact_id(&generation);
let service = ViewerCommandService::new(audit.clone(), generation);
let command_kinds = [
ViewerCommandKind::SetColor,
ViewerCommandKind::SetVisible,
ViewerCommandKind::SetOpacity,
ViewerCommandKind::Offset,
ViewerCommandKind::Rotate,
ViewerCommandKind::ZoomTo,
ViewerCommandKind::Snapshot,
ViewerCommandKind::ExportImage,
ViewerCommandKind::Dispose,
];

for command in command_kinds {
let created = service
.create_command(ViewerCommandCreateRequest {
adapter: ViewerAdapterHint::ThreeJs,
command,
module_id: None,
artifact_id: Some(artifact_id),
element_ids: Some(vec!["architoken:demo:001".to_owned()]),
arguments: Some(json!({ "contractOnly": true })),
actor: Some("viewer-contract-test".to_owned()),
})
.expect("viewer command contract should be accepted");
assert_eq!(created.command, command);
assert_eq!(created.status, ViewerCommandStatus::Queued);
assert!(created.audit_event_id.is_some());
}

let events = audit
.list(&AuditEventQuery {
module_id: Some("digital_twin".to_owned()),
target_type: Some("viewer_command".to_owned()),
target_id: None,
actor: Some("viewer-contract-test".to_owned()),
limit: Some(20),
cursor: None,
})
.expect("audit list should work");
assert_eq!(events.items.len(), command_kinds.len());
}

#[test]
fn vendor_adapter_command_is_candidate_only() {
let audit = Arc::new(ModuleAuditService::new());
Expand Down
90 changes: 90 additions & 0 deletions 04-backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2856,6 +2856,30 @@ components:
productionCaveats:
type: array
items: { type: string }
example:
activeModuleIds: [digital_twin, production_manufacturing]
generation:
modes: [model_to_lightweight_scene, ifc_to_3dtiles]
artifactKinds: [lightweight_scene, property_index, element_identity_map]
artifactStatuses: [preview, draft, approved, rejected]
geometryFormats: [ifc, glb, gltf, 3dtiles, pointcloud, spz, vendor_opt]
propertyIndexFormats: [json, sqlite, duckdb, parquet, vendor_db]
viewer:
adapterHints: [threejs, webgpu, 3dtiles, ifc, vendor_optrapid3d]
commandKinds: [load_artifact, set_color, zoom_to, snapshot, dispose]
candidateOnlyAdapterHints: [vendor_optrapid3d]
registry:
skills: true
mcpTools: true
knowledgeSources: true
storage:
providers: [memory]
persistsRealBytes: false
productionReady: false
localImplementationMode: in_memory_preview
productionCaveats:
- No real commercial model APIs are connected.
- Vendor formats remain candidate-only.

RuntimeRegistryCapabilities:
type: object
Expand Down Expand Up @@ -3150,6 +3174,25 @@ components:
minimum: 0
pageInfo:
$ref: "#/components/schemas/PageInfo"
example:
commands:
- id: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f3301"
adapter: threejs
command: set_color
artifactId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201"
elementIds: [architoken:wall:001]
arguments: { color: "#ff6600" }
status: executed
auditEventId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f0301"
acknowledgedBy: viewer-dev
acknowledgedAt: "2026-04-30T00:01:00Z"
createdAt: "2026-04-30T00:00:00Z"
updatedAt: "2026-04-30T00:01:00Z"
total: 1
pageInfo:
limit: 50
nextCursor: null
hasMore: false

ArtifactRef:
type: object
Expand Down Expand Up @@ -3385,6 +3428,53 @@ components:
minimum: 0
pageInfo:
$ref: "#/components/schemas/PageInfo"
example:
artifacts:
- id: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201"
kind: lightweight_scene
status: approved
objectUri: memory://generation/job/artifact
fileReference: generation://files/018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201
schemaRef: artifact.lightweight_scene.schema.v1
version: 1
hash: memory-checksum-artifact
metadata: { storage: in_memory_stub, modelCalls: 0 }
reference:
artifactId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201"
artifactKind: lightweight_scene
moduleId: digital_twin
status: approved
name: model_to_lightweight_scene_mock_skill artifact
storageBinding:
artifactRole: geometry_artifact
provider: memory
objectKey: generation/job/lightweight_scene
objectUri: memory://generation/job/lightweight_scene
moduleFileId: null
fileReference: generation://files/018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201
artifactMetadata:
artifactRole: geometry_artifact
geometryFormat: gltf
propertyIndexFormat: null
elementIdNamespace: null
viewerAdapterHint: threejs
sourceModelId: source-model-001
schemaRef: artifact.lightweight_scene.schema.v1
checksum: memory-checksum-artifact
mimeType: application/vnd.architoken.lightweight-scene+json
sizeBytes: 42
owner: planner
sourceJobId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1001"
createdByJobId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1001"
approvalStatus: approved
auditEventId: null
createdAt: "2026-04-30T00:00:00Z"
versions: []
total: 1
pageInfo:
limit: 50
nextCursor: null
hasMore: false

GenerationInput:
type: object
Expand Down
Loading
Loading