feat: integrate workspace-scoped resource references

This commit is contained in:
2026-08-20 16:10:42 +09:00
7 changed files with 1512 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
+9
View File
@@ -1082,6 +1082,11 @@ mod tests {
) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')", ) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')",
[worker_id().to_string()], [worker_id().to_string()],
)?; )?;
c.execute(
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'planning', 1)",
[],
)?;
Ok(()) Ok(())
}) })
.unwrap(); .unwrap();
@@ -1267,7 +1272,11 @@ mod tests {
fn purge_tombstone_commit_is_idempotent() { fn purge_tombstone_commit_is_idempotent() {
let s = setup(); let s = setup();
s.with_conn(|conn| { s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?; conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?;
conn.execute("DELETE FROM typed_tickets WHERE workspace_id='w' AND ticket_id='ticket-old'", [])?;
Ok(()) Ok(())
}).unwrap(); }).unwrap();
let p = s.plan_worker_removal(&req(), &inv()).unwrap(); let p = s.plan_worker_removal(&req(), &inv()).unwrap();
+75 -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),
@@ -14604,6 +14556,21 @@ mod tests {
.create(ticket::NewTicket::new("Assigned Ticket")) .create(ticket::NewTicket::new("Assigned Ticket"))
.unwrap(); .unwrap();
let ticket_id = created.id; let ticket_id = created.id;
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new("embedded", "42"),
display_name: "Worker 42".to_string(),
profile: Some("builtin:coder".to_string()),
retention_state: "normal".to_string(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
})
.unwrap();
let assignment = TicketWorkerAssignmentRecord { let assignment = TicketWorkerAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
ticket_id: ticket_id.clone(), ticket_id: ticket_id.clone(),
@@ -14698,6 +14665,24 @@ mod tests {
.unwrap() .unwrap()
.worker .worker
.unwrap(); .unwrap();
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new(
EMBEDDED_WORKER_RUNTIME_ID,
source_worker.worker.worker_id.clone(),
),
display_name: "Source Worker".to_string(),
profile: Some("builtin:coder".to_string()),
retention_state: "normal".to_string(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
})
.unwrap();
let recipient_worker = api let recipient_worker = api
.runtime .runtime
.spawn_worker( .spawn_worker(
@@ -14708,6 +14693,24 @@ mod tests {
.unwrap() .unwrap()
.worker .worker
.unwrap(); .unwrap();
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new(
EMBEDDED_WORKER_RUNTIME_ID,
recipient_worker.worker.worker_id.clone(),
),
display_name: "Recipient Worker".to_string(),
profile: Some("builtin:coder".to_string()),
retention_state: "normal".to_string(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
})
.unwrap();
let backend = browser_ticket_backend(&api).unwrap(); let backend = browser_ticket_backend(&api).unwrap();
let ticket_ref = backend let ticket_ref = backend
.create(ticket::NewTicket::new("Notify assigned Worker")) .create(ticket::NewTicket::new("Notify assigned Worker"))
@@ -18947,6 +18950,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
+1
View File
@@ -22,6 +22,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. 14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. 15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
16. [`development/validation.md`](development/validation.md) — how to check changes. 16. [`development/validation.md`](development/validation.md) — how to check changes.
17. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
## What belongs here ## What belongs here
@@ -0,0 +1,42 @@
# Workspace database schema migration runbook
The Workspace Server owns one control-plane SQLite database. Schema changes are applied by the Server at startup; domain components such as Ticket and Merge Request contribute tables to that same database, but they do not create a second Workspace authority.
## Before deployment
1. Stop writes and shut down every Server process using the database. Do not run two Server generations against one database during migration.
2. Record the current binary revision and database schema version.
3. Take a byte-for-byte backup of the database and its WAL/SHM state using a SQLite-safe backup procedure.
4. Run the read-only plan with the new binary:
```sh
yoi-server migrate --dry-run --database <server.db>
```
The plan runs against an in-memory copy. It reports the current and target schema versions, migration names, Worker identity mappings, and repairs without mutating the source database. Workspace-resource preflight failures name the relation and bounded offending row identities; repair those rows through the owning domain authority before retrying.
## Applying
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity;
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
- checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and
- restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back.
After startup, verify:
```sql
SELECT MAX(version) FROM __yoi_schema_migrations;
PRAGMA foreign_key_check;
PRAGMA integrity_check;
```
The expected migration version is `39`, `foreign_key_check` returns no rows, and `integrity_check` returns `ok`.
## Failure and rollback
There is no in-place down migration. A failed migration transaction leaves the prior schema version and data intact. Keep the Server stopped, preserve the failure diagnostics, and either repair the preflight data with the prior generation or restore the complete pre-migration backup before retrying.
Never run an older binary after a newer schema version has committed. Startup fences this case and refuses to serve when the database schema version is newer than the binary supports. Rollback therefore means restoring both the prior binary and its matching pre-migration database backup; it does not mean pointing the old binary at the upgraded database.