feat: enforce workspace-scoped resource references

This commit is contained in:
2026-08-20 10:42:57 +09:00
parent 2315c69f0a
commit 223a6ed011
4 changed files with 887 additions and 118 deletions
+4 -6
View File
@@ -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(
+41 -6
View File
@@ -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
+24 -52
View File
@@ -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