feat: enforce workspace-scoped resource references
This commit is contained in:
@@ -848,18 +848,16 @@ fn collect_foreign_key_diagnostics(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
|
// The Ticket component owns its required foreign keys, while an integrated host may
|
||||||
|
// strengthen Workspace/domain boundaries with additional references to host-owned
|
||||||
|
// tables. Reject missing component constraints, but do not treat those host extensions
|
||||||
|
// as Ticket schema drift.
|
||||||
for missing in expected.difference(&actual) {
|
for missing in expected.difference(&actual) {
|
||||||
push_diagnostic(
|
push_diagnostic(
|
||||||
diagnostics,
|
diagnostics,
|
||||||
format!("table {table:?} is missing foreign key {missing:?}"),
|
format!("table {table:?} is missing foreign key {missing:?}"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for unexpected in actual.difference(&expected) {
|
|
||||||
push_diagnostic(
|
|
||||||
diagnostics,
|
|
||||||
format!("table {table:?} has unexpected foreign key {unexpected:?}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_foreign_key_check_diagnostics(
|
fn collect_foreign_key_check_diagnostics(
|
||||||
|
|||||||
@@ -2843,12 +2843,6 @@ mod tests {
|
|||||||
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
|
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
|
||||||
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
||||||
let db_path = dir.path().join("workspace.db");
|
let db_path = dir.path().join("workspace.db");
|
||||||
SqliteTicketBackend::open(&db_path, "workspace-test")
|
|
||||||
.unwrap()
|
|
||||||
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
|
||||||
dir.path().join(".yoi/tickets"),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
@@ -2861,6 +2855,27 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
SqliteTicketBackend::open(&db_path, "workspace-test")
|
||||||
|
.unwrap()
|
||||||
|
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
||||||
|
dir.path().join(".yoi/tickets"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
rusqlite::Connection::open(&db_path)
|
||||||
|
.unwrap()
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
INSERT INTO workspace_resource_human_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'),
|
||||||
|
('workspace-test', 'ticket', '00000000001J5', 2, 'T-2', '2026-01-01T00:00:00Z'),
|
||||||
|
('workspace-test', 'ticket', '00000000001J6', 3, 'T-3', '2026-01-01T00:00:00Z');
|
||||||
|
INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence)
|
||||||
|
VALUES ('workspace-test', 'ticket', 4);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
store
|
store
|
||||||
.upsert_objective(&ObjectiveRecord {
|
.upsert_objective(&ObjectiveRecord {
|
||||||
workspace_id: "workspace-test".to_string(),
|
workspace_id: "workspace-test".to_string(),
|
||||||
@@ -3216,6 +3231,26 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
rusqlite::Connection::open(&db_path)
|
||||||
|
.unwrap()
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
INSERT INTO typed_tickets (
|
||||||
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
|
workflow_state, workflow_state_explicit
|
||||||
|
) VALUES
|
||||||
|
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1),
|
||||||
|
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1);
|
||||||
|
INSERT INTO workspace_resource_human_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'),
|
||||||
|
('workspace-test', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z');
|
||||||
|
INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence)
|
||||||
|
VALUES ('workspace-test', 'ticket', 3);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap();
|
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap();
|
||||||
|
|
||||||
let created = authority
|
let created = authority
|
||||||
|
|||||||
@@ -12889,38 +12889,10 @@ mod tests {
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
|
|
||||||
let ticket = browser_ticket_backend(&api)
|
|
||||||
.unwrap()
|
|
||||||
.create(create_input)
|
|
||||||
.unwrap();
|
|
||||||
let flow_ticket_launch = WorkerSpawnRequest {
|
|
||||||
requested_worker_name: Some("cross-workspace-ticket".to_string()),
|
|
||||||
intent: WorkerSpawnIntent::TicketRole {
|
|
||||||
ticket_id: ticket.id,
|
|
||||||
role: TicketWorkerRole::Coder,
|
|
||||||
},
|
|
||||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
|
||||||
expected_segments: 2,
|
|
||||||
},
|
|
||||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
|
||||||
ticket_assignment: None,
|
|
||||||
initial_submit: vec![
|
|
||||||
Segment::Flow {
|
|
||||||
selector: "builtin:coder-review".to_string(),
|
|
||||||
},
|
|
||||||
Segment::text("Implement the Ticket"),
|
|
||||||
],
|
|
||||||
working_directory_request: None,
|
|
||||||
resolved_working_directory_request: None,
|
|
||||||
resolved_working_directory: None,
|
|
||||||
resolved_config_bundle: None,
|
|
||||||
resolved_worker_observation_enabled: false,
|
|
||||||
resolved_worker_observation_grants: Vec::new(),
|
|
||||||
resolved_control_operation: None,
|
|
||||||
resolved_workspace_api: None,
|
|
||||||
};
|
|
||||||
assert!(
|
assert!(
|
||||||
api.validate_worker_spawn_repository_scope(&flow_ticket_launch)
|
browser_ticket_backend(&api)
|
||||||
|
.unwrap()
|
||||||
|
.create(create_input)
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -14392,27 +14364,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut missing = ticket::NewTicket::new("Missing target");
|
let mut missing = ticket::NewTicket::new("Missing target");
|
||||||
missing.repository_id = Some("unknown".to_owned());
|
missing.repository_id = Some("unknown".to_owned());
|
||||||
let missing = backend.create(missing).unwrap();
|
assert!(backend.create(missing).is_err());
|
||||||
assert!(matches!(
|
|
||||||
backend.mark_ready(
|
|
||||||
TicketIdOrSlug::Id(missing.id.clone()),
|
|
||||||
ticket::TicketMarkReady {
|
|
||||||
operation_key: "missing-repository".to_owned(),
|
|
||||||
reason: None,
|
|
||||||
author: None,
|
|
||||||
intake_summary: None,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Err(ticket::TicketError::UnknownTargetRepository(_))
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
backend
|
|
||||||
.show(TicketIdOrSlug::Id(missing.id))
|
|
||||||
.unwrap()
|
|
||||||
.meta
|
|
||||||
.workflow_state,
|
|
||||||
TicketWorkflowState::Planning
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
backend.set_workflow_state(
|
backend.set_workflow_state(
|
||||||
TicketIdOrSlug::Id(ticket_ref.id),
|
TicketIdOrSlug::Id(ticket_ref.id),
|
||||||
@@ -18947,6 +18899,26 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
rusqlite::Connection::open(&config.database_path)
|
||||||
|
.unwrap()
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
INSERT INTO typed_tickets (
|
||||||
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
|
workflow_state, workflow_state_explicit
|
||||||
|
) VALUES
|
||||||
|
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1),
|
||||||
|
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1);
|
||||||
|
INSERT INTO workspace_resource_human_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
|
||||||
|
) VALUES
|
||||||
|
('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'),
|
||||||
|
('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z');
|
||||||
|
INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence)
|
||||||
|
VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let api = WorkspaceApi::new_with_execution_backend(
|
let api = WorkspaceApi::new_with_execution_backend(
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user