feat: enforce workspace-scoped resource references
This commit is contained in:
@@ -848,18 +848,16 @@ fn collect_foreign_key_diagnostics(
|
||||
)
|
||||
})
|
||||
.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) {
|
||||
push_diagnostic(
|
||||
diagnostics,
|
||||
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(
|
||||
|
||||
@@ -2843,12 +2843,6 @@ mod tests {
|
||||
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
|
||||
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
||||
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();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
@@ -2861,6 +2855,27 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.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
|
||||
.upsert_objective(&ObjectiveRecord {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
@@ -3216,6 +3231,26 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.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 created = authority
|
||||
|
||||
@@ -12889,38 +12889,10 @@ mod tests {
|
||||
.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!(
|
||||
api.validate_worker_spawn_repository_scope(&flow_ticket_launch)
|
||||
browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(create_input)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -14392,27 +14364,7 @@ mod tests {
|
||||
|
||||
let mut missing = ticket::NewTicket::new("Missing target");
|
||||
missing.repository_id = Some("unknown".to_owned());
|
||||
let missing = backend.create(missing).unwrap();
|
||||
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!(backend.create(missing).is_err());
|
||||
assert!(matches!(
|
||||
backend.set_workflow_state(
|
||||
TicketIdOrSlug::Id(ticket_ref.id),
|
||||
@@ -18947,6 +18899,26 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.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(
|
||||
config,
|
||||
store,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user