feat: replace revision counters with domain state contracts
Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts. Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
This commit is contained in:
@@ -310,21 +310,12 @@ impl SqliteWorkspaceAuthority {
|
||||
created_at: event.created_at.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let revision = format!(
|
||||
"{}:{}",
|
||||
record.updated_at,
|
||||
all_events
|
||||
.last()
|
||||
.map(|event| event.event_id.as_str())
|
||||
.unwrap_or("none")
|
||||
);
|
||||
Ok(ObjectiveDetail {
|
||||
resource_key: self
|
||||
.resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
|
||||
id: record.objective_id,
|
||||
title: record.title,
|
||||
state: record.state,
|
||||
revision,
|
||||
created_at: Some(record.created_at),
|
||||
updated_at: Some(record.updated_at),
|
||||
linked_tickets,
|
||||
@@ -878,16 +869,9 @@ impl SqliteWorkspaceAuthority {
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let merge_request = (merge_requests.len() == 1).then(|| merge_requests[0].clone());
|
||||
let item_revision = ticket
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|event| matches!(event.kind.as_str(), "create" | "item_edit"))
|
||||
.and_then(|event| event.attributes.get("event_id").cloned())
|
||||
.or_else(|| ticket.meta.updated_at.clone())
|
||||
.unwrap_or_else(|| format!("{}:0", ticket.meta.id));
|
||||
let content_digest = ticket::ticket_content_digest(&ticket);
|
||||
let requirement_approved =
|
||||
ticket_requirement_approved(&requests, &merge_requests, &item_revision);
|
||||
ticket_requirement_approved(&requests, &merge_requests, &content_digest);
|
||||
let evidence = ticket_evidence_summary(
|
||||
&write_repository_keys,
|
||||
&merge_requests,
|
||||
@@ -934,7 +918,7 @@ impl SqliteWorkspaceAuthority {
|
||||
priority: ticket.meta.priority,
|
||||
created_at: ticket.meta.created_at,
|
||||
updated_at: ticket.meta.updated_at,
|
||||
item_revision,
|
||||
content_digest,
|
||||
queued_by: ticket.meta.queued_by,
|
||||
queued_at: ticket.meta.queued_at,
|
||||
targets,
|
||||
@@ -1846,7 +1830,7 @@ fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec<
|
||||
fn ticket_requirement_approved(
|
||||
requests: &[MergeRequest],
|
||||
summaries: &[TicketMergeRequestSummary],
|
||||
item_revision: &str,
|
||||
content_digest: &str,
|
||||
) -> bool {
|
||||
let subjects = summaries
|
||||
.iter()
|
||||
@@ -1860,7 +1844,7 @@ fn ticket_requirement_approved(
|
||||
})
|
||||
.collect::<Option<Vec<_>>>();
|
||||
subjects.is_some_and(|subjects| {
|
||||
merge_request::requirement_approval(requests, item_revision, &subjects, None).is_ok()
|
||||
merge_request::requirement_approval(requests, content_digest, &subjects, None).is_ok()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2333,7 +2317,7 @@ fn ticket_query_item(
|
||||
priority: summary.priority,
|
||||
created_at: detail.created_at.clone(),
|
||||
updated_at: summary.updated_at,
|
||||
item_revision: detail.item_revision.clone(),
|
||||
content_digest: detail.content_digest.clone(),
|
||||
workspace_action_priority: summary.workspace_action_priority,
|
||||
matched_fields,
|
||||
snippet: snippet.map(|value| truncate_body(&value, 512).0),
|
||||
@@ -2794,7 +2778,7 @@ mod tests {
|
||||
event_id: "request-1".to_string(),
|
||||
sequence: 1,
|
||||
subject_ref: "commit-1".to_string(),
|
||||
ticket_item_revision: "revision-1".to_string(),
|
||||
ticket_content_digest: "revision-1".to_string(),
|
||||
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
|
||||
merge_request_id: "mr-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
@@ -2808,7 +2792,7 @@ mod tests {
|
||||
sequence: 2,
|
||||
request_event_id: "request-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
ticket_item_revision: "revision-1".to_string(),
|
||||
ticket_content_digest: "revision-1".to_string(),
|
||||
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
|
||||
merge_request_id: "mr-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
@@ -3079,14 +3063,14 @@ mod tests {
|
||||
MergeRequestThreadEvent::ReviewRequested(review) => {
|
||||
review.event_id = format!("request-{sequence}");
|
||||
review.sequence = sequence;
|
||||
review.ticket_item_revision = revision.into();
|
||||
review.ticket_content_digest = revision.into();
|
||||
review.ticket_merge_request_subjects = subjects.clone();
|
||||
}
|
||||
MergeRequestThreadEvent::Review(review) => {
|
||||
review.event_id = format!("review-{sequence}");
|
||||
review.request_event_id = format!("request-{sequence}");
|
||||
review.sequence = sequence + 1;
|
||||
review.ticket_item_revision = revision.into();
|
||||
review.ticket_content_digest = revision.into();
|
||||
review.ticket_merge_request_subjects = subjects.clone();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
@@ -3521,7 +3505,7 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert!(ticket.body.contains("Ticket body"));
|
||||
assert!(ticket.body_truncated);
|
||||
assert!(!ticket.body.contains("Deep Ticket marker"));
|
||||
assert!(!ticket.item_revision.is_empty());
|
||||
assert!(!ticket.content_digest.is_empty());
|
||||
assert_eq!(ticket.linked_objectives[0].id, "00000000001J3");
|
||||
assert_eq!(ticket.event_page.returned, ticket.events.len());
|
||||
let ticket_query = authority
|
||||
@@ -3704,7 +3688,6 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
|
||||
let objective = authority.objective("00000000001J3").unwrap();
|
||||
assert!(objective.body.contains("Objective body"));
|
||||
assert!(!objective.revision.is_empty());
|
||||
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
|
||||
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
|
||||
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
|
||||
|
||||
@@ -261,8 +261,6 @@ pub struct BackendJobRequest {
|
||||
pub job_id: String,
|
||||
/// Stable machine-readable use-site name, for example `ticket_item_check`.
|
||||
pub purpose: String,
|
||||
/// Revision fenced by the structured result.
|
||||
pub input_revision: String,
|
||||
/// Durable domain reference used to reload or explain the input.
|
||||
pub input_ref: String,
|
||||
/// Bounded immutable input snapshot. This must not contain credentials.
|
||||
@@ -295,7 +293,6 @@ impl BackendJobRequest {
|
||||
Self {
|
||||
job_id: request.job_id,
|
||||
purpose: request.purpose,
|
||||
input_revision: request.input_revision,
|
||||
input_ref: request.input_ref,
|
||||
input: request.input,
|
||||
instruction: request.instruction,
|
||||
@@ -347,7 +344,6 @@ impl BackendJobRequest {
|
||||
job::JobRequest {
|
||||
job_id: self.job_id.clone(),
|
||||
purpose: self.purpose.clone(),
|
||||
input_revision: self.input_revision.clone(),
|
||||
input_ref: self.input_ref.clone(),
|
||||
input: self.input.clone(),
|
||||
instruction: self.instruction.clone(),
|
||||
@@ -371,6 +367,10 @@ impl BackendJobRequest {
|
||||
.or_else(|| self.serialization_key.clone())
|
||||
}
|
||||
|
||||
pub fn input_digest(&self) -> Result<String> {
|
||||
self.to_job_request().input_digest().map_err(backend_error)
|
||||
}
|
||||
|
||||
pub fn fingerprint(&self) -> Result<String> {
|
||||
self.validate()?;
|
||||
job::fingerprint(self).map_err(backend_error)
|
||||
@@ -436,7 +436,7 @@ pub struct BackendJobAttemptRecord {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
pub attempt: u8,
|
||||
pub input_revision: String,
|
||||
pub input_digest: String,
|
||||
pub state: BackendJobAttemptState,
|
||||
pub worker: Option<RuntimeWorkerRef>,
|
||||
pub runtime_run_id: Option<String>,
|
||||
@@ -492,7 +492,7 @@ impl BackendJobResultAcceptance {
|
||||
fn job_outcome(job: &BackendJobRecord, attempt: &BackendJobAttemptRecord) -> job::JobOutcome {
|
||||
job::JobOutcome {
|
||||
job_id: job.request.job_id.clone(),
|
||||
input_revision: job.request.input_revision.clone(),
|
||||
input_digest: attempt.input_digest.clone(),
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
attempt: attempt.attempt,
|
||||
state: job.state.into(),
|
||||
@@ -566,7 +566,6 @@ mod tests {
|
||||
BackendJobRequest {
|
||||
job_id: "ticket-check:T-1:r7".to_string(),
|
||||
purpose: "ticket_item_check".to_string(),
|
||||
input_revision: "7".to_string(),
|
||||
input_ref: "ticket://T-1/revisions/7".to_string(),
|
||||
input: serde_json::json!({"title": "Check me"}),
|
||||
instruction: "Check this immutable Ticket snapshot.".to_string(),
|
||||
@@ -595,7 +594,7 @@ mod tests {
|
||||
"job_id": "ticket-check:T-1:r7",
|
||||
"attempt_id": "ticket-check:T-1:r7:attempt:2",
|
||||
"attempt": 2,
|
||||
"input_revision": "7",
|
||||
"input_digest": "7",
|
||||
"state": "reserved",
|
||||
"worker": {"runtime_id": "runtime-a", "worker_id": "worker-a"},
|
||||
"runtime_run_id": "run-a",
|
||||
@@ -687,7 +686,7 @@ mod tests {
|
||||
reservation.outcome(),
|
||||
job::JobOutcome {
|
||||
job_id: reservation.job.request.job_id.clone(),
|
||||
input_revision: "7".into(),
|
||||
input_digest: "7".into(),
|
||||
attempt_id: "ticket-check:T-1:r7:attempt:2".into(),
|
||||
attempt: 2,
|
||||
state: job::JobState::Pending,
|
||||
@@ -767,11 +766,11 @@ mod tests {
|
||||
let encoded = serde_json::to_string(&request).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_revision":"7","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
|
||||
r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
|
||||
);
|
||||
assert_eq!(
|
||||
request.fingerprint().unwrap(),
|
||||
"sha256:920187fa9efa111a97c4d6869d134943e45b52edb2861cad19a0a173233c8f60"
|
||||
"sha256:250b626572e954d2aa2228364274c946d9f6b176eb0f0a205157e55031581147"
|
||||
);
|
||||
let neutral = request.to_job_request();
|
||||
neutral.validate().unwrap();
|
||||
@@ -785,7 +784,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
request.worker_input("attempt-1").unwrap(),
|
||||
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_revision: 7\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
|
||||
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_digest: sha256:7d0f26b7fc612e982fd7acf2b64f29d2e8f65c7e1292683e36d7dcac38905c50\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -794,13 +793,13 @@ mod tests {
|
||||
let submission = BackendJobResultSubmission {
|
||||
job_id: "job-1".into(),
|
||||
attempt_id: "job-1:attempt:1".into(),
|
||||
input_revision: "7".into(),
|
||||
input_digest: "7".into(),
|
||||
result: serde_json::json!({"ok": true}),
|
||||
};
|
||||
let encoded = serde_json::to_string(&submission).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_revision":"7","result":{"ok":true}}"#
|
||||
r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_digest":"7","result":{"ok":true}}"#
|
||||
);
|
||||
let neutral: job::JobResultSubmission = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(neutral, submission);
|
||||
@@ -832,12 +831,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_covers_revision_profile_limits_and_notification_provenance() {
|
||||
fn fingerprint_covers_input_profile_limits_and_notification_provenance() {
|
||||
let request = request();
|
||||
let first = request.fingerprint().unwrap();
|
||||
assert_eq!(first, request.fingerprint().unwrap());
|
||||
for mutate in [
|
||||
|request: &mut BackendJobRequest| request.input_revision = "8".to_string(),
|
||||
|request: &mut BackendJobRequest| {
|
||||
request.input = serde_json::json!({"title":"changed"})
|
||||
},
|
||||
|request: &mut BackendJobRequest| request.purpose = "alternate_check".to_string(),
|
||||
|request: &mut BackendJobRequest| request.limits.max_attempts = 3,
|
||||
|request: &mut BackendJobRequest| request.notification_target = None,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
// Frozen schema-85 cutover. Legacy ordinal/wire names belong only here.
|
||||
fn migrate_content_state_v85_to_v86(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 85 {
|
||||
return Err(Error::Store(
|
||||
"content state migration requires schema 85".into(),
|
||||
));
|
||||
}
|
||||
if !conn.is_autocommit() {
|
||||
return Err(Error::Store(
|
||||
"content state migration requires an autocommit connection".into(),
|
||||
));
|
||||
}
|
||||
let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
|
||||
let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?;
|
||||
// Replacing a parent table must not cascade-delete its referencing rows or
|
||||
// rewrite their FK targets. Restore both connection settings on every exit.
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
|
||||
let result = (|| -> Result<()> {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
|
||||
if table_exists(&tx, "workspace_config_tree_revisions")? {
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE workspace_config_tree_history (
|
||||
workspace_id TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
toolchain_fingerprint TEXT NOT NULL,
|
||||
projection_digest TEXT NOT NULL,
|
||||
manifest_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}',
|
||||
PRIMARY KEY (workspace_id, content_digest, toolchain_fingerprint, projection_digest),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
let history = {
|
||||
let mut stmt = tx.prepare("SELECT workspace_id, tree_digest, toolchain_fingerprint, projection_digest, manifest_json, created_at, schema_bundle_json FROM workspace_config_tree_revisions ORDER BY revision")?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
r.get::<_, String>(6)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, digest, toolchain, projection, manifest, created, schema) in history {
|
||||
let entries: std::collections::BTreeMap<
|
||||
config_source::VirtualPath,
|
||||
config_source::ConfigEntry,
|
||||
> = serde_json::from_str(&manifest)
|
||||
.map_err(|e| Error::Store(format!("invalid legacy config manifest: {e}")))?;
|
||||
if entries.iter().any(|(path, entry)| path != &entry.path) {
|
||||
return Err(Error::Store("legacy config manifest path mismatch".into()));
|
||||
}
|
||||
let snapshot =
|
||||
config_source::ConfigTreeSnapshot::from_entries(entries.into_values())
|
||||
.map_err(|e| Error::Store(format!("invalid legacy config content: {e}")))?;
|
||||
if snapshot.digest != digest {
|
||||
return Err(Error::Store("legacy config content digest mismatch".into()));
|
||||
}
|
||||
tx.execute("INSERT OR IGNORE INTO workspace_config_tree_history(workspace_id,content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
params![workspace,digest,toolchain,projection,manifest,created,schema])?;
|
||||
}
|
||||
tx.execute_batch(
|
||||
"DROP TABLE workspace_config_tree_revisions;
|
||||
ALTER TABLE workspace_config_trees DROP COLUMN revision;
|
||||
ALTER TABLE workspace_config_trees RENAME COLUMN tree_digest TO content_digest;",
|
||||
)?;
|
||||
}
|
||||
let missing_current: i64 = tx.query_row("SELECT COUNT(*) FROM workspace_config_trees t WHERE NOT EXISTS (
|
||||
SELECT 1 FROM workspace_config_tree_history h WHERE h.workspace_id=t.workspace_id AND h.content_digest=t.content_digest)", [], |r| r.get(0))?;
|
||||
if missing_current != 0 {
|
||||
return Err(Error::Store(
|
||||
"config cutover found missing current content".into(),
|
||||
));
|
||||
}
|
||||
if column_exists(&tx, "workspace_memory_settings", "settings_revision")? {
|
||||
tx.execute_batch(
|
||||
"ALTER TABLE workspace_memory_settings DROP COLUMN settings_revision",
|
||||
)?;
|
||||
}
|
||||
if column_exists(
|
||||
&tx,
|
||||
"worker_create_reservations",
|
||||
"memory_settings_revision",
|
||||
)? {
|
||||
remove_legacy_reservation_memory_revision(&tx)?;
|
||||
}
|
||||
// Older retained chains rebuilt this table without the lookup index.
|
||||
// Converge on the canonical schema for both legacy creation paths.
|
||||
tx.execute_batch("CREATE INDEX IF NOT EXISTS worker_create_reservations_worker ON worker_create_reservations(workspace_id, worker_id);")?;
|
||||
if column_exists(&tx, "backend_jobs", "input_revision")? {
|
||||
let jobs = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT workspace_id,job_id,request_json FROM backend_jobs")?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
tx.execute_batch(
|
||||
"ALTER TABLE backend_jobs RENAME COLUMN input_revision TO input_digest;
|
||||
ALTER TABLE backend_job_attempts RENAME COLUMN input_revision TO input_digest;",
|
||||
)?;
|
||||
for (workspace, id, encoded) in jobs {
|
||||
let mut value: serde_json::Value = serde_json::from_str(&encoded)
|
||||
.map_err(|e| Error::Store(format!("invalid legacy Job intent: {e}")))?;
|
||||
value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| Error::Store("legacy Job intent is not an object".into()))?
|
||||
.remove("input_revision");
|
||||
let request: BackendJobRequest = serde_json::from_value(value)
|
||||
.map_err(|e| Error::Store(format!("invalid legacy Job intent: {e}")))?;
|
||||
let digest = request.input_digest()?;
|
||||
let encoded =
|
||||
serde_json::to_string(&request).map_err(|e| Error::Store(e.to_string()))?;
|
||||
// A Reserved-only ledger proves no dispatch was admitted. Fail it
|
||||
// definitively and record vacuous cleanup so its resource is released.
|
||||
let reserved_only: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM backend_job_attempts WHERE workspace_id=?1 AND job_id=?2)
|
||||
AND NOT EXISTS(SELECT 1 FROM backend_job_attempts WHERE workspace_id=?1 AND job_id=?2
|
||||
AND (state != 'reserved' OR runtime_id IS NOT NULL OR worker_cleanup_state IS NOT NULL))",
|
||||
params![workspace, id], |r| r.get(0),
|
||||
)?;
|
||||
// Outstanding old capabilities must not gain authority under a new input fence.
|
||||
// Preserve completed results and cleanup receipts; uncertain execution is not retryable.
|
||||
tx.execute("UPDATE backend_jobs SET input_digest=?3,request_json=?4,intent_fingerprint=?5,
|
||||
state=CASE WHEN state='pending' THEN ?6 ELSE state END,
|
||||
failure_category=CASE WHEN state='pending' THEN 'content_contract_cutover' ELSE failure_category END,
|
||||
completed_at=CASE WHEN state='pending' THEN COALESCE(completed_at,updated_at) ELSE completed_at END
|
||||
WHERE workspace_id=?1 AND job_id=?2", params![workspace,id,digest,encoded,request.fingerprint()?, if reserved_only { "failed" } else { "unknown" }])?;
|
||||
tx.execute("UPDATE backend_job_attempts SET input_digest=?3,
|
||||
state=CASE WHEN state='reserved' AND ?4 THEN 'failed' WHEN state IN ('reserved','dispatching','dispatched') THEN 'unknown' ELSE state END,
|
||||
failure_category=CASE WHEN state IN ('reserved','dispatching','dispatched') THEN 'content_contract_cutover' ELSE failure_category END,
|
||||
worker_cleanup_state=CASE WHEN state='reserved' AND ?4 THEN 'completed' WHEN runtime_id IS NOT NULL AND state IN ('dispatching','dispatched') AND worker_cleanup_state IS NULL THEN 'pending' ELSE worker_cleanup_state END,
|
||||
completed_at=CASE WHEN state IN ('reserved','dispatching','dispatched') THEN COALESCE(completed_at,updated_at) ELSE completed_at END
|
||||
WHERE workspace_id=?1 AND job_id=?2", params![workspace,id,digest,reserved_only])?;
|
||||
tx.execute("UPDATE backend_job_deliveries SET state='unknown', failure_category='content_contract_cutover'
|
||||
WHERE workspace_id=?1 AND job_id=?2 AND state IN ('pending','sending')", params![workspace,id])?;
|
||||
}
|
||||
}
|
||||
// A legacy pending manual claim cannot be admitted against a newly inferred digest.
|
||||
tx.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='content_contract_cutover'
|
||||
WHERE claim_state='pending' AND binding_recovery_json IS NOT NULL AND assignment_id IS NULL", [])?;
|
||||
let broken: i64 =
|
||||
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
if broken != 0 {
|
||||
return Err(Error::Store(
|
||||
"content state migration foreign-key verification failed".into(),
|
||||
));
|
||||
}
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(86,'content-fenced config, Memory settings and Jobs')", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
conn.pragma_update(None, "legacy_alter_table", legacy_alter)?;
|
||||
conn.pragma_update(None, "foreign_keys", foreign_keys)?;
|
||||
result
|
||||
}
|
||||
|
||||
fn remove_legacy_reservation_memory_revision(conn: &Connection) -> Result<()> {
|
||||
// This is the exact table CHECK introduced by the frozen v52 -> v53
|
||||
// migration. Fresh v85 databases do not have it. Do not rewrite arbitrary
|
||||
// constraints: SQLite's DROP COLUMN must still reject unknown dependencies.
|
||||
const LEGACY_CHECK: &str = ",\n CHECK (\n (request_fingerprint IS NULL AND memory_settings_revision IS NULL AND memory_language IS NULL)\n OR\n (request_fingerprint IS NOT NULL AND memory_settings_revision IS NOT NULL AND memory_settings_revision > 0 AND memory_language IS NOT NULL AND length(trim(memory_language)) > 0)\n )";
|
||||
let ddl: String = conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE type='table' AND name='worker_create_reservations'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !ddl.contains(LEGACY_CHECK) {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE worker_create_reservations DROP COLUMN memory_settings_revision",
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
let ddl = ddl.replacen(LEGACY_CHECK, "", 1);
|
||||
let definition = ddl
|
||||
.find('(')
|
||||
.ok_or_else(|| Error::Store("invalid legacy reservation DDL".into()))?;
|
||||
// Retain the actual table definition, not a baseline copy: historical
|
||||
// column order, additional constraints and outbound FKs must survive.
|
||||
conn.execute_batch(&format!(
|
||||
"CREATE TABLE worker_create_reservations_v86 {}",
|
||||
&ddl[definition..]
|
||||
))?;
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE worker_create_reservations_v86 DROP COLUMN memory_settings_revision",
|
||||
)?;
|
||||
let columns = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT name FROM pragma_table_info('worker_create_reservations_v86') ORDER BY cid",
|
||||
)?;
|
||||
stmt.query_map([], |r| r.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
let columns = columns
|
||||
.into_iter()
|
||||
.map(|name| format!("\"{}\"", name.replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let indexes = {
|
||||
let mut stmt = conn.prepare("SELECT sql FROM sqlite_schema WHERE type='index' AND tbl_name='worker_create_reservations' AND sql IS NOT NULL")?;
|
||||
stmt.query_map([], |r| r.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
// Cross-table triggers can also reference this table during replacement.
|
||||
let triggers = {
|
||||
let mut stmt = conn.prepare("SELECT name,sql FROM sqlite_schema WHERE type='trigger'")?;
|
||||
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (name, _) in &triggers {
|
||||
conn.execute_batch(&format!("DROP TRIGGER \"{}\"", name.replace('"', "\"\"")))?;
|
||||
}
|
||||
// No state, fingerprint, language, lease, timestamp or uncertain admission
|
||||
// is inferred from the removed ordinal. Copy every remaining column as-is.
|
||||
conn.execute_batch(&format!(
|
||||
"INSERT INTO worker_create_reservations_v86 ({columns}) SELECT {columns} FROM worker_create_reservations;
|
||||
DROP TABLE worker_create_reservations;
|
||||
ALTER TABLE worker_create_reservations_v86 RENAME TO worker_create_reservations;"
|
||||
))?;
|
||||
for sql in indexes {
|
||||
conn.execute_batch(&sql)?;
|
||||
}
|
||||
for (_, sql) in triggers {
|
||||
conn.execute_batch(&sql)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
use super::*;
|
||||
|
||||
fn legacy_content_database(path: &Path) -> (Connection, String) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(r#"
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL);
|
||||
INSERT INTO __yoi_schema_migrations VALUES(85,'workspace schema baseline');
|
||||
CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);
|
||||
INSERT INTO workspaces VALUES('space');
|
||||
CREATE TABLE workspace_config_trees(workspace_id TEXT PRIMARY KEY,revision INTEGER,tree_digest TEXT);
|
||||
CREATE TABLE workspace_config_tree_revisions(workspace_id TEXT,revision INTEGER,tree_digest TEXT,
|
||||
toolchain_fingerprint TEXT,projection_digest TEXT,manifest_json TEXT,created_at TEXT,schema_bundle_json TEXT,
|
||||
PRIMARY KEY(workspace_id,revision));
|
||||
CREATE TABLE workspace_memory_settings(workspace_id TEXT PRIMARY KEY,settings_revision INTEGER,
|
||||
language TEXT,created_at TEXT,updated_at TEXT);
|
||||
INSERT INTO workspace_memory_settings VALUES('space',9,'Japanese','created','updated');
|
||||
CREATE TABLE ticket_assignment_operations(claim_state TEXT,failure_reason TEXT,binding_recovery_json TEXT,assignment_id TEXT);
|
||||
INSERT INTO ticket_assignment_operations VALUES('pending',NULL,'legacy recovery',NULL);
|
||||
"#).unwrap();
|
||||
// Reproduce the real upgrade path: v53's table CHECK survives v73's
|
||||
// singleton ALTERs, unlike the simplified fresh-v85 baseline definition.
|
||||
let legacy = include_str!("frozen_legacy_migrations.rs");
|
||||
let start = legacy
|
||||
.find("CREATE TABLE worker_create_reservations_v53 (")
|
||||
.unwrap();
|
||||
let end = start
|
||||
+ legacy[start..]
|
||||
.find("INSERT INTO worker_create_reservations_v53 (")
|
||||
.unwrap();
|
||||
conn.execute_batch(&legacy[start..end].replace(
|
||||
"worker_create_reservations_v53",
|
||||
"worker_create_reservations",
|
||||
))
|
||||
.unwrap();
|
||||
conn.execute_batch("ALTER TABLE worker_create_reservations ADD COLUMN singleton_key TEXT;
|
||||
ALTER TABLE worker_create_reservations ADD COLUMN singleton_generation INTEGER
|
||||
CHECK (singleton_generation IS NULL OR singleton_generation > 0);
|
||||
INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,
|
||||
request_fingerprint,memory_settings_revision,memory_language,created_at,updated_at)
|
||||
VALUES('space','create','worker','runtime','legacy-create','reserved','legacy-request',8,'English','created','updated');").unwrap();
|
||||
let frozen = include_str!("frozen_schema_v85.sql");
|
||||
let start = frozen
|
||||
.find("CREATE TRIGGER worker_create_reservation_singleton_lease_insert")
|
||||
.unwrap();
|
||||
let end = frozen
|
||||
.find("CREATE TABLE worker_singleton_owners (")
|
||||
.unwrap();
|
||||
conn.execute_batch(&frozen[start..end]).unwrap();
|
||||
// The frozen Job DDL protects the persisted checks and public replay/resource contract.
|
||||
let jobs = frozen.find("CREATE TABLE backend_jobs (").unwrap();
|
||||
let end = frozen
|
||||
.find("CREATE TABLE worker_registry_observations (")
|
||||
.unwrap();
|
||||
conn.execute_batch(&frozen[jobs..end]).unwrap();
|
||||
let snapshot =
|
||||
config_source::ConfigTreeSnapshot::from_entries([config_source::ConfigEntry::new(
|
||||
config_source::VirtualPath::parse("main.dcdl").unwrap(),
|
||||
config_source::ConfigContentType::Decodal,
|
||||
"{} as WorkspaceConfigSchema",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let manifest = serde_json::to_string(&snapshot.entries).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_config_trees VALUES('space',3,?1)",
|
||||
[&snapshot.digest],
|
||||
)
|
||||
.unwrap();
|
||||
for (ordinal, created) in [(1, "first"), (3, "third")] {
|
||||
conn.execute("INSERT INTO workspace_config_tree_revisions VALUES('space',?1,?2,'toolchain','projection',?3,?4,'{}')",
|
||||
params![ordinal,snapshot.digest,manifest,created]).unwrap();
|
||||
}
|
||||
let request = BackendJobRequest {
|
||||
job_id: "legacy-job".into(),
|
||||
purpose: "migration_test".into(),
|
||||
input_ref: "test://input".into(),
|
||||
input: serde_json::json!({"content":"immutable"}),
|
||||
instruction: "Inspect immutable input.".into(),
|
||||
profile: "builtin:backend-job".into(),
|
||||
grants: Default::default(),
|
||||
serialization_key: Some("migration-resource".into()),
|
||||
source_worker: None,
|
||||
notification_target: None,
|
||||
limits: Default::default(),
|
||||
};
|
||||
let mut value = serde_json::to_value(&request).unwrap();
|
||||
value["input_revision"] = "old-number".into();
|
||||
conn.execute("INSERT INTO backend_jobs(workspace_id,job_id,purpose,input_ref,resource_key,request_json,input_revision,intent_fingerprint,state,current_attempt,created_at,updated_at)
|
||||
VALUES('space','legacy-job','migration_test','test://input',?2,?1,'old-number','old-fingerprint','pending',1,'2026-08-13T00:00:00Z','2026-08-13T00:00:00Z')",
|
||||
params![serde_json::to_string(&value).unwrap(),request.resource_key()]).unwrap();
|
||||
conn.execute_batch("INSERT INTO backend_job_attempts(workspace_id,job_id,attempt_id,attempt,input_revision,state,runtime_id,worker_id,deadline_at,created_at,updated_at)
|
||||
VALUES('space','legacy-job','legacy-job:attempt:1',1,'old-number','dispatched','runtime','worker','2026-08-14T00:00:00Z','2026-08-13T00:00:00Z','2026-08-13T00:00:00Z');
|
||||
INSERT INTO backend_job_deliveries(workspace_id,delivery_id,job_id,attempt_id,target_runtime_id,target_worker_id,state,created_at,updated_at)
|
||||
VALUES('space','delivery','legacy-job','legacy-job:attempt:1','runtime','target','pending','created','updated');") .unwrap();
|
||||
(conn, snapshot.digest)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_reservation_check_cutover_preserves_rows_constraints_indexes_triggers_and_foreign_keys() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("content.db");
|
||||
let (conn, _) = legacy_content_database(&path);
|
||||
let error = conn
|
||||
.execute_batch(
|
||||
"ALTER TABLE worker_create_reservations DROP COLUMN memory_settings_revision",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("memory_settings_revision"),
|
||||
"{error}"
|
||||
);
|
||||
conn.execute_batch(r#"
|
||||
CREATE TABLE reservation_owners(owner_id TEXT PRIMARY KEY);
|
||||
INSERT INTO reservation_owners VALUES('owner');
|
||||
ALTER TABLE worker_create_reservations ADD COLUMN extension_owner TEXT
|
||||
REFERENCES reservation_owners(owner_id) ON DELETE RESTRICT;
|
||||
UPDATE worker_create_reservations SET extension_owner='owner';
|
||||
INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,
|
||||
request_fingerprint,memory_settings_revision,memory_language,created_at,updated_at,singleton_key,singleton_generation)
|
||||
VALUES ('space','uncertain','uncertain-worker','runtime','old-uncertain','reserved',NULL,NULL,NULL,'old','old',NULL,NULL),
|
||||
('space','created','created-worker','runtime','old-created','created','request',2,'Japanese','old','new','singleton',3),
|
||||
('space','removed','removed-worker','runtime','old-removed','removed','request',7,'French','old','new',NULL,NULL);
|
||||
CREATE INDEX reservation_pending_idx ON worker_create_reservations(workspace_id,updated_at) WHERE state='reserved';
|
||||
CREATE TABLE reservation_receipts(workspace_id TEXT,allocation_key TEXT,
|
||||
FOREIGN KEY(workspace_id,allocation_key) REFERENCES worker_create_reservations(workspace_id,allocation_key) ON DELETE CASCADE);
|
||||
INSERT INTO reservation_receipts VALUES('space','create');
|
||||
CREATE TABLE reservation_events(allocation_key TEXT,state TEXT);
|
||||
CREATE TRIGGER reservation_state_audit AFTER UPDATE OF state ON worker_create_reservations
|
||||
BEGIN INSERT INTO reservation_events VALUES(NEW.allocation_key,NEW.state); END;
|
||||
CREATE TRIGGER reservation_receipt_touch AFTER INSERT ON reservation_receipts
|
||||
BEGIN UPDATE worker_create_reservations SET updated_at=updated_at
|
||||
WHERE workspace_id=NEW.workspace_id AND allocation_key=NEW.allocation_key; END;
|
||||
"#).unwrap();
|
||||
let schema_objects = |conn: &Connection| -> Vec<(String, String)> {
|
||||
conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND
|
||||
(tbl_name='worker_create_reservations' AND type IN ('index','trigger') OR name IN ('reservation_receipts','reservation_receipt_touch')) ORDER BY name")
|
||||
.unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?))).unwrap()
|
||||
.collect::<rusqlite::Result<_>>().unwrap()
|
||||
};
|
||||
let original_objects = schema_objects(&conn);
|
||||
let columns: Vec<String> = conn.prepare("SELECT name FROM pragma_table_info('worker_create_reservations') WHERE name != 'memory_settings_revision' ORDER BY cid")
|
||||
.unwrap().query_map([], |r| r.get(0)).unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||
let select = format!(
|
||||
"SELECT {} FROM worker_create_reservations ORDER BY allocation_key",
|
||||
columns.join(",")
|
||||
);
|
||||
let rows = |conn: &Connection| -> Vec<Vec<rusqlite::types::Value>> {
|
||||
conn.prepare(&select)
|
||||
.unwrap()
|
||||
.query_map([], |r| {
|
||||
(0..columns.len())
|
||||
.map(|i| r.get(i))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<_>>()
|
||||
.unwrap()
|
||||
};
|
||||
let original_rows = rows(&conn);
|
||||
let foreign_keys = |conn: &Connection| -> Vec<(String, String, String, String)> {
|
||||
conn.prepare("SELECT \"table\",\"from\",\"to\",on_delete FROM pragma_foreign_key_list('worker_create_reservations') ORDER BY id,seq")
|
||||
.unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap()
|
||||
.collect::<rusqlite::Result<_>>().unwrap()
|
||||
};
|
||||
let original_foreign_keys = foreign_keys(&conn);
|
||||
migrate_content_state_v85_to_v86(&conn).unwrap();
|
||||
assert!(
|
||||
conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
drop(conn);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 86);
|
||||
assert!(
|
||||
!column_exists(
|
||||
&conn,
|
||||
"worker_create_reservations",
|
||||
"memory_settings_revision"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(rows(&conn), original_rows);
|
||||
let mut expected_objects = original_objects;
|
||||
expected_objects.push((
|
||||
"worker_create_reservations_worker".into(),
|
||||
"CREATE INDEX worker_create_reservations_worker ON worker_create_reservations(workspace_id, worker_id)".into(),
|
||||
));
|
||||
expected_objects.sort();
|
||||
assert_eq!(schema_objects(&conn), expected_objects);
|
||||
assert_eq!(foreign_keys(&conn), original_foreign_keys);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM reservation_receipts", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM reservation_events", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
for sql in [
|
||||
"UPDATE worker_create_reservations SET singleton_generation=NULL WHERE allocation_key='created'",
|
||||
"UPDATE worker_create_reservations SET singleton_generation=0 WHERE allocation_key='created'",
|
||||
"UPDATE worker_create_reservations SET state='pending' WHERE allocation_key='create'",
|
||||
"UPDATE worker_create_reservations SET worker_id='worker' WHERE allocation_key='uncertain'",
|
||||
"UPDATE worker_create_reservations SET extension_owner='missing' WHERE allocation_key='create'",
|
||||
"UPDATE worker_create_reservations SET workspace_id='missing' WHERE allocation_key='create'",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err(), "{sql}");
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE worker_create_reservations SET state='removed' WHERE allocation_key='create'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let audit: (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT allocation_key,state FROM reservation_events",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(audit, ("create".into(), "removed".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_v85_reservations_cut_over_without_legacy_check_and_restore_connection_settings() {
|
||||
for (foreign_keys, legacy_alter) in [(false, false), (false, true), (true, false), (true, true)]
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (conn, _) = legacy_content_database(&dir.path().join("content.db"));
|
||||
conn.execute_batch("DROP TABLE worker_create_reservations")
|
||||
.unwrap();
|
||||
let frozen = include_str!("frozen_schema_v85.sql");
|
||||
let start = frozen
|
||||
.find("CREATE TABLE worker_create_reservations (")
|
||||
.unwrap();
|
||||
let end = frozen
|
||||
.find("CREATE TABLE worker_singleton_owners (")
|
||||
.unwrap();
|
||||
conn.execute_batch(&frozen[start..end]).unwrap();
|
||||
conn.execute_batch("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,
|
||||
create_fingerprint,state,created_at,updated_at,memory_settings_revision,memory_language)
|
||||
VALUES('space','fresh','worker','runtime','fingerprint','reserved','created','updated',9,'Japanese')").unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", foreign_keys)
|
||||
.unwrap();
|
||||
conn.pragma_update(None, "legacy_alter_table", legacy_alter)
|
||||
.unwrap();
|
||||
migrate_content_state_v85_to_v86(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 86);
|
||||
assert!(
|
||||
!column_exists(
|
||||
&conn,
|
||||
"worker_create_reservations",
|
||||
"memory_settings_revision"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let saved: (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT state,memory_language FROM worker_create_reservations",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved, ("reserved".into(), "Japanese".into()));
|
||||
assert_eq!(
|
||||
conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0))
|
||||
.unwrap(),
|
||||
foreign_keys
|
||||
);
|
||||
assert_eq!(
|
||||
conn.pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0))
|
||||
.unwrap(),
|
||||
legacy_alter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_cutover_preserves_content_and_languages_but_fences_old_job_capabilities() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("content.db");
|
||||
let (conn, digest) = legacy_content_database(&path);
|
||||
migrate_content_state_v85_to_v86(&conn).unwrap();
|
||||
drop(conn);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 86);
|
||||
assert!(!table_exists(&conn, "workspace_config_tree_revisions").unwrap());
|
||||
let history: (i64, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*),content_digest,created_at FROM workspace_config_tree_history",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(history, (1, digest, "first".into()));
|
||||
assert!(!column_exists(&conn, "workspace_memory_settings", "settings_revision").unwrap());
|
||||
assert!(
|
||||
!column_exists(
|
||||
&conn,
|
||||
"worker_create_reservations",
|
||||
"memory_settings_revision"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let language: String = conn
|
||||
.query_row("SELECT language FROM workspace_memory_settings", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(language, "Japanese");
|
||||
let captured: String = conn
|
||||
.query_row(
|
||||
"SELECT memory_language FROM worker_create_reservations",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(captured, "English");
|
||||
let (encoded, state): (String, String) = conn
|
||||
.query_row("SELECT request_json,state FROM backend_jobs", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
let request: BackendJobRequest = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(state, "unknown");
|
||||
let (input_digest, state, cleanup): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT input_digest,state,worker_cleanup_state FROM backend_job_attempts",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(input_digest, request.input_digest().unwrap());
|
||||
assert_eq!(state, "unknown");
|
||||
assert_eq!(cleanup, "pending");
|
||||
let delivery: String = conn
|
||||
.query_row("SELECT state FROM backend_job_deliveries", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(delivery, "unknown");
|
||||
let claim: String = conn
|
||||
.query_row(
|
||||
"SELECT claim_state FROM ticket_assignment_operations",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(claim, "failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_cutover_keeps_each_evaluation_provenance_and_deduplicates_only_identical_evaluations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("content.db");
|
||||
let (conn, _) = legacy_content_database(&path);
|
||||
for (revision, toolchain, projection, schema) in [
|
||||
(4, "new-toolchain", "projection", "schema-2"),
|
||||
(5, "toolchain", "new-projection", "schema-1"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_config_tree_revisions
|
||||
SELECT workspace_id,?1,tree_digest,?2,?3,manifest_json,'later',?4
|
||||
FROM workspace_config_tree_revisions WHERE revision=1",
|
||||
params![revision, toolchain, projection, schema],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let expected: Vec<(String,String,String,String,String,String)> = conn.prepare(
|
||||
"SELECT tree_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json
|
||||
FROM workspace_config_tree_revisions WHERE revision != 3 ORDER BY toolchain_fingerprint,projection_digest"
|
||||
).unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?)))
|
||||
.unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||
migrate_content_state_v85_to_v86(&conn).unwrap();
|
||||
drop(conn);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 86);
|
||||
let saved: Vec<(String,String,String,String,String,String)> = conn.prepare(
|
||||
"SELECT content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json
|
||||
FROM workspace_config_tree_history ORDER BY toolchain_fingerprint,projection_digest"
|
||||
).unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?)))
|
||||
.unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||
assert_eq!(saved, expected);
|
||||
let default: String = conn.query_row(
|
||||
"SELECT dflt_value FROM pragma_table_info('workspace_config_tree_history') WHERE name='schema_bundle_json'",
|
||||
[], |r| r.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
default,
|
||||
"'{\"contributions\":[],\"source\":\"{}\",\"fingerprint\":\"\"}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_cutover_reserved_only_releases_resource_but_uncertain_dispatch_keeps_it() {
|
||||
for (state, expected_job, expected_attempt, expected_cleanup) in [
|
||||
(
|
||||
"reserved",
|
||||
BackendJobState::Failed,
|
||||
BackendJobAttemptState::Failed,
|
||||
Some(BackendJobWorkerCleanupState::Completed),
|
||||
),
|
||||
(
|
||||
"dispatching",
|
||||
BackendJobState::Unknown,
|
||||
BackendJobAttemptState::Unknown,
|
||||
Some(BackendJobWorkerCleanupState::Pending),
|
||||
),
|
||||
(
|
||||
"dispatched",
|
||||
BackendJobState::Unknown,
|
||||
BackendJobAttemptState::Unknown,
|
||||
Some(BackendJobWorkerCleanupState::Pending),
|
||||
),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("content.db");
|
||||
let (conn, _) = legacy_content_database(&path);
|
||||
conn.execute(
|
||||
"UPDATE backend_job_attempts SET state=?1,
|
||||
runtime_id=CASE WHEN ?1='reserved' THEN NULL ELSE runtime_id END,
|
||||
worker_id=CASE WHEN ?1='reserved' THEN NULL ELSE worker_id END",
|
||||
[state],
|
||||
)
|
||||
.unwrap();
|
||||
migrate_content_state_v85_to_v86(&conn).unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(Connection::open(path).unwrap())),
|
||||
};
|
||||
let job = store
|
||||
.get_backend_job("space", "legacy-job")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(job.state, expected_job, "{state}");
|
||||
let replay = store
|
||||
.reserve_backend_job("space", &job.request, "2026-08-15T00:00:00Z")
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.attempt.state, expected_attempt);
|
||||
assert_eq!(replay.attempt.worker_cleanup_state, expected_cleanup);
|
||||
let mut next = job.request.clone();
|
||||
next.job_id = "next-job".into();
|
||||
let next = store
|
||||
.reserve_backend_job("space", &next, "2026-08-15T00:00:00Z")
|
||||
.unwrap();
|
||||
assert_eq!(next.resource_reused, state != "reserved");
|
||||
assert_eq!(
|
||||
next.job.request.job_id,
|
||||
if state == "reserved" {
|
||||
"next-job"
|
||||
} else {
|
||||
"legacy-job"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_cutover_completed_result_cleanup_and_replay_remain_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("content.db");
|
||||
let (conn, _) = legacy_content_database(&path);
|
||||
let result = serde_json::json!({"finding":"preserved"});
|
||||
let (digest, encoded) = job::result_digest(&result, 4096).unwrap();
|
||||
conn.execute("UPDATE backend_jobs SET state='completed',result_json=?1,result_digest=?2,completed_at='finished'",params![encoded,digest]).unwrap();
|
||||
conn.execute("UPDATE backend_job_attempts SET state='completed',result_json=?1,result_digest=?2,
|
||||
worker_cleanup_state='completed',worker_cleanup_completed_at='cleaned',completed_at='finished'",params![encoded,digest]).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE backend_job_deliveries SET state='completed',delivered_at='delivered'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
migrate_content_state_v85_to_v86(&conn).unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(Connection::open(path).unwrap())),
|
||||
};
|
||||
let job = store
|
||||
.get_backend_job("space", "legacy-job")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let replay = store
|
||||
.reserve_backend_job("space", &job.request, "2026-08-15T00:00:00Z")
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.job.state, BackendJobState::Completed);
|
||||
assert_eq!(replay.job.result, Some(result.clone()));
|
||||
assert_eq!(replay.job.result_digest, Some(digest.clone()));
|
||||
assert_eq!(replay.job.completed_at.as_deref(), Some("finished"));
|
||||
assert_eq!(replay.attempt.state, BackendJobAttemptState::Completed);
|
||||
assert_eq!(replay.attempt.result, Some(result));
|
||||
assert_eq!(replay.attempt.result_digest, Some(digest));
|
||||
assert_eq!(
|
||||
replay.attempt.worker_cleanup_state,
|
||||
Some(BackendJobWorkerCleanupState::Completed)
|
||||
);
|
||||
assert_eq!(
|
||||
replay.attempt.worker_cleanup_completed_at.as_deref(),
|
||||
Some("cleaned")
|
||||
);
|
||||
let delivery: (String, String) = store
|
||||
.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT state,delivered_at FROM backend_job_deliveries",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(delivery, ("completed".into(), "delivered".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_job_failure_rolls_back_config_memory_jobs_and_migration_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (conn, _) = legacy_content_database(&dir.path().join("content.db"));
|
||||
let original_reservation_schema: Vec<(String,String)> = conn.prepare(
|
||||
"SELECT name,sql FROM sqlite_schema WHERE tbl_name='worker_create_reservations' AND sql IS NOT NULL ORDER BY name"
|
||||
).unwrap().query_map([],|r| Ok((r.get(0)?,r.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER fail_delivery_update BEFORE UPDATE ON backend_job_deliveries
|
||||
BEGIN SELECT RAISE(ABORT,'late cutover failure'); END;",
|
||||
)
|
||||
.unwrap();
|
||||
let original: (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT request_json,intent_fingerprint FROM backend_jobs",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
let error = migrate_content_state_v85_to_v86(&conn).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("late cutover failure"),
|
||||
"{error}"
|
||||
);
|
||||
let restored_reservation_schema: Vec<(String,String)> = conn.prepare(
|
||||
"SELECT name,sql FROM sqlite_schema WHERE tbl_name='worker_create_reservations' AND sql IS NOT NULL ORDER BY name"
|
||||
).unwrap().query_map([],|r| Ok((r.get(0)?,r.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||
assert_eq!(restored_reservation_schema, original_reservation_schema);
|
||||
assert!(!table_exists(&conn, "worker_create_reservations_v86").unwrap());
|
||||
assert!(
|
||||
conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 85);
|
||||
assert!(table_exists(&conn, "workspace_config_tree_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "workspace_config_tree_history").unwrap());
|
||||
assert!(column_exists(&conn, "workspace_config_trees", "revision").unwrap());
|
||||
assert!(column_exists(&conn, "workspace_memory_settings", "settings_revision").unwrap());
|
||||
assert!(
|
||||
column_exists(
|
||||
&conn,
|
||||
"worker_create_reservations",
|
||||
"memory_settings_revision"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(column_exists(&conn, "backend_jobs", "input_revision").unwrap());
|
||||
assert!(column_exists(&conn, "backend_job_attempts", "input_revision").unwrap());
|
||||
let saved: (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT request_json,intent_fingerprint,state FROM backend_jobs",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved, (original.0, original.1, "pending".into()));
|
||||
let attempt: (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT state,worker_cleanup_state FROM backend_job_attempts",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(attempt, ("dispatched".into(), None));
|
||||
let delivery: String = conn
|
||||
.query_row("SELECT state FROM backend_job_deliveries", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(delivery, "pending");
|
||||
let claim: String = conn
|
||||
.query_row(
|
||||
"SELECT claim_state FROM ticket_assignment_operations",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(claim, "pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_config_history_rolls_back_entire_content_cutover() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (conn, _) = legacy_content_database(&dir.path().join("content.db"));
|
||||
conn.execute(
|
||||
"UPDATE workspace_config_tree_revisions SET tree_digest='corrupt'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(migrate_content_state_v85_to_v86(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 85);
|
||||
assert!(table_exists(&conn, "workspace_config_tree_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "workspace_config_tree_history").unwrap());
|
||||
assert!(column_exists(&conn, "backend_jobs", "input_revision").unwrap());
|
||||
let state: String = conn
|
||||
.query_row("SELECT state FROM backend_jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(state, "pending");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Frozen schema-84 conversion. Legacy ordinal names are confined to this migration.
|
||||
fn migrate_flow_content_v84_to_v85(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 84 {
|
||||
return Err(Error::Store(
|
||||
"Flow content migration requires schema 84".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
if table_exists(&tx, "flow_source_revisions")? {
|
||||
// Validate every saved row before deduplicating or changing the schema.
|
||||
// Do not recompile: compiler changes must not rewrite historical meaning.
|
||||
{
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT workspace_id, flow_id, revision, content, content_digest, definition_json
|
||||
FROM flow_source_revisions",
|
||||
)?;
|
||||
let mut rows = statement.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let workspace_id: String = row.get(0)?;
|
||||
let flow_id: String = row.get(1)?;
|
||||
let revision: i64 = row.get(2)?;
|
||||
let content: String = row.get(3)?;
|
||||
let stored_digest: String = row.get(4)?;
|
||||
let definition_json: String = row.get(5)?;
|
||||
let identity = format!("{workspace_id}/{flow_id} revision {revision}");
|
||||
let source_digest = format!(
|
||||
"sha256:{}",
|
||||
Sha256::digest(content.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
if source_digest != stored_digest {
|
||||
return Err(Error::Store(format!(
|
||||
"Flow content migration found source digest mismatch for {identity}"
|
||||
)));
|
||||
}
|
||||
let definition: CompiledFlowDefinition =
|
||||
serde_json::from_str(&definition_json).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"Flow content migration found invalid stored definition for {identity}: {error}"
|
||||
))
|
||||
})?;
|
||||
if definition.content_digest != stored_digest {
|
||||
return Err(Error::Store(format!(
|
||||
"Flow content migration found definition digest mismatch for {identity}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let conflicts: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM (SELECT 1 FROM flow_source_revisions GROUP BY workspace_id,flow_id,content_digest
|
||||
HAVING COUNT(DISTINCT content)>1 OR COUNT(DISTINCT definition_json)>1)", [], |row| row.get(0))?;
|
||||
if conflicts != 0 {
|
||||
return Err(Error::Store(
|
||||
"Flow content migration found inconsistent content identity".into(),
|
||||
));
|
||||
}
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE flow_source_contents (
|
||||
workspace_id TEXT NOT NULL,
|
||||
flow_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
definition_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, flow_id, content_digest),
|
||||
FOREIGN KEY (workspace_id, flow_id)
|
||||
REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO flow_source_contents
|
||||
(workspace_id, flow_id, content, content_digest, definition_json, created_at)
|
||||
SELECT workspace_id, flow_id, content, content_digest, definition_json, MIN(created_at)
|
||||
FROM flow_source_revisions
|
||||
GROUP BY workspace_id, flow_id, content_digest;
|
||||
DROP TABLE flow_source_revisions;
|
||||
ALTER TABLE flow_sources DROP COLUMN revision;
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
let missing: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM flow_sources s WHERE NOT EXISTS (
|
||||
SELECT 1 FROM flow_source_contents c WHERE c.workspace_id=s.workspace_id
|
||||
AND c.flow_id=s.flow_id AND c.content_digest=s.content_digest
|
||||
AND c.content=s.content)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if missing != 0 {
|
||||
return Err(Error::Store(
|
||||
"Flow content migration found missing current content".into(),
|
||||
));
|
||||
}
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(85,'content-addressed Flow sources')", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use super::*;
|
||||
|
||||
fn legacy_flow_source(instructions: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
schema_version = 1;
|
||||
name = "flow";
|
||||
initial = "work";
|
||||
states = {{
|
||||
work = {{
|
||||
instructions = "{instructions}";
|
||||
transitions = {{ done = {{ target = "done"; condition = "Finished."; }}; }};
|
||||
}};
|
||||
done = {{ instructions = ""; terminal = true; }};
|
||||
}};
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn legacy_flow_definition(content: &str) -> CompiledFlowDefinition {
|
||||
let mut definition = compile_flow_source(content).unwrap();
|
||||
// Model a saved definition from a previous compiler. Migration must preserve
|
||||
// its runtime-supplied meaning, not replace it with today's compiler output.
|
||||
for state in definition.states.values_mut() {
|
||||
for transition in &mut state.transitions {
|
||||
if transition.synthetic {
|
||||
transition.condition = "Frozen compiler cancellation condition.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
definition
|
||||
}
|
||||
|
||||
// Frozen schema-84 fixture: old ordinals are migration input only.
|
||||
fn legacy_flow_database(path: &Path) -> Connection {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL);
|
||||
INSERT INTO __yoi_schema_migrations VALUES(84,'workspace schema baseline');
|
||||
CREATE TABLE flow_sources(workspace_id TEXT,flow_id TEXT,content TEXT,content_digest TEXT,
|
||||
revision INTEGER,PRIMARY KEY(workspace_id,flow_id));
|
||||
CREATE TABLE flow_source_revisions(workspace_id TEXT,flow_id TEXT,revision INTEGER,
|
||||
content TEXT,content_digest TEXT,definition_json TEXT,created_at TEXT,
|
||||
PRIMARY KEY(workspace_id,flow_id,revision));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let alpha = legacy_flow_source("alpha");
|
||||
let beta = legacy_flow_source("beta");
|
||||
let alpha_definition = legacy_flow_definition(&alpha);
|
||||
let beta_definition = legacy_flow_definition(&beta);
|
||||
conn.execute(
|
||||
"INSERT INTO flow_sources VALUES('space','flow',?1,?2,3)",
|
||||
params![alpha, alpha_definition.content_digest],
|
||||
)
|
||||
.unwrap();
|
||||
for (revision, content, definition, created_at) in [
|
||||
(1, &alpha, &alpha_definition, "first"),
|
||||
(2, &beta, &beta_definition, "second"),
|
||||
(3, &alpha, &alpha_definition, "third"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO flow_source_revisions VALUES('space','flow',?1,?2,?3,?4,?5)",
|
||||
params![
|
||||
revision,
|
||||
content,
|
||||
definition.content_digest,
|
||||
serde_json::to_string_pretty(definition).unwrap(),
|
||||
created_at
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_content_migration_preserves_current_and_historical_content_after_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("flows.db");
|
||||
let conn = legacy_flow_database(&path);
|
||||
let expected: Vec<(String, String, String, String)> = conn
|
||||
.prepare("SELECT content_digest,content,definition_json,created_at FROM flow_source_revisions WHERE revision IN (1,2) ORDER BY content_digest")
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<_>>()
|
||||
.unwrap();
|
||||
for (_, content, definition_json, _) in &expected {
|
||||
let saved: CompiledFlowDefinition = serde_json::from_str(definition_json).unwrap();
|
||||
assert_ne!(saved, compile_flow_source(content).unwrap());
|
||||
}
|
||||
migrate_flow_content_v84_to_v85(&conn).unwrap();
|
||||
drop(conn);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 85);
|
||||
let rows: Vec<(String, String, String, String)> = conn
|
||||
.prepare("SELECT content_digest,content,definition_json,created_at FROM flow_source_contents ORDER BY content_digest")
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<_>>()
|
||||
.unwrap();
|
||||
assert_eq!(rows, expected);
|
||||
let current: String = conn
|
||||
.query_row("SELECT c.content FROM flow_sources s JOIN flow_source_contents c USING(workspace_id,flow_id,content_digest)", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(current, legacy_flow_source("alpha"));
|
||||
assert!(!table_exists(&conn, "flow_source_revisions").unwrap());
|
||||
let failures: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(failures, 0);
|
||||
}
|
||||
|
||||
// Snapshot all fixture data and schema so rejection proves durable rollback,
|
||||
// including the corrupted input (which migration must not silently repair).
|
||||
fn legacy_flow_snapshot(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
[
|
||||
"SELECT name,sql FROM sqlite_schema ORDER BY name",
|
||||
"SELECT * FROM __yoi_schema_migrations ORDER BY version",
|
||||
"SELECT * FROM flow_sources ORDER BY workspace_id,flow_id",
|
||||
"SELECT * FROM flow_source_revisions ORDER BY workspace_id,flow_id,revision",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|sql| {
|
||||
let mut statement = conn.prepare(sql).unwrap();
|
||||
let count = statement.column_count();
|
||||
statement
|
||||
.query_map([], |row| (0..count).map(|column| row.get(column)).collect())
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<_>>()
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_flow_migration_rolls_back(conn: Connection, path: &Path, expected_error: &str) {
|
||||
let before = legacy_flow_snapshot(&conn);
|
||||
let error = migrate_flow_content_v84_to_v85(&conn).unwrap_err();
|
||||
assert!(error.to_string().contains(expected_error), "{error}");
|
||||
assert_eq!(legacy_flow_snapshot(&conn), before);
|
||||
drop(conn);
|
||||
let reopened = Connection::open(path).unwrap();
|
||||
assert_eq!(current_schema_version(&reopened).unwrap(), 84);
|
||||
assert!(table_exists(&reopened, "flow_source_revisions").unwrap());
|
||||
assert!(!table_exists(&reopened, "flow_source_contents").unwrap());
|
||||
assert_eq!(legacy_flow_snapshot(&reopened), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_content_migration_missing_current_content_rolls_back_data_and_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("flows.db");
|
||||
let conn = legacy_flow_database(&path);
|
||||
conn.execute(
|
||||
"DELETE FROM flow_source_revisions WHERE revision IN (1,3)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_flow_migration_rolls_back(conn, &path, "missing current content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_content_migration_single_historical_wrong_source_digest_rolls_back_data_and_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("flows.db");
|
||||
let conn = legacy_flow_database(&path);
|
||||
let mut definition = legacy_flow_definition(&legacy_flow_source("beta"));
|
||||
let wrong_digest = format!("sha256:{}", "0".repeat(64));
|
||||
definition.content_digest = wrong_digest.clone();
|
||||
conn.execute(
|
||||
"UPDATE flow_source_revisions SET content_digest=?1,definition_json=?2 WHERE revision=2",
|
||||
params![wrong_digest, serde_json::to_string(&definition).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_flow_migration_rolls_back(conn, &path, "source digest mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_content_migration_invalid_historical_definition_json_rolls_back_data_and_marker() {
|
||||
for invalid_definition in ["{", "{}"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("flows.db");
|
||||
let conn = legacy_flow_database(&path);
|
||||
conn.execute(
|
||||
"UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=2",
|
||||
[invalid_definition],
|
||||
)
|
||||
.unwrap();
|
||||
assert_flow_migration_rolls_back(conn, &path, "invalid stored definition");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_content_migration_historical_definition_digest_mismatch_rolls_back_data_and_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("flows.db");
|
||||
let conn = legacy_flow_database(&path);
|
||||
let mut definition = legacy_flow_definition(&legacy_flow_source("beta"));
|
||||
definition.content_digest = format!("sha256:{}", "0".repeat(64));
|
||||
conn.execute(
|
||||
"UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=2",
|
||||
[serde_json::to_string(&definition).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_flow_migration_rolls_back(conn, &path, "definition digest mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_content_migration_inconsistent_saved_definitions_rolls_back_without_data_loss() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("flows.db");
|
||||
let conn = legacy_flow_database(&path);
|
||||
let mut definition = legacy_flow_definition(&legacy_flow_source("alpha"));
|
||||
definition
|
||||
.states
|
||||
.get_mut(&flow::StateId::new("work").unwrap())
|
||||
.unwrap()
|
||||
.instructions = "Different saved meaning.".into();
|
||||
conn.execute(
|
||||
"UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=3",
|
||||
[serde_json::to_string_pretty(&definition).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_flow_migration_rolls_back(conn, &path, "inconsistent content identity");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
||||
// Frozen schema-86 SSH cutover. No ordinal is used as a new authority token.
|
||||
fn migrate_repository_keys_v86_to_v87(conn: &Connection) -> Result<()> {
|
||||
migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
conn,
|
||||
conn.path().filter(|path| !path.is_empty()).map(Path::new),
|
||||
)
|
||||
}
|
||||
|
||||
// Validation may run against an in-memory backup. Its secret authority remains
|
||||
// the original database's master key, not the candidate connection's location.
|
||||
fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
conn: &Connection,
|
||||
secret_source: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
if current_schema_version(conn)? != 86 {
|
||||
return Err(Error::Store(
|
||||
"Repository key migration requires schema 86".into(),
|
||||
));
|
||||
}
|
||||
if !conn.is_autocommit() {
|
||||
return Err(Error::Store(
|
||||
"Repository key migration requires an autocommit connection".into(),
|
||||
));
|
||||
}
|
||||
let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
|
||||
let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?;
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
|
||||
let result = (|| {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
|
||||
// Each stored key must resolve to one actual mutation receipt. Ambiguous or
|
||||
// missing evidence is not repaired by manufacturing an ID from its ordinal.
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE repository_key_cutover (
|
||||
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
||||
legacy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,kind,resource_id,legacy_revision));",
|
||||
)?;
|
||||
for (table, kind, id, fingerprint) in [
|
||||
(
|
||||
"repository_ssh_credential_revisions",
|
||||
"credential",
|
||||
"credential_id",
|
||||
"public_key_fingerprint",
|
||||
),
|
||||
(
|
||||
"repository_ssh_host_trust_revisions",
|
||||
"host_trust",
|
||||
"host_trust_id",
|
||||
"fingerprint",
|
||||
),
|
||||
] {
|
||||
let rows = {
|
||||
let mut statement = tx.prepare(&format!(
|
||||
"SELECT workspace_id,{id},revision,{fingerprint},created_at FROM {table}"
|
||||
))?;
|
||||
statement
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, resource, revision, fingerprint, created) in rows {
|
||||
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
|
||||
let matches = statement
|
||||
.query_map(params![workspace, kind, resource, revision, created], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
if matches.len() != 1 || matches[0].is_empty() {
|
||||
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
params![
|
||||
workspace,
|
||||
kind,
|
||||
resource,
|
||||
revision,
|
||||
matches[0],
|
||||
fingerprint,
|
||||
created
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Current rows must agree with their pointed-to historical key, not merely
|
||||
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
|
||||
let inconsistent: bool = tx.query_row("SELECT EXISTS (
|
||||
SELECT 1 FROM repository_ssh_credentials c LEFT JOIN repository_ssh_credential_revisions k
|
||||
ON k.workspace_id=c.workspace_id AND k.credential_id=c.credential_id AND k.revision=c.current_revision
|
||||
WHERE k.revision IS NULL OR k.public_key_algorithm<>c.public_key_algorithm OR k.public_key_fingerprint<>c.public_key_fingerprint
|
||||
UNION ALL
|
||||
SELECT 1 FROM repository_ssh_host_trusts c LEFT JOIN repository_ssh_host_trust_revisions k
|
||||
ON k.workspace_id=c.workspace_id AND k.host_trust_id=c.host_trust_id AND k.revision=c.current_revision
|
||||
WHERE k.revision IS NULL OR k.hostname<>c.hostname OR k.port<>c.port OR k.key_algorithm<>c.key_algorithm OR k.host_key<>c.host_key OR k.fingerprint<>c.fingerprint
|
||||
UNION ALL
|
||||
SELECT 1 FROM workdir_create_credential_revision_retentions r LEFT JOIN workdir_create_credential_candidates c
|
||||
ON c.workspace_id=r.workspace_id AND c.operation_id=r.operation_id AND c.ordinal=r.ordinal
|
||||
WHERE c.ordinal IS NULL OR c.credential_id<>r.credential_id OR c.credential_revision<>r.credential_revision
|
||||
)", [], |r| r.get(0))?;
|
||||
if inconsistent {
|
||||
return Err(Error::Store(
|
||||
"Repository key cutover found inconsistent current or retained key evidence".into(),
|
||||
));
|
||||
}
|
||||
for table in [
|
||||
"repository_secret_audit_events",
|
||||
"repository_secret_operations",
|
||||
"repository_ssh_credentials",
|
||||
"repository_ssh_credential_revisions",
|
||||
"repository_ssh_host_trusts",
|
||||
"repository_ssh_host_trust_revisions",
|
||||
"server_secret_versions",
|
||||
"workdir_create_operations",
|
||||
"workdir_create_credential_candidates",
|
||||
"workdir_create_credential_revision_retentions",
|
||||
] {
|
||||
tx.execute_batch(&format!("ALTER TABLE {table} RENAME TO {table}_v86;"))?;
|
||||
}
|
||||
tx.execute_batch("DROP INDEX idx_workdir_create_credential_candidates_revision;")?;
|
||||
tx.execute_batch(include_str!("frozen_repository_key_schema.sql"))?;
|
||||
tx.execute_batch("INSERT INTO repository_secret_operations SELECT workspace_id,operation_id,request_fingerprint,resource_kind,resource_id,operation_id,created_at FROM repository_secret_operations_v86;
|
||||
INSERT INTO repository_ssh_credentials SELECT c.workspace_id,c.credential_id,c.name,c.public_key_algorithm,c.public_key_fingerprint,m.operation_id,c.status,c.created_at,c.rotated_at FROM repository_ssh_credentials_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.current_revision;
|
||||
INSERT INTO repository_ssh_credential_keys SELECT c.workspace_id,c.credential_id,m.operation_id,c.public_key_algorithm,c.public_key_fingerprint,c.created_at FROM repository_ssh_credential_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.revision;
|
||||
INSERT INTO repository_ssh_host_trusts SELECT c.workspace_id,c.host_trust_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,m.operation_id,c.created_at,c.updated_at FROM repository_ssh_host_trusts_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_revision=c.current_revision;
|
||||
INSERT INTO repository_ssh_host_trust_keys SELECT c.workspace_id,c.host_trust_id,m.operation_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,c.created_at FROM repository_ssh_host_trust_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_revision=c.revision;")?;
|
||||
let secrets = {
|
||||
let mut statement = tx.prepare("SELECT s.workspace_id,s.secret_id,s.revision,s.purpose,s.encryption_algorithm,s.nonce,s.ciphertext,s.created_at,m.operation_id FROM server_secret_versions_v86 s LEFT JOIN repository_key_cutover m ON m.workspace_id=s.workspace_id AND m.kind='credential' AND m.resource_id=s.secret_id AND m.legacy_revision=s.revision")?;
|
||||
statement
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, u64>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, Vec<u8>>(5)?,
|
||||
r.get::<_, Vec<u8>>(6)?,
|
||||
r.get::<_, String>(7)?,
|
||||
r.get::<_, String>(8)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, id, revision, purpose, algorithm, nonce, ciphertext, created, operation) in
|
||||
secrets
|
||||
{
|
||||
if algorithm != "aes-256-gcm-v1" {
|
||||
return Err(Error::Store(
|
||||
"unsupported Repository secret envelope".into(),
|
||||
));
|
||||
}
|
||||
let database = secret_source.ok_or_else(|| {
|
||||
Error::Store("Repository secret migration requires its database master key".into())
|
||||
})?;
|
||||
let (nonce, ciphertext) =
|
||||
crate::repository_access::migrate_legacy_repository_secret_envelope(
|
||||
database,
|
||||
&workspace,
|
||||
&id,
|
||||
revision,
|
||||
&operation,
|
||||
&purpose,
|
||||
&nonce,
|
||||
&ciphertext,
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO server_secret_objects VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
workspace, id, operation, purpose, algorithm, nonce, ciphertext, created
|
||||
],
|
||||
)?;
|
||||
}
|
||||
let missing_secret: i64 = tx.query_row("SELECT count(*) FROM repository_ssh_credential_keys k WHERE NOT EXISTS (SELECT 1 FROM server_secret_objects s WHERE s.workspace_id=k.workspace_id AND s.secret_id=k.credential_id AND s.operation_id=k.operation_id AND s.purpose='private_key')", [], |r| r.get(0))?;
|
||||
if missing_secret != 0 {
|
||||
return Err(Error::Store(
|
||||
"Repository key migration found a missing private key envelope".into(),
|
||||
));
|
||||
}
|
||||
// Audit events also require an actual receipt, including deletions whose key
|
||||
// material no longer exists. Keep their timestamps and actor attribution.
|
||||
let audits = {
|
||||
let mut statement=tx.prepare("SELECT workspace_id,event_id,kind,resource_id,revision,actor_account_id,created_at FROM repository_secret_audit_events_v86")?;
|
||||
statement
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, i64>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
r.get::<_, String>(6)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, event, kind, resource, revision, actor, created) in audits {
|
||||
let resource_kind = if kind.starts_with("credential_") {
|
||||
"credential"
|
||||
} else if kind.starts_with("host_trust_") {
|
||||
"host_trust"
|
||||
} else {
|
||||
return Err(Error::Store("unknown Repository audit kind".into()));
|
||||
};
|
||||
let mut statement=tx.prepare("SELECT operation_id FROM repository_secret_operations_v86 WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
|
||||
let matches = statement
|
||||
.query_map(
|
||||
params![workspace, resource_kind, resource, revision, created],
|
||||
|r| r.get::<_, String>(0),
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
if matches.len() != 1 {
|
||||
return Err(Error::Store(
|
||||
"Repository audit cutover requires one unambiguous mutation receipt".into(),
|
||||
));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO repository_secret_audit_events VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
params![workspace, event, kind, resource, matches[0], actor, created],
|
||||
)?;
|
||||
}
|
||||
// Successful unretained snapshots are history, not future lease input.
|
||||
// Archive all their SSH evidence, including counters, without interpreting
|
||||
// a reused credential ID/ordinal as a newer key. Failed creates are retryable.
|
||||
tx.execute_batch("CREATE TEMP TABLE workdir_terminal_archive_cutover AS
|
||||
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
|
||||
WHERE o.state='succeeded'
|
||||
AND NOT EXISTS(SELECT 1 FROM workdir_create_credential_revision_retentions_v86 r WHERE r.workspace_id=o.workspace_id AND r.operation_id=o.operation_id)
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));
|
||||
INSERT INTO repository_secret_legacy_receipts
|
||||
SELECT e.workspace_id,e.operation_id,e.request_fingerprint,e.resource_kind,e.resource_id,e.result_revision,e.created_at,e.mutation_kind,
|
||||
CASE WHEN e.mutation_kind IN ('credential_created','host_trust_created') THEN 0
|
||||
WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1
|
||||
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END,
|
||||
m.operation_id,m.fingerprint
|
||||
FROM (SELECT o.*, (SELECT min(a.kind) FROM repository_secret_audit_events_v86 a
|
||||
WHERE a.workspace_id=o.workspace_id AND a.resource_id=o.resource_id AND a.revision=o.result_revision AND a.created_at=o.created_at
|
||||
AND ((o.resource_kind='credential' AND a.kind LIKE 'credential_%') OR (o.resource_kind='host_trust' AND a.kind LIKE 'host_trust_%'))
|
||||
HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
|
||||
LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id
|
||||
AND m.created_at<=e.created_at
|
||||
AND m.legacy_revision=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
|
||||
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
|
||||
INSERT INTO workdir_create_operations
|
||||
SELECT o.workspace_id,o.operation_id,'workdir-create-v86:' || COALESCE(CAST(o.source_revision AS TEXT),'unknown') || ':' || o.request_fingerprint,
|
||||
o.repository_id,o.selector,o.requested_runtime_id,o.resolved_runtime_id,o.config_projection_digest,o.working_directory_id,o.state,o.failure,o.created_at,o.updated_at,
|
||||
o.source_kind,o.source_uri,o.source_fingerprint,
|
||||
CASE WHEN a.operation_id IS NULL THEN o.credential_id END,CASE WHEN a.operation_id IS NULL THEN c.fingerprint END,
|
||||
CASE WHEN a.operation_id IS NULL THEN o.host_trust_id END,CASE WHEN a.operation_id IS NULL THEN h.fingerprint END,
|
||||
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
|
||||
FROM workdir_create_operations_v86 o
|
||||
LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_revision=o.credential_revision AND c.created_at<=o.updated_at
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_revision=o.host_trust_revision AND h.created_at<=o.updated_at;
|
||||
INSERT INTO workdir_create_credential_candidates
|
||||
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
|
||||
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m
|
||||
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.credential_revision AND m.created_at<=o.updated_at
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id);
|
||||
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.credential_revision;")?;
|
||||
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?;
|
||||
if missing != 0 {
|
||||
return Err(Error::Store(
|
||||
"Workdir SSH snapshot is missing key identity evidence".into(),
|
||||
));
|
||||
}
|
||||
let archived = {
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT workspace_id,operation_id FROM workdir_terminal_archive_cutover",
|
||||
)?;
|
||||
statement
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, operation) in archived {
|
||||
let old_operation=frozen_repository_json_rows(&tx,"SELECT * FROM workdir_create_operations_v86 WHERE workspace_id=?1 AND operation_id=?2",params![workspace,operation])?.pop().ok_or_else(|| Error::Store("archived Workdir operation disappeared".into()))?;
|
||||
let old_candidates = frozen_repository_json_rows(
|
||||
&tx,
|
||||
"SELECT * FROM workdir_create_credential_candidates_v86 WHERE workspace_id=?1 AND operation_id=?2 ORDER BY ordinal",
|
||||
params![workspace, operation],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO workdir_create_legacy_ssh_archives VALUES(?1,?2,?3,?4)",
|
||||
params![
|
||||
workspace,
|
||||
operation,
|
||||
serde_json::to_string(&old_operation)
|
||||
.map_err(|e| Error::Store(e.to_string()))?,
|
||||
serde_json::to_string(&old_candidates)
|
||||
.map_err(|e| Error::Store(e.to_string()))?
|
||||
],
|
||||
)?;
|
||||
}
|
||||
tx.execute_batch("DROP TABLE workdir_terminal_archive_cutover;")?;
|
||||
for table in [
|
||||
"workdir_create_credential_revision_retentions",
|
||||
"workdir_create_credential_candidates",
|
||||
"workdir_create_operations",
|
||||
"server_secret_versions",
|
||||
"repository_ssh_credential_revisions",
|
||||
"repository_ssh_host_trust_revisions",
|
||||
"repository_ssh_credentials",
|
||||
"repository_ssh_host_trusts",
|
||||
"repository_secret_operations",
|
||||
"repository_secret_audit_events",
|
||||
] {
|
||||
tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?;
|
||||
}
|
||||
tx.execute_batch("DROP TABLE repository_key_cutover;
|
||||
CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id);
|
||||
CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id,status,credential_id);
|
||||
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
|
||||
BEFORE INSERT ON workdir_create_operations FOR EACH ROW
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM runtime_removal_operations operation
|
||||
WHERE operation.runtime_id = NEW.resolved_runtime_id
|
||||
AND operation.state IN ('pending', 'cleanup_pending')
|
||||
)
|
||||
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
|
||||
CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal
|
||||
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM runtime_removal_operations operation
|
||||
WHERE operation.runtime_id = NEW.resolved_runtime_id
|
||||
AND operation.state IN ('pending', 'cleanup_pending')
|
||||
)
|
||||
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
|
||||
"#,
|
||||
)?;
|
||||
if tx.prepare("PRAGMA foreign_key_check")?.exists([])? {
|
||||
return Err(Error::Store(
|
||||
"Repository key migration foreign key check failed".into(),
|
||||
));
|
||||
}
|
||||
if table_exists(&tx, "repositories")? {
|
||||
tx.execute_batch("ALTER TABLE repositories DROP COLUMN source_revision;")?;
|
||||
}
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(87,'Repository SSH key identities and encrypted operation objects')",[])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
let restore = conn
|
||||
.execute_batch(&format!(
|
||||
"PRAGMA legacy_alter_table={}; PRAGMA foreign_keys={};",
|
||||
i32::from(legacy_alter),
|
||||
i32::from(foreign_keys)
|
||||
))
|
||||
.map_err(Error::from);
|
||||
result.and(restore)
|
||||
}
|
||||
|
||||
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
|
||||
// are never read by credential lookup, lease construction, or a live CAS.
|
||||
fn frozen_repository_json_rows(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
params: &[&dyn rusqlite::ToSql],
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let mut statement = conn.prepare(query)?;
|
||||
let columns = statement
|
||||
.column_names()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let mut rows = statement.query(params)?;
|
||||
let mut result = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let mut object = serde_json::Map::new();
|
||||
for (index, name) in columns.iter().enumerate() {
|
||||
let value = match row.get_ref(index)? {
|
||||
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
|
||||
rusqlite::types::ValueRef::Integer(value) => value.into(),
|
||||
rusqlite::types::ValueRef::Text(value) => serde_json::Value::String(
|
||||
std::str::from_utf8(value)
|
||||
.map_err(|e| Error::Store(e.to_string()))?
|
||||
.to_string(),
|
||||
),
|
||||
_ => {
|
||||
return Err(Error::Store(
|
||||
"unexpected value in frozen Repository archive".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
object.insert(name.clone(), value);
|
||||
}
|
||||
result.push(object.into());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
CREATE TABLE repository_secret_audit_events (
|
||||
workspace_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, event_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_secret_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')),
|
||||
resource_id TEXT NOT NULL,
|
||||
result_operation_id TEXT NOT NULL CHECK (length(result_operation_id) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_credential_keys (
|
||||
workspace_id TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
|
||||
public_key_algorithm TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, credential_id, operation_id),
|
||||
UNIQUE (workspace_id, credential_id, operation_id, public_key_fingerprint),
|
||||
FOREIGN KEY (workspace_id, credential_id)
|
||||
REFERENCES repository_ssh_credentials(workspace_id, credential_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_credentials (
|
||||
workspace_id TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
public_key_algorithm TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
|
||||
created_at TEXT NOT NULL,
|
||||
rotated_at TEXT,
|
||||
PRIMARY KEY (workspace_id, credential_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_host_trust_keys (
|
||||
workspace_id TEXT NOT NULL,
|
||||
host_trust_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
key_algorithm TEXT NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, host_trust_id, operation_id),
|
||||
FOREIGN KEY (workspace_id, host_trust_id)
|
||||
REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_host_trusts (
|
||||
workspace_id TEXT NOT NULL,
|
||||
host_trust_id TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
key_algorithm TEXT NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, host_trust_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE server_secret_objects (
|
||||
workspace_id TEXT NOT NULL,
|
||||
secret_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')),
|
||||
encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'),
|
||||
nonce BLOB NOT NULL CHECK (length(nonce) = 12),
|
||||
ciphertext BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, secret_id, operation_id, purpose),
|
||||
FOREIGN KEY (workspace_id, secret_id, operation_id)
|
||||
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workdir_create_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
repository_id TEXT NOT NULL,
|
||||
selector TEXT,
|
||||
requested_runtime_id TEXT,
|
||||
resolved_runtime_id TEXT NOT NULL,
|
||||
config_projection_digest TEXT NOT NULL,
|
||||
working_directory_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
|
||||
failure TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_fingerprint TEXT, credential_id TEXT, credential_fingerprint TEXT, host_trust_id TEXT, host_trust_fingerprint TEXT, repository_access_mode TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
UNIQUE (workspace_id, working_directory_id)
|
||||
);
|
||||
CREATE TABLE workdir_create_credential_candidates (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
|
||||
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
|
||||
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
|
||||
credential_fingerprint TEXT NOT NULL CHECK (length(credential_fingerprint) > 0),
|
||||
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||
UNIQUE (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint),
|
||||
UNIQUE (workspace_id, operation_id, role),
|
||||
UNIQUE (workspace_id, operation_id, credential_id),
|
||||
FOREIGN KEY (workspace_id, operation_id)
|
||||
REFERENCES workdir_create_operations(workspace_id, operation_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_workdir_create_credential_candidates_fingerprint
|
||||
ON workdir_create_credential_candidates(
|
||||
workspace_id, credential_id, credential_fingerprint
|
||||
);
|
||||
CREATE TABLE workdir_create_credential_retentions (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
credential_fingerprint TEXT NOT NULL,
|
||||
credential_operation_id TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint)
|
||||
REFERENCES workdir_create_credential_candidates(
|
||||
workspace_id, operation_id, ordinal, credential_id, credential_fingerprint
|
||||
)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, credential_id, credential_operation_id, credential_fingerprint)
|
||||
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id, public_key_fingerprint)
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
-- Frozen evidence only. Neither table is a key, lease, or mutation authority.
|
||||
CREATE TABLE workdir_create_legacy_ssh_archives (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
operation_json TEXT NOT NULL CHECK (json_valid(operation_json)),
|
||||
candidates_json TEXT NOT NULL CHECK (json_valid(candidates_json)),
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id, operation_id)
|
||||
REFERENCES workdir_create_operations(workspace_id, operation_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER workdir_create_legacy_archive_cannot_authorize_update
|
||||
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
|
||||
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
|
||||
WHERE a.workspace_id=OLD.workspace_id AND a.operation_id=OLD.operation_id)
|
||||
AND (NEW.state <> 'succeeded' OR NEW.credential_id IS NOT NULL
|
||||
OR NEW.credential_fingerprint IS NOT NULL OR NEW.host_trust_id IS NOT NULL
|
||||
OR NEW.host_trust_fingerprint IS NOT NULL OR NEW.repository_access_mode IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
|
||||
CREATE TRIGGER workdir_create_legacy_archive_blocks_candidates
|
||||
BEFORE INSERT ON workdir_create_credential_candidates FOR EACH ROW
|
||||
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
|
||||
CREATE TRIGGER workdir_create_legacy_archive_blocks_candidate_updates
|
||||
BEFORE UPDATE ON workdir_create_credential_candidates FOR EACH ROW
|
||||
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
|
||||
CREATE TABLE repository_secret_legacy_receipts (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
result_revision INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
mutation_kind TEXT,
|
||||
expected_revision INTEGER,
|
||||
expected_operation_id TEXT,
|
||||
expected_key_fingerprint TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id, operation_id)
|
||||
REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,764 @@
|
||||
// Frozen schema-87 -> 88 authority cutover. Old counters are never new IDs.
|
||||
// Existing private material is unchanged; v1 material has its own frozen decoder.
|
||||
fn migrate_runtime_bindings_v87_to_v88(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 87 {
|
||||
return Err(Error::Store(
|
||||
"Runtime binding cutover requires schema 87".into(),
|
||||
));
|
||||
}
|
||||
let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
|
||||
let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?;
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
|
||||
let result = (|| -> Result<()> {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
// An executing/failed removal may already have a durable Runtime receipt,
|
||||
// with the source gone but Backend metadata not yet committed. Its legacy
|
||||
// request fingerprint includes ordinal policy/Worker evidence that cannot
|
||||
// be reconstructed as a new content contract. Never retire that recovery
|
||||
// authority, regenerate the request, or treat failure as proof of no effect.
|
||||
let unresolved_removal: Option<(String, String)> = tx
|
||||
.query_row(
|
||||
"SELECT operation_id,state FROM worker_removal_operations \
|
||||
WHERE state IN ('executing','failed') ORDER BY operation_id LIMIT 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((operation_id, state)) = unresolved_removal {
|
||||
return Err(Error::Store(format!(
|
||||
"Runtime binding cutover requires old-version reconciliation of Worker removal `{operation_id}` ({state}); reconcile using schema 87 before retrying the upgrade"
|
||||
)));
|
||||
}
|
||||
// Triggers may refer to a table while it is being replaced. Preserve all of them,
|
||||
// including cross-domain removal fences, and restore before checking constraints.
|
||||
let triggers = {
|
||||
let mut stmt = tx.prepare("SELECT name,sql FROM sqlite_schema WHERE type='trigger'")?;
|
||||
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
let indexes = {
|
||||
let mut stmt = tx.prepare("SELECT name,tbl_name,sql FROM sqlite_schema WHERE type='index' AND sql IS NOT NULL")?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (name, _) in &triggers {
|
||||
tx.execute_batch(&format!("DROP TRIGGER \"{}\"", name.replace('"', "\"\"")))?;
|
||||
}
|
||||
for table in [
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
"workspace_signing_identity_audit",
|
||||
] {
|
||||
let unsupported: bool = tx.query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE revision != 1)"),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if unsupported {
|
||||
return Err(Error::Store(
|
||||
"unsupported legacy Workspace signing identity revision".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
tx.execute_batch("CREATE TEMP TABLE runtime_binding_cutover_ids(workspace_id TEXT,runtime_id TEXT,ordinal INTEGER,binding_id TEXT,PRIMARY KEY(workspace_id,runtime_id,ordinal));
|
||||
CREATE TEMP TABLE retention_cutover_digests(workspace_id TEXT,policy_id TEXT,ordinal INTEGER,policy_digest TEXT,PRIMARY KEY(workspace_id,policy_id,ordinal));")?;
|
||||
let identities = {
|
||||
let mut stmt=tx.prepare("SELECT workspace_id,runtime_id,binding_revision FROM workspace_runtime_bindings UNION SELECT workspace_id,runtime_id,binding_revision FROM workspace_runtime_binding_audit")?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, runtime, ordinal) in identities {
|
||||
if ordinal <= 0 {
|
||||
return Err(Error::Store("invalid legacy binding ordinal".into()));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO runtime_binding_cutover_ids VALUES(?1,?2,?3,?4)",
|
||||
params![workspace, runtime, ordinal, new_runtime_binding_id()],
|
||||
)?;
|
||||
}
|
||||
let policies = {
|
||||
let mut stmt=tx.prepare("SELECT workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds FROM workspace_worker_retention_policy_revisions")?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
r.get::<_, Option<u64>>(6)?,
|
||||
r.get::<_, String>(7)?,
|
||||
r.get::<_, Option<u64>>(8)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (
|
||||
workspace,
|
||||
id,
|
||||
ordinal,
|
||||
session,
|
||||
metadata,
|
||||
archive,
|
||||
seconds,
|
||||
diagnostics,
|
||||
diagnostics_seconds,
|
||||
) in policies
|
||||
{
|
||||
let archive = match (archive.as_str(), seconds) {
|
||||
("forever", None) => serde_json::json!({"kind":"forever"}),
|
||||
("for_seconds", Some(seconds)) if seconds > 0 => {
|
||||
serde_json::json!({"kind":"for_seconds","seconds":seconds})
|
||||
}
|
||||
_ => return Err(Error::Store("invalid legacy retention duration".into())),
|
||||
};
|
||||
let policy: crate::retention::WorkerRetentionPolicyUpdate=serde_json::from_value(serde_json::json!({
|
||||
"policy_id":id,"session_disposition":session,"metadata_disposition":metadata,
|
||||
"archive_retention":archive,"diagnostics_disposition":diagnostics,"diagnostics_retention_seconds":diagnostics_seconds
|
||||
})).map_err(|e|Error::Store(format!("invalid legacy retention policy: {e}")))?;
|
||||
let digest = crate::retention::worker_retention_policy_digest(&policy);
|
||||
tx.execute(
|
||||
"INSERT INTO retention_cutover_digests VALUES(?1,?2,?3,?4)",
|
||||
params![workspace, policy.policy_id, ordinal, digest],
|
||||
)?;
|
||||
}
|
||||
tx.execute_batch(r#"CREATE TABLE workspace_runtime_bindings_v88 (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
|
||||
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
|
||||
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
|
||||
workspace_key_id TEXT,
|
||||
workspace_public_key_fingerprint TEXT,
|
||||
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, runtime_id),
|
||||
UNIQUE (workspace_id, public_key_fingerprint),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
||||
CHECK (
|
||||
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
|
||||
OR
|
||||
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
|
||||
),
|
||||
CHECK (
|
||||
(state = 'revoked' AND revoked_at IS NOT NULL)
|
||||
OR
|
||||
(state != 'revoked' AND revoked_at IS NULL)
|
||||
)
|
||||
);"#)?;
|
||||
tx.execute_batch(r#"CREATE TABLE workspace_runtime_verifications_v88 (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
|
||||
workspace_key_id TEXT NOT NULL,
|
||||
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
|
||||
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
|
||||
runtime_public_key_fingerprint TEXT NOT NULL,
|
||||
challenge_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
|
||||
last_outcome TEXT NOT NULL,
|
||||
verified_at TEXT,
|
||||
checked_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, runtime_id),
|
||||
FOREIGN KEY(workspace_id, runtime_id)
|
||||
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
|
||||
CHECK((state = 'verified' AND verified_at IS NOT NULL)
|
||||
OR (state != 'verified' AND verified_at IS NULL))
|
||||
);"#)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE TABLE workspace_runtime_binding_audit_v88 (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
|
||||
old_fingerprint TEXT,
|
||||
new_fingerprint TEXT,
|
||||
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, binding_id),
|
||||
FOREIGN KEY(workspace_id, runtime_id)
|
||||
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
|
||||
);"#,
|
||||
)?;
|
||||
tx.execute_batch(r#"CREATE TABLE workspace_signing_identities_v88 (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
|
||||
public_key TEXT,
|
||||
public_key_fingerprint TEXT,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
|
||||
created_at TEXT NOT NULL,
|
||||
provisioned_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
|
||||
OR
|
||||
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
|
||||
),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);"#)?;
|
||||
tx.execute_batch(r#"CREATE TABLE workspace_signing_identity_provisioning_operations_v88 (
|
||||
operation_key TEXT PRIMARY KEY,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
|
||||
workspace_id TEXT NOT NULL UNIQUE,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);"#)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE TABLE workspace_signing_identity_audit_v88 (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('provisioned')),
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);"#,
|
||||
)?;
|
||||
tx.execute_batch(r#"CREATE TABLE runtime_removal_operations_v88 (
|
||||
operation_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
expected_binding_id TEXT NOT NULL CHECK(length(expected_binding_id)>0),
|
||||
config_digest TEXT NOT NULL CHECK(length(config_digest)>0),
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')),
|
||||
failure_category TEXT,
|
||||
binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)),
|
||||
runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);"#)?;
|
||||
tx.execute_batch(r#"CREATE TABLE workspace_worker_retention_policies_v88 (
|
||||
workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(workspace_id,policy_id,policy_digest) REFERENCES workspace_worker_retention_policy_snapshots(workspace_id,policy_id,policy_digest),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?;
|
||||
tx.execute_batch(r#"CREATE TABLE workspace_worker_retention_policy_snapshots_v88 (
|
||||
workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL CHECK(length(policy_digest)>0),
|
||||
session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')),
|
||||
metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')),
|
||||
archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')),
|
||||
archive_retention_seconds INTEGER,
|
||||
diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')),
|
||||
diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,policy_id,policy_digest),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?;
|
||||
tx.execute_batch(r#"CREATE TABLE worker_removal_operations_v88 (
|
||||
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
worker_updated_at TEXT NOT NULL,
|
||||
policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL,
|
||||
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
|
||||
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
|
||||
diagnostics_disposition TEXT NOT NULL,
|
||||
diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')),
|
||||
reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?;
|
||||
tx.execute_batch(r#"CREATE TABLE worker_session_archives_v88 (
|
||||
archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL,
|
||||
policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE,
|
||||
committed_at TEXT NOT NULL, expires_at TEXT,
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));"#)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE TABLE worker_diagnostics_archives_v88 (
|
||||
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL,
|
||||
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#,
|
||||
)?;
|
||||
tx.execute_batch(r#"CREATE TABLE worker_tombstones_v88 (
|
||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
|
||||
archive_id TEXT, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE,
|
||||
PRIMARY KEY(workspace_id,runtime_id,worker_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id),
|
||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));"#)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE TABLE workdir_removal_operations_v88 (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
repository_id TEXT NOT NULL,
|
||||
materialization_fingerprint TEXT NOT NULL,
|
||||
source_actor TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')),
|
||||
attempt_id TEXT,
|
||||
retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)),
|
||||
disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')),
|
||||
failure_category TEXT,
|
||||
attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0),
|
||||
attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);"#,
|
||||
)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE TABLE workspace_deletion_operations_v88 (
|
||||
operation_id TEXT PRIMARY KEY,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
workspace_display_name TEXT NOT NULL,
|
||||
workspace_updated_at TEXT NOT NULL,
|
||||
owner_account_id TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')),
|
||||
resource_counts_json TEXT NOT NULL,
|
||||
child_operation_ids_json TEXT NOT NULL,
|
||||
blockers_json TEXT NOT NULL,
|
||||
failure_category TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY(owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
|
||||
);"#,
|
||||
)?;
|
||||
{
|
||||
let columns = table_columns(&tx, "workspace_runtime_bindings_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"binding_id" => "(SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.binding_revision)".to_string(),
|
||||
"workspace_trust_id" => "NULL".to_string(),
|
||||
"workspace_public_key_fingerprint" => "(SELECT public_key_fingerprint FROM workspace_signing_identities i WHERE i.workspace_id=o.workspace_id AND i.key_id=o.workspace_key_id)".to_string(),
|
||||
"state" => "CASE WHEN state='verified' AND authentication_mode='workspace_identity' THEN 'configured' ELSE state END".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workspace_runtime_bindings_v88 ({}) SELECT {} FROM workspace_runtime_bindings o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
// Legacy proof cannot attest to a new binding/trust identity.
|
||||
{
|
||||
let columns = table_columns(&tx, "workspace_runtime_binding_audit_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"binding_id" => "(SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.binding_revision)".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workspace_runtime_binding_audit_v88 ({}) SELECT {} FROM workspace_runtime_binding_audit o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "workspace_signing_identities_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression = match column.as_str() {
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workspace_signing_identities_v88 ({}) SELECT {} FROM workspace_signing_identities o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(
|
||||
&tx,
|
||||
"workspace_signing_identity_provisioning_operations_v88",
|
||||
)?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression = match column.as_str() {
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workspace_signing_identity_provisioning_operations_v88 ({}) SELECT {} FROM workspace_signing_identity_provisioning_operations o",columns.join(","),expressions.join(",")))?;
|
||||
migrate_existing_workspace_provisioning_receipts_v87(&tx)?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "workspace_signing_identity_audit_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression = match column.as_str() {
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workspace_signing_identity_audit_v88 ({}) SELECT {} FROM workspace_signing_identity_audit o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "runtime_removal_operations_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"expected_binding_id" => "COALESCE((SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.expected_binding_revision),'RB-retired-' || lower(hex(randomblob(16))))".to_string(),
|
||||
"config_digest" => "(SELECT content_digest FROM workspace_config_trees t WHERE t.workspace_id=o.workspace_id)".to_string(),
|
||||
"state" => "CASE WHEN state IN ('pending','failed') THEN 'failed' ELSE state END".to_string(),
|
||||
"failure_category" => "CASE WHEN state IN ('pending','failed') THEN 'binding_identity_cutover' ELSE failure_category END".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO runtime_removal_operations_v88 ({}) SELECT {} FROM runtime_removal_operations o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "workspace_worker_retention_policies_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.revision)".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workspace_worker_retention_policies_v88 ({}) SELECT {} FROM workspace_worker_retention_policies o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "workspace_worker_retention_policy_snapshots_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.revision)".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workspace_worker_retention_policy_snapshots_v88 ({}) SELECT {} FROM workspace_worker_retention_policy_revisions o WHERE 1 ON CONFLICT(workspace_id,policy_id,policy_digest) DO NOTHING",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "worker_removal_operations_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(),
|
||||
"worker_updated_at" => "worker_revision".to_string(),
|
||||
// Only unexecuted plans lose admission authority. Executing/failed
|
||||
// rows were rejected above; succeeded and already-stale evidence stays intact.
|
||||
"state" => "CASE WHEN state IN ('planned','blocked') THEN 'stale' ELSE state END".to_string(),
|
||||
"failure_category" => "CASE WHEN state IN ('planned','blocked') THEN 'policy_content_cutover' ELSE failure_category END".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO worker_removal_operations_v88 ({}) SELECT {} FROM worker_removal_operations o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "worker_session_archives_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO worker_session_archives_v88 ({}) SELECT {} FROM worker_session_archives o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "worker_diagnostics_archives_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO worker_diagnostics_archives_v88 ({}) SELECT {} FROM worker_diagnostics_archives o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "worker_tombstones_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!(
|
||||
"INSERT INTO worker_tombstones_v88 ({}) SELECT {} FROM worker_tombstones o",
|
||||
columns.join(","),
|
||||
expressions.join(",")
|
||||
))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "workdir_removal_operations_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression=match column.as_str() {
|
||||
// This is a Backend-local callback fence, not Runtime receipt
|
||||
// identity. Recovery still proves the prior owner is orphaned,
|
||||
// observes the exact materialization (or authoritative absence),
|
||||
// and uses the unchanged request/materialization fingerprints.
|
||||
"attempt_id" => "CASE WHEN attempt_count>0 THEN 'WDRA-' || lower(hex(randomblob(16))) ELSE NULL END".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workdir_removal_operations_v88 ({}) SELECT {} FROM workdir_removal_operations o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
{
|
||||
let columns = table_columns(&tx, "workspace_deletion_operations_v88")?;
|
||||
let mut expressions = Vec::new();
|
||||
for column in &columns {
|
||||
let expression = match column.as_str() {
|
||||
"workspace_updated_at" => "workspace_revision".to_string(),
|
||||
_ => format!("o.{column}"),
|
||||
};
|
||||
expressions.push(expression);
|
||||
}
|
||||
tx.execute_batch(&format!("INSERT INTO workspace_deletion_operations_v88 ({}) SELECT {} FROM workspace_deletion_operations o",columns.join(","),expressions.join(",")))?;
|
||||
}
|
||||
tx.execute_batch("DROP TABLE workspace_runtime_bindings; ALTER TABLE workspace_runtime_bindings_v88 RENAME TO workspace_runtime_bindings;")?;
|
||||
tx.execute_batch("DROP TABLE workspace_runtime_verifications; ALTER TABLE workspace_runtime_verifications_v88 RENAME TO workspace_runtime_verifications;")?;
|
||||
tx.execute_batch("DROP TABLE workspace_runtime_binding_audit; ALTER TABLE workspace_runtime_binding_audit_v88 RENAME TO workspace_runtime_binding_audit;")?;
|
||||
tx.execute_batch("DROP TABLE workspace_signing_identities; ALTER TABLE workspace_signing_identities_v88 RENAME TO workspace_signing_identities;")?;
|
||||
tx.execute_batch("DROP TABLE workspace_signing_identity_provisioning_operations; ALTER TABLE workspace_signing_identity_provisioning_operations_v88 RENAME TO workspace_signing_identity_provisioning_operations;")?;
|
||||
tx.execute_batch("DROP TABLE workspace_signing_identity_audit; ALTER TABLE workspace_signing_identity_audit_v88 RENAME TO workspace_signing_identity_audit;")?;
|
||||
tx.execute_batch("DROP TABLE runtime_removal_operations; ALTER TABLE runtime_removal_operations_v88 RENAME TO runtime_removal_operations;")?;
|
||||
tx.execute_batch("DROP TABLE workspace_worker_retention_policies; ALTER TABLE workspace_worker_retention_policies_v88 RENAME TO workspace_worker_retention_policies;")?;
|
||||
tx.execute_batch("DROP TABLE workspace_worker_retention_policy_revisions; ALTER TABLE workspace_worker_retention_policy_snapshots_v88 RENAME TO workspace_worker_retention_policy_snapshots;")?;
|
||||
tx.execute_batch("DROP TABLE worker_removal_operations; ALTER TABLE worker_removal_operations_v88 RENAME TO worker_removal_operations;")?;
|
||||
tx.execute_batch("DROP TABLE worker_session_archives; ALTER TABLE worker_session_archives_v88 RENAME TO worker_session_archives;")?;
|
||||
tx.execute_batch("DROP TABLE worker_diagnostics_archives; ALTER TABLE worker_diagnostics_archives_v88 RENAME TO worker_diagnostics_archives;")?;
|
||||
tx.execute_batch("DROP TABLE worker_tombstones; ALTER TABLE worker_tombstones_v88 RENAME TO worker_tombstones;")?;
|
||||
tx.execute_batch("DROP TABLE workdir_removal_operations; ALTER TABLE workdir_removal_operations_v88 RENAME TO workdir_removal_operations;")?;
|
||||
tx.execute_batch("DROP TABLE workspace_deletion_operations; ALTER TABLE workspace_deletion_operations_v88 RENAME TO workspace_deletion_operations;")?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE INDEX workspace_runtime_verifications_state_idx
|
||||
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);"#,
|
||||
)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE INDEX idx_workspace_runtime_binding_audit_recent
|
||||
ON workspace_runtime_binding_audit(workspace_id, runtime_id, at DESC, binding_id DESC);"#,
|
||||
)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE INDEX idx_workspace_runtime_bindings_workspace
|
||||
ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id);"#,
|
||||
)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE INDEX workspace_deletion_operations_workspace_recent
|
||||
ON workspace_deletion_operations(workspace_id, created_at DESC);"#,
|
||||
)?;
|
||||
tx.execute_batch(
|
||||
r#"CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending
|
||||
ON workdir_removal_operations(workspace_id, workdir_id)
|
||||
WHERE state = 'pending';"#,
|
||||
)?;
|
||||
// Config content can be evaluated under multiple toolchains/schema bundles.
|
||||
// Preserve their distinct projection evidence rather than overwriting same-content rows.
|
||||
let has_history: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='workspace_config_tree_history')", [], |r|r.get(0))?;
|
||||
if has_history {
|
||||
tx.execute_batch(r#"CREATE TABLE workspace_config_tree_history_v88 (
|
||||
workspace_id TEXT NOT NULL, content_digest TEXT NOT NULL,
|
||||
toolchain_fingerprint TEXT NOT NULL, projection_digest TEXT NOT NULL,
|
||||
manifest_json TEXT NOT NULL, created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}',
|
||||
PRIMARY KEY(workspace_id,content_digest,toolchain_fingerprint,projection_digest),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
|
||||
INSERT INTO workspace_config_tree_history_v88 SELECT workspace_id,content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json FROM workspace_config_tree_history;
|
||||
DROP TABLE workspace_config_tree_history;
|
||||
ALTER TABLE workspace_config_tree_history_v88 RENAME TO workspace_config_tree_history;"#)?;
|
||||
}
|
||||
for (name, table, sql) in indexes {
|
||||
let exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='index' AND name=?1)",
|
||||
[&name],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
let sql = if table == "workspace_runtime_binding_audit" {
|
||||
sql.replace("binding_revision DESC", "at DESC, binding_id DESC")
|
||||
.replace("binding_revision", "binding_id")
|
||||
} else {
|
||||
sql
|
||||
};
|
||||
tx.execute_batch(&sql)?;
|
||||
}
|
||||
}
|
||||
for (name, sql) in triggers {
|
||||
if name == "seed_worker_retention_policy_after_workspace_insert" {
|
||||
continue;
|
||||
}
|
||||
tx.execute_batch(&sql)?;
|
||||
}
|
||||
tx.execute_batch(r#"CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN
|
||||
INSERT INTO workspace_worker_retention_policy_snapshots
|
||||
(workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
|
||||
VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198','archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at);
|
||||
INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,policy_digest,updated_at)
|
||||
VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198',NEW.created_at);
|
||||
END;"#)?;
|
||||
tx.execute_batch(
|
||||
"DROP TABLE runtime_binding_cutover_ids; DROP TABLE retention_cutover_digests;",
|
||||
)?;
|
||||
let violation: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_foreign_key_check)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if violation {
|
||||
return Err(Error::Store(
|
||||
"Runtime binding cutover foreign-key violation".into(),
|
||||
));
|
||||
}
|
||||
verify_runtime_binding_content_schema(&tx)?;
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(88,'Runtime binding identities and retention content contracts')",[])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
let restore = conn
|
||||
.execute_batch(&format!(
|
||||
"PRAGMA legacy_alter_table={}; PRAGMA foreign_keys={};",
|
||||
i32::from(legacy_alter),
|
||||
i32::from(foreign_keys)
|
||||
))
|
||||
.map_err(Error::from);
|
||||
result.and(restore)
|
||||
}
|
||||
|
||||
// Only the reservation lookup contract changes. Keep the same reserved key,
|
||||
// material reference, actor, state and timestamps, including completed receipts.
|
||||
// workspace_create receipts have an independent request contract and stay opaque.
|
||||
fn migrate_existing_workspace_provisioning_receipts_v87(conn: &Connection) -> Result<()> {
|
||||
let receipts = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,completed_at \
|
||||
FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='existing_workspace'",
|
||||
)?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
r.get::<_, Option<String>>(6)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (old_operation_key, old_fingerprint, workspace, key, material_ref, state, completed_at) in
|
||||
receipts
|
||||
{
|
||||
let identity: Option<(String, String, String)> = conn
|
||||
.query_row(
|
||||
"SELECT key_id,private_material_ref,state FROM workspace_signing_identities WHERE workspace_id=?1",
|
||||
[&workspace],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let expected_state = if state == "pending" {
|
||||
"pending_provisioning"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
if old_operation_key != format!("existing-workspace:{workspace}:revision-1")
|
||||
|| old_fingerprint
|
||||
!= frozen_existing_workspace_provisioning_fingerprint_v1(&workspace, &key)
|
||||
|| identity != Some((key.clone(), material_ref.clone(), expected_state.into()))
|
||||
|| (state == "completed") != completed_at.is_some()
|
||||
{
|
||||
return Err(Error::Store(format!(
|
||||
"inconsistent legacy Workspace signing identity provisioning receipt `{old_operation_key}`"
|
||||
)));
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE workspace_signing_identity_provisioning_operations_v88 SET operation_key=?2,request_fingerprint=?3 WHERE operation_key=?1",
|
||||
params![
|
||||
old_operation_key,
|
||||
format!("existing-workspace:{workspace}:key-{key}"),
|
||||
crate::workspace_signing_identity::provisioning_fingerprint(&workspace, &key, &material_ref),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frozen_existing_workspace_provisioning_fingerprint_v1(workspace: &str, key: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(workspace.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(key.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(1_u64.to_be_bytes());
|
||||
let digest: String = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
format!("sha256:{digest}")
|
||||
}
|
||||
|
||||
fn verify_runtime_binding_content_schema(conn: &Connection) -> Result<()> {
|
||||
for (table, required, forbidden) in [
|
||||
(
|
||||
"workspace_runtime_bindings",
|
||||
"binding_id",
|
||||
"binding_revision",
|
||||
),
|
||||
(
|
||||
"workspace_runtime_verifications",
|
||||
"workspace_public_key_fingerprint",
|
||||
"runtime_identity_revision",
|
||||
),
|
||||
("workspace_signing_identities", "key_id", "revision"),
|
||||
(
|
||||
"runtime_removal_operations",
|
||||
"config_digest",
|
||||
"config_revision",
|
||||
),
|
||||
(
|
||||
"workspace_worker_retention_policy_snapshots",
|
||||
"policy_digest",
|
||||
"revision",
|
||||
),
|
||||
("workdir_removal_operations", "attempt_id", "attempt_count"),
|
||||
(
|
||||
"workspace_deletion_operations",
|
||||
"workspace_updated_at",
|
||||
"workspace_revision",
|
||||
),
|
||||
] {
|
||||
let columns = table_columns(conn, table)?;
|
||||
if !columns.iter().any(|v| v == required) || columns.iter().any(|v| v == forbidden) {
|
||||
return Err(Error::Store(format!(
|
||||
"{table} does not match content/identity authority schema"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
use super::*;
|
||||
|
||||
// Frozen schema-87 tables: use only the legacy authorities at this cutover.
|
||||
fn legacy_binding_database() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL);
|
||||
INSERT INTO __yoi_schema_migrations VALUES(87,'workspace schema baseline');
|
||||
CREATE TABLE accounts(account_id TEXT PRIMARY KEY);
|
||||
INSERT INTO accounts VALUES('owner');
|
||||
CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);
|
||||
INSERT INTO workspaces VALUES('space');
|
||||
CREATE TABLE workspace_config_trees(workspace_id TEXT PRIMARY KEY,content_digest TEXT NOT NULL);
|
||||
INSERT INTO workspace_config_trees VALUES('space','sha256:config-content');
|
||||
CREATE TABLE workspace_config_tree_history(workspace_id TEXT,content_digest TEXT,toolchain_fingerprint TEXT,projection_digest TEXT,manifest_json TEXT,created_at TEXT,schema_bundle_json TEXT,PRIMARY KEY(workspace_id,content_digest));
|
||||
INSERT INTO workspace_config_tree_history VALUES('space','sha256:config-content','toolchain-original','projection-original','{}','created','{}');").unwrap();
|
||||
let frozen = include_str!("frozen_schema_v85.sql");
|
||||
for table in [
|
||||
"workspace_runtime_bindings",
|
||||
"workspace_runtime_verifications",
|
||||
"workspace_runtime_binding_audit",
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
"workspace_signing_identity_audit",
|
||||
"runtime_removal_operations",
|
||||
"workspace_worker_retention_policies",
|
||||
"workspace_worker_retention_policy_revisions",
|
||||
"worker_removal_operations",
|
||||
"worker_session_archives",
|
||||
"worker_diagnostics_archives",
|
||||
"worker_tombstones",
|
||||
"workdir_removal_operations",
|
||||
"workspace_deletion_operations",
|
||||
] {
|
||||
let marker = format!("CREATE TABLE {table} (");
|
||||
let start = frozen.find(&marker).unwrap();
|
||||
let end = start + frozen[start..].find(");").unwrap() + 2;
|
||||
conn.execute_batch(&frozen[start..end]).unwrap();
|
||||
}
|
||||
for index in [
|
||||
"runtime_removal_operations_one_active_runtime",
|
||||
"runtime_removal_operations_workspace_state",
|
||||
"idx_workdir_removal_operations_recovery",
|
||||
"idx_workdir_removal_operations_workdir",
|
||||
"workspace_signing_identity_audit_workspace_idx",
|
||||
] {
|
||||
let start = frozen
|
||||
.find(&format!("CREATE INDEX {index}"))
|
||||
.or_else(|| frozen.find(&format!("CREATE UNIQUE INDEX {index}")))
|
||||
.unwrap();
|
||||
let end = start + frozen[start..].find(';').unwrap() + 1;
|
||||
conn.execute_batch(&frozen[start..end]).unwrap();
|
||||
}
|
||||
conn.execute_batch("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,revision,state,created_at,provisioned_at,updated_at)
|
||||
VALUES('space','workspace-key','ed25519','public','sha256:workspace','material',1,'active','created','provisioned','updated');
|
||||
INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_revision,state,authentication_mode,workspace_key_id,workspace_key_generation,created_at,updated_at)
|
||||
VALUES('space','remote','Remote','https://remote.test','public','sha256:runtime',8,'verified','workspace_identity','workspace-key',6,'created','updated');
|
||||
INSERT INTO workspace_runtime_binding_audit VALUES('space','remote','owner','created',NULL,'sha256:runtime',8,'created');
|
||||
INSERT INTO workspace_runtime_verifications VALUES('space','remote',8,'workspace-key',1,6,'sha256:runtime',2,'challenge','verified','verified','verified-at','checked-at');
|
||||
INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',1,'archive','tombstone','forever',NULL,'purge',NULL,'first');
|
||||
INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',9,'archive','tombstone','forever',NULL,'purge',NULL,'ninth');
|
||||
INSERT INTO workspace_worker_retention_policies VALUES('space','policy',9,'updated');
|
||||
INSERT INTO runtime_removal_operations VALUES('removal','space','remote','request',8,4,'pending',NULL,0,NULL,'created','updated',NULL);
|
||||
CREATE TRIGGER runtime_binding_cutover_guard BEFORE UPDATE ON workspace_runtime_bindings
|
||||
WHEN EXISTS(SELECT 1 FROM runtime_removal_operations WHERE runtime_id=NEW.runtime_id AND state IN ('pending','cleanup_pending'))
|
||||
BEGIN SELECT RAISE(ABORT,'runtime_removal_in_progress'); END;").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_cutover_preserves_key_facts_and_indices_but_requires_runtime_issued_trust() {
|
||||
let conn = legacy_binding_database();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
let (id,state,trust,key,fingerprint): (String,String,Option<String>,String,String) = conn.query_row(
|
||||
"SELECT binding_id,state,workspace_trust_id,workspace_key_id,workspace_public_key_fingerprint FROM workspace_runtime_bindings", [], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).unwrap();
|
||||
assert!(id.starts_with("RB-"));
|
||||
assert_eq!(state, "configured");
|
||||
assert_eq!(trust, None);
|
||||
assert_eq!(key, "workspace-key");
|
||||
assert_eq!(fingerprint, "sha256:workspace");
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_verifications",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let (expected, state, digest): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT expected_binding_id,state,config_digest FROM runtime_removal_operations",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(expected, id);
|
||||
assert_eq!(state, "failed");
|
||||
assert_eq!(digest, "sha256:config-content");
|
||||
let audit: String = conn
|
||||
.query_row(
|
||||
"SELECT binding_id FROM workspace_runtime_binding_audit",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(audit, id);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_worker_retention_policy_snapshots",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let update = crate::retention::WorkerRetentionPolicyUpdate {
|
||||
policy_id: "policy".into(),
|
||||
session_disposition: worker_runtime::retention::SessionDisposition::Archive,
|
||||
metadata_disposition: crate::retention::MetadataDisposition::Tombstone,
|
||||
archive_retention: crate::retention::ArchiveRetention::Forever,
|
||||
diagnostics_disposition: worker_runtime::retention::DiagnosticsDisposition::Purge,
|
||||
diagnostics_retention_seconds: None,
|
||||
};
|
||||
let digest: String = conn
|
||||
.query_row(
|
||||
"SELECT policy_digest FROM workspace_worker_retention_policies",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
digest,
|
||||
crate::retention::worker_retention_policy_digest(&update)
|
||||
);
|
||||
for index in [
|
||||
"runtime_removal_operations_one_active_runtime",
|
||||
"runtime_removal_operations_workspace_state",
|
||||
"idx_workdir_removal_operations_recovery",
|
||||
"idx_workdir_removal_operations_workdir",
|
||||
"workspace_signing_identity_audit_workspace_idx",
|
||||
] {
|
||||
assert!(
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='index' AND name=?1)",
|
||||
[index],
|
||||
|r| r.get::<_, bool>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"missing index {index}"
|
||||
);
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE runtime_removal_operations SET state='cleanup_pending'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(conn.execute("INSERT INTO runtime_removal_operations SELECT 'other',workspace_id,runtime_id,request_fingerprint,expected_binding_id,config_digest,'pending',NULL,0,NULL,created_at,updated_at,NULL FROM runtime_removal_operations", []).is_err());
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE workspace_runtime_bindings SET display_name='Blocked'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_config_content_keeps_distinct_toolchain_and_projection_history_after_cutover() {
|
||||
let conn = legacy_binding_database();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
conn.execute("INSERT INTO workspace_config_tree_history VALUES('space','sha256:config-content','toolchain-new','projection-new','{}','later','{}')", []).unwrap();
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_config_tree_history WHERE content_digest='sha256:config-content'", [], |r| r.get::<_, i64>(0)).unwrap(), 2);
|
||||
let original: String = conn.query_row("SELECT projection_digest FROM workspace_config_tree_history WHERE toolchain_fingerprint='toolchain-original'", [], |r|r.get(0)).unwrap();
|
||||
assert_eq!(original, "projection-original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_legacy_signing_or_policy_reference_rolls_back_all_cutover_changes() {
|
||||
for invalid in [
|
||||
"UPDATE workspace_signing_identities SET revision=2",
|
||||
"PRAGMA foreign_keys=OFF; DELETE FROM workspace_worker_retention_policy_revisions WHERE revision=9; PRAGMA foreign_keys=ON;",
|
||||
"DELETE FROM workspace_config_trees",
|
||||
] {
|
||||
let conn = legacy_binding_database();
|
||||
conn.execute_batch(invalid).unwrap();
|
||||
assert!(
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).is_err(),
|
||||
"accepted {invalid}"
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 87);
|
||||
assert!(column_exists(&conn, "workspace_runtime_bindings", "binding_revision").unwrap());
|
||||
assert!(!column_exists(&conn, "workspace_runtime_bindings", "binding_id").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_verifications",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_legacy_worker_removal(conn: &Connection, state: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO worker_removal_operations \
|
||||
(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,\
|
||||
policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,\
|
||||
archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,\
|
||||
blockers_json,state,reason,failure_category,created_at,updated_at) \
|
||||
VALUES('worker-removal','worker-plan','legacy-receipt-fingerprint','space','remote','worker',\
|
||||
'worker-updated','policy',9,'archive','tombstone','forever',NULL,'retain',3600,\
|
||||
'archive','[]',?1,'cleanup','legacy-outcome','created','updated')",
|
||||
[state],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_worker_removal_requires_old_version_reconciliation_without_losing_receipt_authority()
|
||||
{
|
||||
for state in ["executing", "failed"] {
|
||||
let conn = legacy_binding_database();
|
||||
seed_legacy_worker_removal(&conn, state);
|
||||
let schema = || {
|
||||
conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name")
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
};
|
||||
let schema_before = schema();
|
||||
let message = migrate_runtime_bindings_v87_to_v88(&conn)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(message.contains("old-version reconciliation"), "{message}");
|
||||
assert!(message.contains("worker-removal"), "{message}");
|
||||
assert!(message.contains("schema 87"), "{message}");
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 87);
|
||||
let evidence: (String, String, String, i64, String, String, String, String) = conn.query_row(
|
||||
"SELECT state,input_fingerprint,worker_revision,policy_revision,archive_id,failure_category,created_at,updated_at FROM worker_removal_operations", [],
|
||||
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).unwrap();
|
||||
assert_eq!(
|
||||
evidence,
|
||||
(
|
||||
state.into(),
|
||||
"legacy-receipt-fingerprint".into(),
|
||||
"worker-updated".into(),
|
||||
9,
|
||||
"archive".into(),
|
||||
"legacy-outcome".into(),
|
||||
"created".into(),
|
||||
"updated".into()
|
||||
)
|
||||
);
|
||||
assert_eq!(schema(), schema_before);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_verifications",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT binding_revision FROM workspace_runtime_bindings",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
assert!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM sqlite_temp_schema", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
// Simulate the old Server committing its recovered receipt. The same
|
||||
// connection can upgrade without dropping or reissuing the operation.
|
||||
conn.execute(
|
||||
"UPDATE worker_removal_operations SET state='succeeded',failure_category=NULL",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT input_fingerprint FROM worker_removal_operations",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"legacy-receipt-fingerprint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexecuted_and_terminal_worker_removal_history_migrates_without_new_execution_authority() {
|
||||
for state in ["planned", "blocked", "stale", "succeeded"] {
|
||||
let conn = legacy_binding_database();
|
||||
seed_legacy_worker_removal(&conn, state);
|
||||
if state == "succeeded" {
|
||||
conn.execute_batch("INSERT INTO worker_session_archives VALUES('archive','space','remote','worker','session','checksum',42,'policy',9,'worker-removal','committed',NULL);
|
||||
INSERT INTO worker_diagnostics_archives VALUES('worker-removal','space','remote','worker','policy',9,'committed','expires');
|
||||
INSERT INTO worker_tombstones VALUES('space','remote','worker','Worker','profile','worker-created','removed','archive','policy',9,'worker-removal');").unwrap();
|
||||
}
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
let (actual_state, category, fingerprint, digest): (String, Option<String>, String, String) = conn.query_row(
|
||||
"SELECT state,failure_category,input_fingerprint,policy_digest FROM worker_removal_operations", [],
|
||||
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap();
|
||||
let unexecuted = matches!(state, "planned" | "blocked");
|
||||
assert_eq!(actual_state, if unexecuted { "stale" } else { state });
|
||||
assert_eq!(
|
||||
category.as_deref(),
|
||||
Some(if unexecuted {
|
||||
"policy_content_cutover"
|
||||
} else {
|
||||
"legacy-outcome"
|
||||
})
|
||||
);
|
||||
assert_eq!(fingerprint, "legacy-receipt-fingerprint");
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT policy_digest FROM workspace_worker_retention_policies",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
digest
|
||||
);
|
||||
if state == "succeeded" {
|
||||
for table in [
|
||||
"worker_session_archives",
|
||||
"worker_diagnostics_archives",
|
||||
"worker_tombstones",
|
||||
] {
|
||||
let (operation, policy): (String, String) = conn
|
||||
.query_row(
|
||||
&format!("SELECT operation_id,policy_digest FROM {table}"),
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(operation, "worker-removal");
|
||||
assert_eq!(policy, digest);
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT checksum_sha256,content_bytes FROM worker_session_archives",
|
||||
[],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
|
||||
)
|
||||
.unwrap(),
|
||||
("checksum".into(), 42)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_removal_cutover_preserves_recovery_state_owner_and_materialization_evidence() {
|
||||
for (state, attempts, retryable, owner) in [
|
||||
("pending", 0, true, None),
|
||||
("pending", 2, true, Some((1234_i64, 5678_i64))),
|
||||
("failed", 2, true, None),
|
||||
("failed", 2, false, None),
|
||||
("completed", 2, false, None),
|
||||
] {
|
||||
let conn = legacy_binding_database();
|
||||
conn.execute("INSERT INTO workdir_removal_operations VALUES('space','workdir-removal','workdir-request','workdir','remote','repository','materialization','owner','cleanup',?1,?2,?3,?4,?5,?6,?7,'created','updated',?8)",
|
||||
params![state,attempts,retryable,if state == "completed" { Some("removed") } else { None },
|
||||
if state == "failed" { Some("provider_cleanup_outcome_unknown") } else { None },
|
||||
owner.map(|o|o.0),owner.map(|o|o.1),if state == "completed" { Some("completed") } else { None }]).unwrap();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
let (actual_state,attempt,actual_retryable,pid,marker,request,materialization):
|
||||
(String,Option<String>,bool,Option<i64>,Option<i64>,String,String) = conn.query_row(
|
||||
"SELECT state,attempt_id,retryable,attempt_owner_pid,attempt_owner_start_marker,request_fingerprint,materialization_fingerprint FROM workdir_removal_operations", [],
|
||||
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).unwrap();
|
||||
assert_eq!(actual_state, state);
|
||||
assert_eq!(attempt.is_some(), attempts > 0);
|
||||
assert_eq!(actual_retryable, retryable);
|
||||
assert_eq!(pid.zip(marker), owner);
|
||||
assert_eq!(request, "workdir-request");
|
||||
assert_eq!(materialization, "materialization");
|
||||
let (category, disposition): (Option<String>, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT failure_category,disposition FROM workdir_removal_operations",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
category.as_deref(),
|
||||
if state == "failed" {
|
||||
Some("provider_cleanup_outcome_unknown")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
disposition.as_deref(),
|
||||
if state == "completed" {
|
||||
Some("removed")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
);
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workdir_removal_operations WHERE state='pending' OR (state='failed' AND retryable=1)", [], |r| r.get::<_, i64>(0)).unwrap(), i64::from(state == "pending" || (state == "failed" && retryable)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_removal_cutover_keeps_post_binding_commit_cleanup_resumable() {
|
||||
for state in ["cleanup_pending", "succeeded"] {
|
||||
let conn = legacy_binding_database();
|
||||
conn.execute("UPDATE runtime_removal_operations SET state=?1,binding_removed=1,runtime_registration_removed=?2,completed_at=?3",
|
||||
params![state,if state == "succeeded" { Some(1) } else { None },if state == "succeeded" { Some("completed") } else { None }]).unwrap();
|
||||
conn.execute_batch(
|
||||
"DELETE FROM workspace_runtime_binding_audit; DELETE FROM workspace_runtime_bindings;",
|
||||
)
|
||||
.unwrap();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
let (actual_state,binding_removed,registration_removed,completed,fingerprint): (String,bool,Option<bool>,Option<String>,String) = conn.query_row(
|
||||
"SELECT state,binding_removed,runtime_registration_removed,completed_at,request_fingerprint FROM runtime_removal_operations", [],
|
||||
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).unwrap();
|
||||
assert_eq!(actual_state, state);
|
||||
assert!(binding_removed);
|
||||
assert_eq!(
|
||||
registration_removed,
|
||||
if state == "succeeded" {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
completed.as_deref(),
|
||||
if state == "succeeded" {
|
||||
Some("completed")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
);
|
||||
assert_eq!(fingerprint, "request");
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM runtime_removal_operations WHERE state IN ('pending','cleanup_pending')", [], |r| r.get::<_, i64>(0)).unwrap(), i64::from(state == "cleanup_pending"));
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM workspace_runtime_bindings", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod existing_workspace_provisioning {
|
||||
use super::*;
|
||||
use crate::workspace_signing_identity::{
|
||||
FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
|
||||
WorkspaceSigningMaterialStore, WorkspaceSigningPrivateMaterial,
|
||||
};
|
||||
|
||||
const OLD_OPERATION_KEY: &str = "existing-workspace:space:revision-1";
|
||||
// Frozen v1 fingerprint: SHA256(workspace || NUL || key || NUL || 1u64_be).
|
||||
const OLD_FINGERPRINT: &str =
|
||||
"sha256:913cd2ceeefdd81fcaf0363b0f5db66d39a0b9040440ca564892f3e0c93a9edd";
|
||||
const NEW_OPERATION_KEY: &str = "existing-workspace:space:key-workspace-key";
|
||||
|
||||
fn legacy_reserved_database() -> Connection {
|
||||
let conn = legacy_binding_database();
|
||||
conn.execute_batch("UPDATE workspace_signing_identities SET state='pending_provisioning',public_key=NULL,public_key_fingerprint=NULL,provisioned_at=NULL;").unwrap();
|
||||
conn.execute("INSERT INTO workspace_signing_identity_provisioning_operations VALUES(?1,?2,'existing_workspace','space','workspace-key','material',1,'owner','pending','reserved',NULL)",
|
||||
params![OLD_OPERATION_KEY, OLD_FINGERPRINT]).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn reopen_store(conn: Connection, path: &Path) -> Arc<SqliteWorkspaceStore> {
|
||||
let mut persisted = Connection::open(path).unwrap();
|
||||
Backup::new(&conn, &mut persisted)
|
||||
.unwrap()
|
||||
.run_to_completion(128, Duration::from_millis(1), None)
|
||||
.unwrap();
|
||||
drop(persisted);
|
||||
drop(conn);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
// This fixture contains only the schema-87 cutover authorities, not the
|
||||
// unrelated full Server schema. Exercise the real service/store methods.
|
||||
Arc::new(SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
fn install_frozen_material(root: &Path) -> String {
|
||||
let material = WorkspaceSigningPrivateMaterial::generate("space", "workspace-key").unwrap();
|
||||
let public_key = material
|
||||
.validate_and_public_key("space", "workspace-key")
|
||||
.unwrap();
|
||||
let mut old = serde_json::to_value(material).unwrap();
|
||||
old["version"] = 1.into();
|
||||
old["revision"] = 1.into();
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
std::fs::write(
|
||||
root.join("material.json"),
|
||||
serde_json::to_vec(&old).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
public_key
|
||||
}
|
||||
|
||||
fn assert_receipt(store: &SqliteWorkspaceStore, state: &str) {
|
||||
store.with_conn(|conn| {
|
||||
let receipt: (String,String,String,String,String,String,String,String) = conn.query_row(
|
||||
"SELECT operation_key,operation_kind,workspace_id,key_id,private_material_ref,actor_account_id,state,created_at FROM workspace_signing_identity_provisioning_operations", [],
|
||||
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?)))?;
|
||||
assert_eq!(receipt, (NEW_OPERATION_KEY.into(),"existing_workspace".into(),"space".into(),"workspace-key".into(),"material".into(),"owner".into(),state.into(),"reserved".into()));
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_provisioning_operations", [], |r| r.get::<_, i64>(0))?, 1);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_reserved_workspace_resumes_same_key_and_receipt_after_cutover_and_restart() {
|
||||
for material_was_published in [false, true] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("identities");
|
||||
let old_public_key = material_was_published.then(|| install_frozen_material(&root));
|
||||
let old_bytes =
|
||||
material_was_published.then(|| std::fs::read(root.join("material.json")).unwrap());
|
||||
let conn = legacy_reserved_database();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
let store = reopen_store(conn, &temp.path().join("server.db"));
|
||||
let materials = Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone()));
|
||||
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
|
||||
// A recovery caller need not be the original actor: the reserved
|
||||
// actor remains the authority attributed to the activation audit.
|
||||
let recovered = service
|
||||
.provision_existing("space", "recovery-caller")
|
||||
.unwrap();
|
||||
assert_eq!(recovered.key_id, "workspace-key");
|
||||
assert_eq!(recovered.private_material_ref, "material");
|
||||
assert_eq!(recovered.state, "active");
|
||||
if let Some(public_key) = old_public_key {
|
||||
assert_eq!(recovered.public_key.as_deref(), Some(public_key.as_str()));
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("material.json")).unwrap(),
|
||||
old_bytes.unwrap()
|
||||
);
|
||||
}
|
||||
assert_receipt(&store, "completed");
|
||||
assert_eq!(
|
||||
service
|
||||
.provision_existing("space", "recovery-caller")
|
||||
.unwrap(),
|
||||
recovered
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE actor_account_id='owner' AND key_id='workspace-key'", [], |r|r.get::<_, i64>(0))?, 1);
|
||||
assert!(conn.query_row("SELECT completed_at FROM workspace_signing_identity_provisioning_operations", [], |r|r.get::<_, Option<String>>(0))?.is_some());
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(materials.load("material").unwrap().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_reserved_workspace_activation_failure_rolls_back_and_retries_same_material() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("identities");
|
||||
let old_public_key = install_frozen_material(&root);
|
||||
let old_bytes = std::fs::read(root.join("material.json")).unwrap();
|
||||
let conn = legacy_reserved_database();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER fail_provisioning_audit BEFORE INSERT ON workspace_signing_identity_audit BEGIN SELECT RAISE(ABORT,'injected audit failure'); END;").unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
let store = reopen_store(conn, &path);
|
||||
let service = WorkspaceSigningIdentityService::new(
|
||||
store.clone(),
|
||||
Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone())),
|
||||
);
|
||||
assert!(service.provision_existing("space", "owner").is_err());
|
||||
assert_receipt(&store, "pending");
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_signing_identity("space")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state,
|
||||
"pending_provisioning"
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_audit", [], |r|r.get::<_, i64>(0))?, 0);
|
||||
assert_eq!(conn.query_row("SELECT completed_at FROM workspace_signing_identity_provisioning_operations", [], |r|r.get::<_, Option<String>>(0))?, None);
|
||||
conn.execute_batch("DROP TRIGGER fail_provisioning_audit")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
drop(service);
|
||||
drop(store);
|
||||
let store = Arc::new(SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(Connection::open(&path).unwrap())),
|
||||
});
|
||||
let service = WorkspaceSigningIdentityService::new(
|
||||
store.clone(),
|
||||
Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone())),
|
||||
);
|
||||
let recovered = service.provision_existing("space", "owner").unwrap();
|
||||
assert_eq!(
|
||||
recovered.public_key.as_deref(),
|
||||
Some(old_public_key.as_str())
|
||||
);
|
||||
assert_eq!(recovered.key_id, "workspace-key");
|
||||
assert_receipt(&store, "completed");
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("material.json")).unwrap(),
|
||||
old_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_existing_receipt_keeps_completion_evidence_and_workspace_create_contract_stays_opaque()
|
||||
{
|
||||
let conn = legacy_reserved_database();
|
||||
conn.execute_batch("UPDATE workspace_signing_identities SET state='active',public_key='public',public_key_fingerprint='sha256:workspace',provisioned_at='provisioned';
|
||||
UPDATE workspace_signing_identity_provisioning_operations SET state='completed',completed_at='completed';
|
||||
INSERT INTO workspace_signing_identity_provisioning_operations VALUES('workspace-create:request','create-request-fingerprint','workspace_create','new-space','new-key','new-material',1,'owner','pending','create-reserved',NULL);").unwrap();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
let completed: (String,String,String,String,String,String,String) = conn.query_row(
|
||||
"SELECT operation_key,key_id,private_material_ref,actor_account_id,state,created_at,completed_at FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='existing_workspace'", [],
|
||||
|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).unwrap();
|
||||
assert_eq!(
|
||||
completed,
|
||||
(
|
||||
NEW_OPERATION_KEY.into(),
|
||||
"workspace-key".into(),
|
||||
"material".into(),
|
||||
"owner".into(),
|
||||
"completed".into(),
|
||||
"reserved".into(),
|
||||
"completed".into()
|
||||
)
|
||||
);
|
||||
let create: (String,String,String,String,String,String,String,Option<String>) = conn.query_row(
|
||||
"SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,created_at,completed_at FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='workspace_create'", [],
|
||||
|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).unwrap();
|
||||
assert_eq!(
|
||||
create,
|
||||
(
|
||||
"workspace-create:request".into(),
|
||||
"create-request-fingerprint".into(),
|
||||
"new-space".into(),
|
||||
"new-key".into(),
|
||||
"new-material".into(),
|
||||
"pending".into(),
|
||||
"create-reserved".into(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_cutover_failure_keeps_old_reservation_contract_for_upgrade_retry() {
|
||||
let conn = legacy_reserved_database();
|
||||
// The missing config fails the runtime-removal copy after the signing
|
||||
// reservation has been converted inside the cutover transaction.
|
||||
conn.execute("DELETE FROM workspace_config_trees", [])
|
||||
.unwrap();
|
||||
assert!(migrate_runtime_bindings_v87_to_v88(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 87);
|
||||
let receipt: (String,String,String,String) = conn.query_row(
|
||||
"SELECT operation_key,request_fingerprint,key_id,private_material_ref FROM workspace_signing_identity_provisioning_operations", [],
|
||||
|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap();
|
||||
assert_eq!(
|
||||
receipt,
|
||||
(
|
||||
OLD_OPERATION_KEY.into(),
|
||||
OLD_FINGERPRINT.into(),
|
||||
"workspace-key".into(),
|
||||
"material".into()
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
column_exists(
|
||||
&conn,
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
"revision"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_config_trees VALUES('space','sha256:config-content')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT operation_key FROM workspace_signing_identity_provisioning_operations",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
NEW_OPERATION_KEY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_legacy_provisioning_receipt_rolls_back_cutover_without_replacing_reservation() {
|
||||
for invalid in [
|
||||
"UPDATE workspace_signing_identity_provisioning_operations SET key_id='different-key'",
|
||||
"UPDATE workspace_signing_identity_provisioning_operations SET private_material_ref='different-material'",
|
||||
"UPDATE workspace_signing_identity_provisioning_operations SET workspace_id='different-workspace'",
|
||||
"UPDATE workspace_signing_identity_provisioning_operations SET operation_key='different-operation'",
|
||||
"UPDATE workspace_signing_identity_provisioning_operations SET request_fingerprint='different-input'",
|
||||
"UPDATE workspace_signing_identity_provisioning_operations SET state='completed',completed_at='completed'",
|
||||
] {
|
||||
let conn = legacy_reserved_database();
|
||||
conn.execute_batch(invalid).unwrap();
|
||||
let before = || {
|
||||
conn.prepare("SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,completed_at FROM workspace_signing_identity_provisioning_operations").unwrap()
|
||||
.query_row([], |r| Ok((r.get::<_, String>(0)?,r.get::<_, String>(1)?,r.get::<_, String>(2)?,r.get::<_, String>(3)?,r.get::<_, String>(4)?,r.get::<_, String>(5)?,r.get::<_, Option<String>>(6)?))).unwrap()
|
||||
};
|
||||
let receipt = before();
|
||||
let error = migrate_runtime_bindings_v87_to_v88(&conn)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("legacy Workspace signing identity provisioning"),
|
||||
"{invalid}: {error}"
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 87);
|
||||
assert_eq!(before(), receipt);
|
||||
assert!(
|
||||
column_exists(
|
||||
&conn,
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
"revision"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
column_exists(&conn, "workspace_runtime_bindings", "binding_revision").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_verifications",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM sqlite_temp_schema", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod current_authority {
|
||||
use super::*;
|
||||
|
||||
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let workspace_key =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_key =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
|
||||
let workspace_fingerprint = normalize_runtime_public_key(&workspace_key.public_key)
|
||||
.unwrap()
|
||||
.1;
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','created','updated');
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');")?;
|
||||
conn.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519',?1,?2,'material','active','created','provisioned','updated')", params![workspace_key.public_key,workspace_fingerprint])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let request = WorkspaceRuntimeBinding {
|
||||
workspace_id: "space".into(),
|
||||
runtime_id: "remote".into(),
|
||||
display_name: "Remote".into(),
|
||||
base_url: "https://remote.test".into(),
|
||||
public_key: runtime_key.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: String::new(),
|
||||
state: WorkspaceRuntimeBindingState::Configured,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some("workspace-key".into()),
|
||||
workspace_public_key_fingerprint: Some(workspace_fingerprint),
|
||||
workspace_trust_id: Some("runtime-issued-trust".into()),
|
||||
created_at: "created".into(),
|
||||
updated_at: "updated".into(),
|
||||
revoked_at: None,
|
||||
};
|
||||
let (_, binding) = store
|
||||
.put_workspace_runtime_binding_key(request, None, "owner")
|
||||
.unwrap();
|
||||
(store, binding)
|
||||
}
|
||||
|
||||
fn evidence(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeVerificationEvidence {
|
||||
WorkspaceRuntimeVerificationEvidence {
|
||||
workspace_id: binding.workspace_id.clone(),
|
||||
runtime_id: binding.runtime_id.clone(),
|
||||
binding_id: binding.binding_id.clone(),
|
||||
workspace_key_id: binding.workspace_key_id.clone().unwrap(),
|
||||
workspace_public_key_fingerprint: binding
|
||||
.workspace_public_key_fingerprint
|
||||
.clone()
|
||||
.unwrap(),
|
||||
workspace_trust_id: binding.workspace_trust_id.clone().unwrap(),
|
||||
runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(),
|
||||
challenge_id: "challenge".into(),
|
||||
state: "verified".into(),
|
||||
last_outcome: "verified".into(),
|
||||
verified_at: Some("verified-at".into()),
|
||||
checked_at: "checked-at".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_key_reactivation_fences_old_cas_and_verification_evidence() {
|
||||
let (store, binding) = binding_store();
|
||||
let old_proof = evidence(&binding);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&old_proof)
|
||||
.unwrap();
|
||||
store
|
||||
.complete_workspace_runtime_verification(&old_proof)
|
||||
.unwrap();
|
||||
let (_, revoked) = store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
"space",
|
||||
"remote",
|
||||
&binding.binding_id,
|
||||
"owner",
|
||||
"revoked-at",
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(binding.binding_id, revoked.binding_id);
|
||||
let mut replacement = binding.clone();
|
||||
replacement.state = WorkspaceRuntimeBindingState::Configured;
|
||||
let (_, reactivated) = store
|
||||
.put_workspace_runtime_binding_key(replacement, Some(&revoked.binding_id), "owner")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
binding.public_key_fingerprint,
|
||||
reactivated.public_key_fingerprint
|
||||
);
|
||||
assert_ne!(binding.binding_id, reactivated.binding_id);
|
||||
assert!(matches!(
|
||||
store.put_workspace_runtime_binding_key(
|
||||
reactivated.clone(),
|
||||
Some(&binding.binding_id),
|
||||
"owner"
|
||||
),
|
||||
Err(Error::RuntimeBindingIdConflict { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
store.revoke_workspace_runtime_binding_key(
|
||||
"space",
|
||||
"remote",
|
||||
&binding.binding_id,
|
||||
"owner",
|
||||
"later"
|
||||
),
|
||||
Err(Error::RuntimeBindingIdConflict { .. })
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&old_proof)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.complete_workspace_runtime_verification(&old_proof)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.workspace_runtime_verification_matches(
|
||||
&reactivated,
|
||||
&old_proof.workspace_public_key_fingerprint,
|
||||
&old_proof.workspace_trust_id
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_aba_and_workspace_key_change_cannot_reuse_verification_authority() {
|
||||
let (store, binding) = binding_store();
|
||||
let proof = evidence(&binding);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&proof)
|
||||
.unwrap();
|
||||
store
|
||||
.complete_workspace_runtime_verification(&proof)
|
||||
.unwrap();
|
||||
let moved = store
|
||||
.update_workspace_runtime_binding_metadata(
|
||||
"space",
|
||||
"remote",
|
||||
"Remote",
|
||||
"https://other.test",
|
||||
"changed",
|
||||
)
|
||||
.unwrap();
|
||||
let returned = store
|
||||
.update_workspace_runtime_binding_metadata(
|
||||
"space",
|
||||
"remote",
|
||||
"Remote",
|
||||
"https://remote.test",
|
||||
"changed-back",
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(moved.binding_id, binding.binding_id);
|
||||
assert_ne!(returned.binding_id, binding.binding_id);
|
||||
assert!(
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&proof)
|
||||
.is_err()
|
||||
);
|
||||
let current = evidence(&returned);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(¤t)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'",[])?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.complete_workspace_runtime_verification(¤t)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_runtime_binding("space", "remote")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state,
|
||||
WorkspaceRuntimeBindingState::Configured
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -564,7 +564,7 @@ pub enum WorkerSpawnIntent {
|
||||
job_id: String,
|
||||
attempt_id: String,
|
||||
purpose: String,
|
||||
input_revision: String,
|
||||
input_digest: String,
|
||||
subjektiv_consolidation: bool,
|
||||
},
|
||||
TicketRole {
|
||||
@@ -1723,8 +1723,8 @@ impl RuntimeRegistry {
|
||||
"workspace_prompt_projection_notification_failed",
|
||||
HostDiagnosticSeverity::Warning,
|
||||
format!(
|
||||
"runtime '{}' rejected Workspace Prompt projection revision {}: {message}",
|
||||
runtime.runtime_id(), projection.config_revision
|
||||
"runtime '{}' rejected Workspace Prompt projection digest {}: {message}",
|
||||
runtime.runtime_id(), projection.projection_digest
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -3614,22 +3614,33 @@ impl WorkspaceRuntimeAuthorization {
|
||||
"Workspace Runtime binding is missing its Workspace key".to_string(),
|
||||
)
|
||||
})?;
|
||||
let trust_generation = binding.workspace_key_generation.ok_or_else(|| {
|
||||
let workspace_public_key_fingerprint = binding
|
||||
.workspace_public_key_fingerprint
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_invalid",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is missing its Workspace public key fingerprint"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let workspace_trust_id = binding.workspace_trust_id.as_deref().ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_invalid",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is missing its trust generation".to_string(),
|
||||
"Workspace Runtime binding is missing its Workspace trust ID".to_string(),
|
||||
)
|
||||
})?;
|
||||
if identity.state != "active"
|
||||
|| identity.key_id != workspace_key_id
|
||||
|| identity.revision != trust_generation
|
||||
|| identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint)
|
||||
|| !self
|
||||
.store
|
||||
.workspace_runtime_verification_matches(
|
||||
&binding,
|
||||
identity.revision,
|
||||
trust_generation,
|
||||
workspace_public_key_fingerprint,
|
||||
workspace_trust_id,
|
||||
)
|
||||
.map_err(|error| {
|
||||
diagnostic(
|
||||
@@ -3650,9 +3661,9 @@ impl WorkspaceRuntimeAuthorization {
|
||||
issuer: self.backend_url.clone(),
|
||||
issuer_workspace_id: binding.workspace_id.clone(),
|
||||
issuer_key_id: identity.key_id,
|
||||
issuer_identity_revision: identity.revision,
|
||||
trust_generation,
|
||||
binding_revision: binding.binding_revision,
|
||||
issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(),
|
||||
trust_id: workspace_trust_id.to_string(),
|
||||
binding_id: binding.binding_id.clone(),
|
||||
runtime_id: binding.runtime_id.clone(),
|
||||
worker_id: worker_id.map(str::to_string),
|
||||
operation: operation.to_string(),
|
||||
@@ -5726,13 +5737,13 @@ fn runtime_create_worker_request(
|
||||
WorkerSpawnIntent::BackendJob {
|
||||
job_id,
|
||||
attempt_id,
|
||||
input_revision,
|
||||
input_digest,
|
||||
subjektiv_consolidation,
|
||||
..
|
||||
} => Some(worker_runtime::catalog::BackendJobExecutionBinding {
|
||||
job_id: job_id.clone(),
|
||||
attempt_id: attempt_id.clone(),
|
||||
input_revision: Some(input_revision.clone()),
|
||||
input_digest: Some(input_digest.clone()),
|
||||
subjektiv_consolidation: *subjektiv_consolidation,
|
||||
}),
|
||||
_ => None,
|
||||
@@ -5824,7 +5835,6 @@ fn builtin_profile_config_bundle(
|
||||
metadata: ConfigBundleMetadata {
|
||||
id,
|
||||
digest: String::new(),
|
||||
revision: "workspace-runtime-v0".to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
created_at: "runtime-generated".to_string(),
|
||||
provenance: ConfigBundleProvenance {
|
||||
@@ -6771,7 +6781,6 @@ mod tests {
|
||||
fn test_memory_settings() -> manifest::WorkspaceMemorySettingsSnapshot {
|
||||
manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -7040,7 +7049,6 @@ mod tests {
|
||||
metadata: worker_runtime::config_bundle::ConfigBundleMetadata {
|
||||
id: "bundle-1".to_string(),
|
||||
digest: String::new(),
|
||||
revision: "rev-1".to_string(),
|
||||
workspace_id: "local:test".to_string(),
|
||||
created_at: "2026-06-26T00:00:00Z".to_string(),
|
||||
provenance: worker_runtime::config_bundle::ConfigBundleProvenance {
|
||||
@@ -7137,10 +7145,9 @@ mod tests {
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let expected = RepositoryRefObservation {
|
||||
repository_id: "repository-1".to_string(),
|
||||
source_revision: 7,
|
||||
source_fingerprint: "sha256:source".to_string(),
|
||||
selector: "refs/heads/published".to_string(),
|
||||
revision_ref: "0123456789012345678901234567890123456789".to_string(),
|
||||
resolved_ref: "0123456789012345678901234567890123456789".to_string(),
|
||||
observed_at_epoch_seconds: 42,
|
||||
};
|
||||
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||
@@ -7159,7 +7166,6 @@ mod tests {
|
||||
kind: server_api::RepositorySourceKind::LocalPath,
|
||||
uri: "/provider/repository.git".to_string(),
|
||||
},
|
||||
source_revision: 7,
|
||||
source_fingerprint: "sha256:source".to_string(),
|
||||
selector: None,
|
||||
},
|
||||
@@ -7259,7 +7265,7 @@ mod tests {
|
||||
runtime_id: String,
|
||||
host_id: String,
|
||||
workers: Vec<InternalWorkerSummary>,
|
||||
observed_prompt_revisions: Arc<Mutex<Vec<u64>>>,
|
||||
observed_prompt_digests: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl FixtureRuntime {
|
||||
@@ -7295,7 +7301,7 @@ mod tests {
|
||||
workdir_attachments: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
}],
|
||||
observed_prompt_revisions: Arc::new(Mutex::new(Vec::new())),
|
||||
observed_prompt_digests: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7309,10 +7315,10 @@ mod tests {
|
||||
&self,
|
||||
projection: worker::WorkspacePromptProjection,
|
||||
) -> Result<(), String> {
|
||||
self.observed_prompt_revisions
|
||||
self.observed_prompt_digests
|
||||
.lock()
|
||||
.map_err(|_| "prompt projection observations poisoned".to_string())?
|
||||
.push(projection.config_revision);
|
||||
.push(projection.projection_digest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7404,14 +7410,14 @@ mod tests {
|
||||
fn registry_gate_rejects_cached_runtime_immediately_after_binding_revocation() {
|
||||
let remote =
|
||||
FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "worker from runtime a");
|
||||
let remote_observed = remote.observed_prompt_revisions.clone();
|
||||
let remote_observed = remote.observed_prompt_digests.clone();
|
||||
let embedded = FixtureRuntime::with_worker(
|
||||
EMBEDDED_RUNTIME_ID,
|
||||
"embedded-host",
|
||||
"embedded-worker",
|
||||
"embedded worker",
|
||||
);
|
||||
let embedded_observed = embedded.observed_prompt_revisions.clone();
|
||||
let embedded_observed = embedded.observed_prompt_digests.clone();
|
||||
let registry = RuntimeRegistry::new(vec![Arc::new(remote), Arc::new(embedded)]);
|
||||
let active = Arc::new(Mutex::new(true));
|
||||
let gate_state = active.clone();
|
||||
@@ -7437,14 +7443,14 @@ mod tests {
|
||||
"default".to_string(),
|
||||
"workspace prompt".to_string(),
|
||||
)]),
|
||||
12,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
.unwrap();
|
||||
let expected_digest = catalog.catalog_digest.clone();
|
||||
let projection = worker::WorkspacePromptProjection::new(
|
||||
"workspace-a",
|
||||
"source-12",
|
||||
"source-content",
|
||||
catalog.catalog_digest.clone(),
|
||||
catalog,
|
||||
)
|
||||
@@ -7455,28 +7461,28 @@ mod tests {
|
||||
.is_empty()
|
||||
);
|
||||
assert!(remote_observed.lock().unwrap().is_empty());
|
||||
assert_eq!(*embedded_observed.lock().unwrap(), vec![12]);
|
||||
assert_eq!(*embedded_observed.lock().unwrap(), vec![expected_digest]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_broadcasts_workspace_prompt_projection_revisions() {
|
||||
fn registry_broadcasts_workspace_prompt_projection_digests() {
|
||||
let runtime =
|
||||
FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "worker from runtime a");
|
||||
let observed = runtime.observed_prompt_revisions.clone();
|
||||
let observed = runtime.observed_prompt_digests.clone();
|
||||
let registry = RuntimeRegistry::new(vec![Arc::new(runtime)]);
|
||||
let catalog = worker::EffectivePromptCatalog::new(
|
||||
std::collections::BTreeMap::from([(
|
||||
"default".to_string(),
|
||||
"workspace prompt".to_string(),
|
||||
)]),
|
||||
12,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
.unwrap();
|
||||
let expected_digest = catalog.catalog_digest.clone();
|
||||
let projection = worker::WorkspacePromptProjection::new(
|
||||
"workspace-a",
|
||||
"source-12",
|
||||
"source-content",
|
||||
catalog.catalog_digest.clone(),
|
||||
catalog,
|
||||
)
|
||||
@@ -7485,7 +7491,7 @@ mod tests {
|
||||
let diagnostics = registry.observe_workspace_prompt_projection(projection);
|
||||
|
||||
assert!(diagnostics.is_empty());
|
||||
assert_eq!(*observed.lock().unwrap(), vec![12]);
|
||||
assert_eq!(*observed.lock().unwrap(), vec![expected_digest]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,17 +22,20 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
|
||||
let now = Utc::now().timestamp();
|
||||
let workspace_public_key_fingerprint =
|
||||
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
|
||||
.unwrap();
|
||||
let binding_id = "binding-restore-test".to_string();
|
||||
let verifications = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
|
||||
verifications
|
||||
.record(WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: "workspace-test".into(),
|
||||
runtime_id: "runtime-test".into(),
|
||||
binding_revision: 1,
|
||||
binding_id: binding_id.clone(),
|
||||
workspace_trust_id: "trust-restore-test".to_string(),
|
||||
workspace_key_id: "workspace-key".into(),
|
||||
workspace_identity_revision: 1,
|
||||
workspace_trust_generation: 1,
|
||||
workspace_public_key_fingerprint: workspace_public_key_fingerprint.clone(),
|
||||
runtime_public_key_fingerprint: signer.public_key_fingerprint().to_owned(),
|
||||
runtime_identity_revision: 1,
|
||||
verified_at: now,
|
||||
})
|
||||
.unwrap();
|
||||
@@ -52,8 +55,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
),
|
||||
identity_revision: 1,
|
||||
trust_generation: 1,
|
||||
trust_id: "trust-restore-test".to_string(),
|
||||
state: WorkspaceIssuerTrustState::Active,
|
||||
registered_at_unix: now,
|
||||
updated_at_unix: now,
|
||||
@@ -73,7 +75,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
);
|
||||
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
|
||||
let body = serde_json::to_vec(&runtime_api::WorkerRestoreCoordinationRequest {
|
||||
expected_observation_token: "observed-generation".into(),
|
||||
expected_observation_token: "observed-runtime-state".into(),
|
||||
request_id: "restore-request".into(),
|
||||
preparation: None,
|
||||
})
|
||||
@@ -90,14 +92,35 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
"wrong-operation",
|
||||
"wrong-worker",
|
||||
"wrong-body",
|
||||
"wrong-key-fingerprint",
|
||||
"wrong-binding-id",
|
||||
"wrong-trust-id",
|
||||
] {
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: "https://backend.test".into(),
|
||||
issuer_workspace_id: "workspace-test".into(),
|
||||
issuer_key_id: "workspace-key".into(),
|
||||
issuer_identity_revision: 1,
|
||||
trust_generation: 1,
|
||||
binding_revision: 1,
|
||||
issuer_public_key_fingerprint: if case == "wrong-key-fingerprint" {
|
||||
format!(
|
||||
"sha256:{}",
|
||||
Sha256::digest(b"another-workspace-key")
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
)
|
||||
} else {
|
||||
workspace_public_key_fingerprint.clone()
|
||||
},
|
||||
binding_id: if case == "wrong-binding-id" {
|
||||
"binding-retired".to_string()
|
||||
} else {
|
||||
binding_id.clone()
|
||||
},
|
||||
trust_id: if case == "wrong-trust-id" {
|
||||
"trust-retired".to_string()
|
||||
} else {
|
||||
"trust-restore-test".to_string()
|
||||
},
|
||||
runtime_id: "runtime-test".into(),
|
||||
worker_id: if case == "wrong-worker" {
|
||||
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
|
||||
@@ -153,22 +176,36 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
} else {
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
if case == "wrong-binding-id" {
|
||||
StatusCode::FORBIDDEN
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
},
|
||||
"{path} {case}: {}",
|
||||
error.error.message
|
||||
);
|
||||
assert_eq!(error.error.code, "unauthorized");
|
||||
assert_eq!(
|
||||
error.error.code,
|
||||
if case == "wrong-binding-id" {
|
||||
"workspace_runtime_verification_stale"
|
||||
} else {
|
||||
"unauthorized"
|
||||
}
|
||||
);
|
||||
let expected = match case {
|
||||
"wrong-operation" => "does not authorize this operation",
|
||||
"wrong-worker" => "targets another Worker",
|
||||
"wrong-body" => "does not bind this request body",
|
||||
"wrong-operation" => Some("does not authorize this operation"),
|
||||
"wrong-worker" => Some("targets another Worker"),
|
||||
"wrong-body" => Some("does not bind this request body"),
|
||||
"wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(
|
||||
error.error.message.contains(expected),
|
||||
"{path} {case}: {}",
|
||||
error.error.message
|
||||
);
|
||||
if let Some(expected) = expected {
|
||||
assert!(
|
||||
error.error.message.contains(expected),
|
||||
"{path} {case}: {}",
|
||||
error.error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,15 +114,14 @@ CREATE TABLE device_login_flows (
|
||||
approved_at TEXT,
|
||||
consumed_at TEXT
|
||||
);
|
||||
CREATE TABLE flow_source_revisions (
|
||||
CREATE TABLE flow_source_contents (
|
||||
workspace_id TEXT NOT NULL,
|
||||
flow_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||
content TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
definition_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, flow_id, revision),
|
||||
PRIMARY KEY (workspace_id, flow_id, content_digest),
|
||||
FOREIGN KEY (workspace_id, flow_id)
|
||||
REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -134,7 +133,6 @@ CREATE TABLE flow_sources (
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, flow_id),
|
||||
@@ -232,7 +230,6 @@ CREATE TABLE "repositories" (
|
||||
updated_at TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_uri TEXT NOT NULL,
|
||||
source_revision INTEGER NOT NULL DEFAULT 1,
|
||||
source_fingerprint TEXT NOT NULL,
|
||||
observed_status TEXT NOT NULL DEFAULT 'unverified',
|
||||
observed_at TEXT,
|
||||
@@ -245,7 +242,7 @@ CREATE TABLE repository_secret_audit_events (
|
||||
event_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, event_id),
|
||||
@@ -257,19 +254,20 @@ CREATE TABLE repository_secret_operations (
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')),
|
||||
resource_id TEXT NOT NULL,
|
||||
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
|
||||
result_operation_id TEXT NOT NULL CHECK (length(result_operation_id) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_credential_revisions (
|
||||
CREATE TABLE repository_ssh_credential_keys (
|
||||
workspace_id TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
|
||||
public_key_algorithm TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, credential_id, revision),
|
||||
PRIMARY KEY (workspace_id, credential_id, operation_id),
|
||||
UNIQUE (workspace_id, credential_id, operation_id, public_key_fingerprint),
|
||||
FOREIGN KEY (workspace_id, credential_id)
|
||||
REFERENCES repository_ssh_credentials(workspace_id, credential_id)
|
||||
ON DELETE CASCADE
|
||||
@@ -280,24 +278,24 @@ CREATE TABLE repository_ssh_credentials (
|
||||
name TEXT NOT NULL,
|
||||
public_key_algorithm TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
current_revision INTEGER NOT NULL CHECK (current_revision >= 1),
|
||||
current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
|
||||
created_at TEXT NOT NULL,
|
||||
rotated_at TEXT,
|
||||
PRIMARY KEY (workspace_id, credential_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_host_trust_revisions (
|
||||
CREATE TABLE repository_ssh_host_trust_keys (
|
||||
workspace_id TEXT NOT NULL,
|
||||
host_trust_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
key_algorithm TEXT NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, host_trust_id, revision),
|
||||
PRIMARY KEY (workspace_id, host_trust_id, operation_id),
|
||||
FOREIGN KEY (workspace_id, host_trust_id)
|
||||
REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id)
|
||||
ON DELETE CASCADE
|
||||
@@ -310,24 +308,24 @@ CREATE TABLE repository_ssh_host_trusts (
|
||||
key_algorithm TEXT NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
current_revision INTEGER NOT NULL CHECK (current_revision >= 1),
|
||||
current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, host_trust_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE server_secret_versions (
|
||||
CREATE TABLE server_secret_objects (
|
||||
workspace_id TEXT NOT NULL,
|
||||
secret_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')),
|
||||
encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'),
|
||||
nonce BLOB NOT NULL CHECK (length(nonce) = 12),
|
||||
ciphertext BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, secret_id, revision, purpose),
|
||||
FOREIGN KEY (workspace_id, secret_id, revision)
|
||||
REFERENCES repository_ssh_credential_revisions(workspace_id, credential_id, revision)
|
||||
PRIMARY KEY (workspace_id, secret_id, operation_id, purpose),
|
||||
FOREIGN KEY (workspace_id, secret_id, operation_id)
|
||||
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE ticket_assignment_operations (
|
||||
@@ -443,11 +441,12 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
base_url TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
|
||||
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
|
||||
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
|
||||
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
|
||||
workspace_key_id TEXT,
|
||||
workspace_key_generation INTEGER CHECK (workspace_key_generation > 0),
|
||||
workspace_public_key_fingerprint TEXT,
|
||||
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
@@ -455,9 +454,9 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
UNIQUE (workspace_id, public_key_fingerprint),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
||||
CHECK (
|
||||
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL)
|
||||
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
|
||||
OR
|
||||
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL)
|
||||
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
|
||||
),
|
||||
CHECK (
|
||||
(state = 'revoked' AND revoked_at IS NOT NULL)
|
||||
@@ -468,12 +467,11 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
CREATE TABLE workspace_runtime_verifications (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
binding_revision INTEGER NOT NULL CHECK(binding_revision > 0),
|
||||
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
|
||||
workspace_key_id TEXT NOT NULL,
|
||||
workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0),
|
||||
workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0),
|
||||
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
|
||||
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
|
||||
runtime_public_key_fingerprint TEXT NOT NULL,
|
||||
runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0),
|
||||
challenge_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
|
||||
last_outcome TEXT NOT NULL,
|
||||
@@ -494,15 +492,15 @@ CREATE TABLE workspace_runtime_binding_audit (
|
||||
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
|
||||
old_fingerprint TEXT,
|
||||
new_fingerprint TEXT,
|
||||
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
|
||||
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, binding_revision),
|
||||
PRIMARY KEY (workspace_id, runtime_id, binding_id),
|
||||
FOREIGN KEY(workspace_id, runtime_id)
|
||||
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX idx_workspace_runtime_binding_audit_recent
|
||||
ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC);
|
||||
ON workspace_runtime_binding_audit(workspace_id, runtime_id, at DESC, binding_id DESC);
|
||||
CREATE TABLE typed_ticket_artifacts (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||
@@ -621,13 +619,12 @@ CREATE TABLE workdir_create_operations (
|
||||
selector TEXT,
|
||||
requested_runtime_id TEXT,
|
||||
resolved_runtime_id TEXT NOT NULL,
|
||||
config_revision INTEGER NOT NULL,
|
||||
config_projection_digest TEXT NOT NULL,
|
||||
working_directory_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
|
||||
failure TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT,
|
||||
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_fingerprint TEXT, credential_id TEXT, credential_fingerprint TEXT, host_trust_id TEXT, host_trust_fingerprint TEXT, repository_access_mode TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
UNIQUE (workspace_id, working_directory_id)
|
||||
);
|
||||
@@ -637,34 +634,34 @@ CREATE TABLE workdir_create_credential_candidates (
|
||||
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
|
||||
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
|
||||
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
|
||||
credential_revision INTEGER NOT NULL CHECK (credential_revision > 0),
|
||||
credential_fingerprint TEXT NOT NULL CHECK (length(credential_fingerprint) > 0),
|
||||
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||
UNIQUE (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint),
|
||||
UNIQUE (workspace_id, operation_id, role),
|
||||
UNIQUE (workspace_id, operation_id, credential_id),
|
||||
FOREIGN KEY (workspace_id, operation_id)
|
||||
REFERENCES workdir_create_operations(workspace_id, operation_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_workdir_create_credential_candidates_revision
|
||||
CREATE INDEX idx_workdir_create_credential_candidates_fingerprint
|
||||
ON workdir_create_credential_candidates(
|
||||
workspace_id, credential_id, credential_revision
|
||||
workspace_id, credential_id, credential_fingerprint
|
||||
);
|
||||
CREATE TABLE workdir_create_credential_revision_retentions (
|
||||
CREATE TABLE workdir_create_credential_retentions (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
credential_revision INTEGER NOT NULL,
|
||||
credential_fingerprint TEXT NOT NULL,
|
||||
credential_operation_id TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, operation_id, ordinal)
|
||||
FOREIGN KEY (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint)
|
||||
REFERENCES workdir_create_credential_candidates(
|
||||
workspace_id, operation_id, ordinal
|
||||
workspace_id, operation_id, ordinal, credential_id, credential_fingerprint
|
||||
)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, credential_id, credential_revision)
|
||||
REFERENCES repository_ssh_credential_revisions(
|
||||
workspace_id, credential_id, revision
|
||||
)
|
||||
FOREIGN KEY (workspace_id, credential_id, credential_operation_id, credential_fingerprint)
|
||||
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id, public_key_fingerprint)
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
CREATE TABLE "workdir_registry" (
|
||||
@@ -734,7 +731,7 @@ CREATE TABLE workdir_removal_operations (
|
||||
source_actor TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
attempt_id TEXT,
|
||||
retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)),
|
||||
disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')),
|
||||
failure_category TEXT,
|
||||
@@ -774,7 +771,7 @@ CREATE TABLE worker_create_reservations (
|
||||
create_fingerprint TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('reserved', 'created', 'removed')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_settings_revision INTEGER, memory_language TEXT,
|
||||
updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_language TEXT,
|
||||
singleton_key TEXT,
|
||||
singleton_generation INTEGER CHECK (singleton_generation IS NULL OR singleton_generation > 0),
|
||||
PRIMARY KEY (workspace_id, allocation_key),
|
||||
@@ -810,7 +807,7 @@ CREATE TABLE worker_singleton_owners (
|
||||
);
|
||||
CREATE TABLE worker_diagnostics_archives (
|
||||
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
|
||||
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL,
|
||||
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
|
||||
@@ -846,7 +843,7 @@ CREATE TABLE backend_jobs (
|
||||
workspace_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
input_revision TEXT NOT NULL,
|
||||
input_digest TEXT NOT NULL,
|
||||
input_ref TEXT NOT NULL,
|
||||
resource_key TEXT,
|
||||
request_json TEXT NOT NULL,
|
||||
@@ -874,7 +871,7 @@ CREATE TABLE backend_job_attempts (
|
||||
job_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3),
|
||||
input_revision TEXT NOT NULL,
|
||||
input_digest TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed', 'unknown')),
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
@@ -955,8 +952,8 @@ CREATE TABLE worker_registry_projection_diagnostics (
|
||||
CREATE TABLE worker_removal_operations (
|
||||
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
worker_revision TEXT NOT NULL,
|
||||
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
|
||||
worker_updated_at TEXT NOT NULL,
|
||||
policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL,
|
||||
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
|
||||
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
|
||||
diagnostics_disposition TEXT NOT NULL,
|
||||
@@ -972,7 +969,7 @@ CREATE TABLE worker_retention_audit_events (
|
||||
CREATE TABLE worker_session_archives (
|
||||
archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL,
|
||||
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE,
|
||||
policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE,
|
||||
committed_at TEXT NOT NULL, expires_at TEXT,
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
|
||||
@@ -990,7 +987,7 @@ CREATE INDEX worker_session_archive_observe_grants_controller
|
||||
CREATE TABLE worker_tombstones (
|
||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
|
||||
archive_id TEXT, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE,
|
||||
archive_id TEXT, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE,
|
||||
PRIMARY KEY(workspace_id,runtime_id,worker_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id),
|
||||
@@ -1027,21 +1024,19 @@ CREATE TABLE workspace_config_entries (
|
||||
PRIMARY KEY (workspace_id, path),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_config_tree_revisions (
|
||||
CREATE TABLE workspace_config_tree_history (
|
||||
workspace_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
tree_digest TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
toolchain_fingerprint TEXT NOT NULL,
|
||||
projection_digest TEXT NOT NULL,
|
||||
manifest_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}',
|
||||
PRIMARY KEY (workspace_id, revision),
|
||||
PRIMARY KEY (workspace_id, content_digest, toolchain_fingerprint, projection_digest),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_config_trees (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 0),
|
||||
tree_digest TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
entrypoints_json TEXT NOT NULL,
|
||||
decodal_version TEXT NOT NULL,
|
||||
@@ -1065,7 +1060,6 @@ CREATE TABLE workspace_signing_identities (
|
||||
public_key TEXT,
|
||||
public_key_fingerprint TEXT,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
|
||||
created_at TEXT NOT NULL,
|
||||
provisioned_at TEXT,
|
||||
@@ -1084,7 +1078,6 @@ CREATE TABLE workspace_signing_identity_provisioning_operations (
|
||||
workspace_id TEXT NOT NULL UNIQUE,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
actor_account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -1095,7 +1088,6 @@ CREATE TABLE workspace_signing_identity_audit (
|
||||
workspace_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('provisioned')),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -1111,7 +1103,6 @@ CREATE TABLE workspace_memory_documents (
|
||||
);
|
||||
CREATE TABLE workspace_memory_settings (
|
||||
workspace_id TEXT PRIMARY KEY NOT NULL,
|
||||
settings_revision INTEGER NOT NULL CHECK(settings_revision >= 1),
|
||||
language TEXT NOT NULL CHECK(length(trim(language)) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@@ -1137,18 +1128,18 @@ CREATE TABLE "workspace_resource_keys" (
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_worker_retention_policies (
|
||||
workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(workspace_id,policy_id,revision) REFERENCES workspace_worker_retention_policy_revisions(workspace_id,policy_id,revision),
|
||||
workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(workspace_id,policy_id,policy_digest) REFERENCES workspace_worker_retention_policy_snapshots(workspace_id,policy_id,policy_digest),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
|
||||
CREATE TABLE workspace_worker_retention_policy_revisions (
|
||||
workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
|
||||
CREATE TABLE workspace_worker_retention_policy_snapshots (
|
||||
workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL CHECK(length(policy_digest)>0),
|
||||
session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')),
|
||||
metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')),
|
||||
archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')),
|
||||
archive_retention_seconds INTEGER,
|
||||
diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')),
|
||||
diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,policy_id,revision),
|
||||
PRIMARY KEY(workspace_id,policy_id,policy_digest),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
|
||||
CREATE TABLE "workspaces" (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
@@ -1252,8 +1243,8 @@ CREATE TABLE runtime_removal_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
expected_binding_revision INTEGER NOT NULL,
|
||||
config_revision INTEGER NOT NULL,
|
||||
expected_binding_id TEXT NOT NULL CHECK(length(expected_binding_id)>0),
|
||||
config_digest TEXT NOT NULL CHECK(length(config_digest)>0),
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')),
|
||||
failure_category TEXT,
|
||||
binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)),
|
||||
@@ -1446,7 +1437,7 @@ CREATE TABLE workspace_deletion_operations (
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
workspace_display_name TEXT NOT NULL,
|
||||
workspace_revision TEXT NOT NULL,
|
||||
workspace_updated_at TEXT NOT NULL,
|
||||
owner_account_id TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')),
|
||||
@@ -1464,11 +1455,11 @@ CREATE INDEX workspace_deletion_operations_workspace_recent
|
||||
ON workspace_deletion_operations(workspace_id, created_at DESC);
|
||||
|
||||
CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN
|
||||
INSERT INTO workspace_worker_retention_policy_revisions
|
||||
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
|
||||
VALUES(NEW.workspace_id,'workspace-default-conservative',1,'archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at);
|
||||
INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at)
|
||||
VALUES(NEW.workspace_id,'workspace-default-conservative',1,NEW.created_at);
|
||||
INSERT INTO workspace_worker_retention_policy_snapshots
|
||||
(workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
|
||||
VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198','archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at);
|
||||
INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,policy_digest,updated_at)
|
||||
VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198',NEW.created_at);
|
||||
END;
|
||||
CREATE TRIGGER ticket_assignment_operations_validate_insert
|
||||
BEFORE INSERT ON ticket_assignment_operations
|
||||
@@ -1654,3 +1645,47 @@ CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_reg
|
||||
WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id
|
||||
AND worker_id=OLD.worker_id AND revoked=0;
|
||||
END;
|
||||
-- Frozen evidence only. Neither table is a key, lease, or mutation authority.
|
||||
CREATE TABLE workdir_create_legacy_ssh_archives (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
operation_json TEXT NOT NULL CHECK (json_valid(operation_json)),
|
||||
candidates_json TEXT NOT NULL CHECK (json_valid(candidates_json)),
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id, operation_id)
|
||||
REFERENCES workdir_create_operations(workspace_id, operation_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER workdir_create_legacy_archive_cannot_authorize_update
|
||||
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
|
||||
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
|
||||
WHERE a.workspace_id=OLD.workspace_id AND a.operation_id=OLD.operation_id)
|
||||
AND (NEW.state <> 'succeeded' OR NEW.credential_id IS NOT NULL
|
||||
OR NEW.credential_fingerprint IS NOT NULL OR NEW.host_trust_id IS NOT NULL
|
||||
OR NEW.host_trust_fingerprint IS NOT NULL OR NEW.repository_access_mode IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
|
||||
CREATE TRIGGER workdir_create_legacy_archive_blocks_candidates
|
||||
BEFORE INSERT ON workdir_create_credential_candidates FOR EACH ROW
|
||||
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
|
||||
CREATE TRIGGER workdir_create_legacy_archive_blocks_candidate_updates
|
||||
BEFORE UPDATE ON workdir_create_credential_candidates FOR EACH ROW
|
||||
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
|
||||
CREATE TABLE repository_secret_legacy_receipts (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
result_revision INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
mutation_kind TEXT,
|
||||
expected_revision INTEGER,
|
||||
expected_operation_id TEXT,
|
||||
expected_key_fingerprint TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id, operation_id)
|
||||
REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -136,10 +136,10 @@ pub enum Error {
|
||||
WorkspaceConfigConflict(String),
|
||||
#[error("Runtime binding conflict: {0}")]
|
||||
RuntimeBindingConflict(String),
|
||||
#[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")]
|
||||
RuntimeBindingRevisionConflict {
|
||||
expected: Option<u64>,
|
||||
actual: Option<u64>,
|
||||
#[error("Runtime binding identity conflict: expected {expected:?}, current {actual:?}")]
|
||||
RuntimeBindingIdConflict {
|
||||
expected: Option<String>,
|
||||
actual: Option<String>,
|
||||
},
|
||||
#[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")]
|
||||
RuntimeBindingFingerprintConflict { fingerprint: String },
|
||||
|
||||
@@ -786,11 +786,12 @@ mod tests {
|
||||
base_url: "https://runtime.example.test".to_owned(),
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_revision: 1,
|
||||
binding_id: "binding-test".to_owned(),
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_key_generation: None,
|
||||
workspace_public_key_fingerprint: None,
|
||||
workspace_trust_id: None,
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
@@ -813,11 +814,12 @@ mod tests {
|
||||
base_url: "http://127.0.0.1:8788".to_owned(),
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_revision: 1,
|
||||
binding_id: "binding-test".to_owned(),
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some("WK-test".to_owned()),
|
||||
workspace_key_generation: Some(1),
|
||||
workspace_public_key_fingerprint: Some("sha256:workspace-key".to_owned()),
|
||||
workspace_trust_id: Some("trust-test".to_owned()),
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
|
||||
@@ -176,7 +176,7 @@ fn memory_evidence_origin_projection(origin: EvidenceOrigin) -> MemoryEvidenceOr
|
||||
worker_id: origin.worker_id,
|
||||
flow_selector: origin.flow_selector,
|
||||
flow_definition_id: origin.flow_definition_id,
|
||||
flow_definition_revision: origin.flow_definition_revision,
|
||||
flow_definition_fingerprint: origin.flow_definition_fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ mod tests {
|
||||
worker_id: Some("worker-1".to_string()),
|
||||
flow_selector: Some("builtin:coder-review".to_string()),
|
||||
flow_definition_id: Some("flow-1".to_string()),
|
||||
flow_definition_revision: Some(7),
|
||||
flow_definition_fingerprint: Some("sha256:flow-content".into()),
|
||||
}),
|
||||
..SourceEvidenceRef::default()
|
||||
});
|
||||
@@ -328,7 +328,10 @@ mod tests {
|
||||
Some("builtin:coder-review")
|
||||
);
|
||||
assert_eq!(origin.flow_definition_id.as_deref(), Some("flow-1"));
|
||||
assert_eq!(origin.flow_definition_revision, Some(7));
|
||||
assert_eq!(
|
||||
origin.flow_definition_fingerprint,
|
||||
Some("sha256:flow-content".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Public migration entrypoints must preserve the old authority on refusal and
|
||||
// validate encrypted data using the original database's secret context.
|
||||
fn seed_migration_workspace(conn: &Connection) {
|
||||
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at)
|
||||
VALUES('owner','user','owner','Owner','1','1');
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at)
|
||||
VALUES('space','owner','Space','active','1','1');").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_schema_84_with_uncertain_removal_preserves_old_server_recovery() {
|
||||
for state in ["executing", "failed"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
prepare_retained_schema(&path, 84);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
seed_migration_workspace(&conn);
|
||||
conn.execute_batch("INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',1,'archive','tombstone','forever',NULL,'retain',3600,'created');
|
||||
UPDATE workspace_worker_retention_policies SET policy_id='policy',revision=1,updated_at='updated' WHERE workspace_id='space';").unwrap();
|
||||
conn.execute("INSERT INTO worker_removal_operations
|
||||
(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,
|
||||
policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,
|
||||
archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,
|
||||
blockers_json,state,reason,failure_category,created_at,updated_at)
|
||||
VALUES('removal','plan','legacy-receipt','space','runtime','worker','updated',
|
||||
'policy',1,'archive','tombstone','forever',NULL,'retain',3600,'archive',
|
||||
'[]',?1,'cleanup','uncertain','created','updated')",[state]).unwrap();
|
||||
let schema_before: String = conn.query_row(
|
||||
"SELECT group_concat(sql, char(10)) FROM (SELECT sql FROM sqlite_schema ORDER BY type,name)",
|
||||
[], |row| row.get(0)).unwrap();
|
||||
drop(conn);
|
||||
for attempt in 0..3 {
|
||||
let error = match attempt {
|
||||
0 => SqliteWorkspaceStore::open(&path).err().unwrap(),
|
||||
1 => SqliteWorkspaceStore::migration_plan(&path).unwrap_err(),
|
||||
_ => SqliteWorkspaceStore::migrate_database(&path).unwrap_err(),
|
||||
};
|
||||
assert!(
|
||||
error.to_string().contains("receipt reconciliation"),
|
||||
"{error}"
|
||||
);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 84);
|
||||
let schema_after: String = conn.query_row(
|
||||
"SELECT group_concat(sql, char(10)) FROM (SELECT sql FROM sqlite_schema ORDER BY type,name)",
|
||||
[], |row| row.get(0)).unwrap();
|
||||
assert_eq!(schema_after, schema_before);
|
||||
let receipt: (String,String,String) = conn.query_row(
|
||||
"SELECT input_fingerprint,archive_id,state FROM worker_removal_operations WHERE operation_id='removal'",
|
||||
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap();
|
||||
assert_eq!(
|
||||
receipt,
|
||||
("legacy-receipt".into(), "archive".into(), state.into())
|
||||
);
|
||||
}
|
||||
// Simulate the old Server settling its receipt, then upgrade normally.
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE worker_removal_operations SET state='succeeded' WHERE operation_id='removal'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(conn.query_row("SELECT input_fingerprint FROM worker_removal_operations WHERE operation_id='removal'",[],|r|r.get::<_,String>(0))?,"legacy-receipt");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_dry_run_and_apply_share_the_original_repository_secret_authority() {
|
||||
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
prepare_retained_schema(&path, 84);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
seed_migration_workspace(&conn);
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap();
|
||||
let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||
let mut ciphertext = b"private bytes".to_vec();
|
||||
key.seal_in_place_append_tag(
|
||||
Nonce::assume_unique_for_key([1; 12]),
|
||||
Aad::from(b"yoi/repository-secret/v1/space/key/7/private_key".as_slice()),
|
||||
&mut ciphertext,
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'created')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
|
||||
drop(conn);
|
||||
let master_key = dir.path().join("repository-secrets.master-key");
|
||||
std::fs::write(&master_key, [42; 32]).unwrap();
|
||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||
assert_eq!(plan.current_schema_version, 84);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 84);
|
||||
let unchanged: Vec<u8> = conn
|
||||
.query_row("SELECT ciphertext FROM server_secret_versions", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(unchanged, ciphertext);
|
||||
assert!(!table_exists(&conn, "server_secret_objects").unwrap());
|
||||
drop(conn);
|
||||
assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
|
||||
let (nonce, mut sealed): (Vec<u8>,Vec<u8>) = conn.query_row(
|
||||
"SELECT nonce,ciphertext FROM server_secret_objects WHERE workspace_id='space' AND secret_id='key' AND operation_id='create-key'",
|
||||
[],|r|Ok((r.get(0)?,r.get(1)?)))?;
|
||||
let aad = serde_json::to_string(&("yoi/repository-secret/operation","space","key","create-key","private_key")).unwrap();
|
||||
let plaintext = key.open_in_place(Nonce::assume_unique_for_key(nonce.try_into().unwrap()),Aad::from(aad.as_bytes()),&mut sealed).unwrap();
|
||||
assert_eq!(plaintext,b"private bytes");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert_eq!(std::fs::read(master_key).unwrap(), [42; 32]);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ pub(crate) fn project_profiles_from_evaluation(
|
||||
resolve_profile_artifact_value(
|
||||
entry.profile.clone(),
|
||||
ProfileSource::Archive {
|
||||
archive_id: format!("workspace-config-r{}", state.snapshot.revision),
|
||||
archive_id: format!("workspace-config:{}", state.snapshot.digest),
|
||||
source: entry.selector.clone(),
|
||||
},
|
||||
Path::new("/"),
|
||||
@@ -172,7 +172,6 @@ pub(crate) fn project_profiles_from_evaluation(
|
||||
content_digest: config_source::digest_bytes(&value_bytes),
|
||||
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
|
||||
editable: false,
|
||||
revision: state.snapshot.revision.to_string(),
|
||||
size_bytes: value_bytes.len() as u64,
|
||||
diagnostics: Vec::new(),
|
||||
});
|
||||
@@ -190,8 +189,6 @@ pub(crate) fn project_profiles_from_evaluation(
|
||||
Ok(ProfileConfigProjection {
|
||||
settings: ProfileSettingsResponse {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
registry_revision: format!("config:{}", state.snapshot.revision),
|
||||
config_revision: Some(state.snapshot.revision),
|
||||
tree_digest: Some(state.snapshot.digest.clone()),
|
||||
projection_digest: Some(evaluation.projection_digest.clone()),
|
||||
default_profile: Some(config.profile.default_profile),
|
||||
@@ -259,10 +256,6 @@ fn validate_prompt_projection_matches_state(
|
||||
projection.projection_digest != prompt_catalog.catalog_digest,
|
||||
"projection digest",
|
||||
),
|
||||
(
|
||||
prompt_catalog.config_revision != state.snapshot.revision,
|
||||
"config revision",
|
||||
),
|
||||
(
|
||||
prompt_catalog.schema_fingerprint != state.contract.schema_bundle.fingerprint,
|
||||
"schema fingerprint",
|
||||
@@ -296,8 +289,6 @@ fn virtual_profile_bundle_id(
|
||||
hasher.update(b"workspace-profile-launch-v1\0");
|
||||
hasher.update(workspace_id.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(state.snapshot.revision.to_le_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(state.snapshot.digest.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(state.projection_digest.as_bytes());
|
||||
@@ -320,10 +311,7 @@ fn virtual_profile_bundle_id(
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
Ok(format!(
|
||||
"workspace-config-profile-r{}-{identity}",
|
||||
state.snapshot.revision
|
||||
))
|
||||
Ok(format!("workspace-config-profile-{identity}"))
|
||||
}
|
||||
|
||||
pub fn build_virtual_profile_config_bundle(
|
||||
@@ -386,7 +374,6 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
|
||||
prompt_projection: &worker::WorkspacePromptProjection,
|
||||
) -> Result<Option<ConfigBundle>> {
|
||||
if projection.settings.workspace_id != workspace_id
|
||||
|| projection.settings.config_revision != Some(state.snapshot.revision)
|
||||
|| projection.settings.tree_digest.as_deref() != Some(state.snapshot.digest.as_str())
|
||||
|| projection.settings.projection_digest.as_deref()
|
||||
!= Some(state.projection_digest.as_str())
|
||||
@@ -418,14 +405,13 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
|
||||
metadata: ConfigBundleMetadata {
|
||||
id: bundle_id,
|
||||
digest: String::new(),
|
||||
revision: state.snapshot.revision.to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
created_at: workspace_created_at.to_string(),
|
||||
provenance: ConfigBundleProvenance {
|
||||
source: "workspace_config".to_string(),
|
||||
detail: Some(format!(
|
||||
"revision={} tree={} projection={}",
|
||||
state.snapshot.revision, state.snapshot.digest, state.projection_digest
|
||||
"content={} projection={}",
|
||||
state.snapshot.digest, state.projection_digest
|
||||
)),
|
||||
},
|
||||
},
|
||||
@@ -474,7 +460,7 @@ fn build_virtual_profile_archive(
|
||||
)
|
||||
})?;
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
format!("workspace-config-profile-r{}", state.snapshot.revision),
|
||||
format!("workspace-config-profile:{}", state.snapshot.digest),
|
||||
selector.to_string(),
|
||||
entry.profile.clone(),
|
||||
builtin_sources,
|
||||
@@ -489,7 +475,7 @@ pub fn workspace_metadata_settings(
|
||||
workspace_id: workspace.workspace_id.clone(),
|
||||
display_name: workspace.display_name.clone(),
|
||||
created_at: workspace.created_at.clone(),
|
||||
revision: workspace.updated_at.clone(),
|
||||
updated_at: workspace.updated_at.clone(),
|
||||
source: "server_db".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
@@ -586,7 +572,7 @@ mod tests {
|
||||
assert_eq!(settings.workspace_id, workspace.workspace_id);
|
||||
assert_eq!(settings.display_name, workspace.display_name);
|
||||
assert_eq!(settings.created_at, workspace.created_at);
|
||||
assert_eq!(settings.revision, workspace.updated_at);
|
||||
assert_eq!(settings.updated_at, workspace.updated_at);
|
||||
assert_eq!(settings.source, "server_db");
|
||||
assert!(settings.diagnostics.is_empty());
|
||||
}
|
||||
@@ -606,7 +592,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn virtual_state(entries: Vec<config_source::ConfigEntry>) -> WorkspaceConfigState {
|
||||
let snapshot = config_source::ConfigTreeSnapshot::from_entries(7, entries).unwrap();
|
||||
let snapshot = config_source::ConfigTreeSnapshot::from_entries(entries).unwrap();
|
||||
let schema_bundle = config_source::WorkspaceConfigSchemaBundle::compose([
|
||||
ProfileConfigSchemaProvider.contribution().unwrap(),
|
||||
crate::prompt_settings::PromptConfigSchemaProvider
|
||||
@@ -632,7 +618,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_config_projection_builds_archive_from_active_revision() {
|
||||
fn virtual_config_projection_builds_archive_from_active_content() {
|
||||
let state = virtual_state(vec![
|
||||
config_source::ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
@@ -657,9 +643,12 @@ mod tests {
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(projection.settings.config_revision, Some(7));
|
||||
assert_eq!(
|
||||
projection.settings.tree_digest.as_deref(),
|
||||
Some(state.snapshot.digest.as_str())
|
||||
);
|
||||
let prompt_catalog = bundle.prompt_catalog.as_ref().unwrap();
|
||||
assert_eq!(prompt_catalog.config_revision, 7);
|
||||
assert_eq!(prompt_catalog.source_digest, state.snapshot.digest);
|
||||
assert!(!prompt_catalog.templates.is_empty());
|
||||
assert!(
|
||||
bundle
|
||||
@@ -667,7 +656,7 @@ mod tests {
|
||||
.provenance
|
||||
.detail
|
||||
.unwrap()
|
||||
.contains("revision=7")
|
||||
.contains(&format!("content={}", state.snapshot.digest))
|
||||
);
|
||||
let archive = bundle.profile_source_archive.unwrap();
|
||||
assert_eq!(
|
||||
@@ -767,18 +756,13 @@ mod tests {
|
||||
companion
|
||||
.metadata
|
||||
.id
|
||||
.starts_with("workspace-config-profile-r7-")
|
||||
);
|
||||
assert!(
|
||||
coder
|
||||
.metadata
|
||||
.id
|
||||
.starts_with("workspace-config-profile-r7-")
|
||||
.starts_with("workspace-config-profile-")
|
||||
);
|
||||
assert!(coder.metadata.id.starts_with("workspace-config-profile-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_config_launch_bundle_rejects_prompt_projection_from_other_revision() {
|
||||
fn virtual_config_launch_bundle_rejects_prompt_projection_from_other_content() {
|
||||
let state = virtual_state(vec![
|
||||
config_source::ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
@@ -792,7 +776,14 @@ mod tests {
|
||||
crate::prompt_settings::project_workspace_prompt_projection("workspace-test", &state)
|
||||
.unwrap();
|
||||
let mut mismatched_state = state.clone();
|
||||
mismatched_state.snapshot.revision += 1;
|
||||
mismatched_state.snapshot = state
|
||||
.snapshot
|
||||
.apply(&[config_source::ConfigTreeChange::Create {
|
||||
path: VirtualPath::parse("changed.txt").unwrap(),
|
||||
content_type: ConfigContentType::Text,
|
||||
content: "different source tree".into(),
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
let error = build_virtual_profile_config_bundle_with_prompt_projection(
|
||||
&projection,
|
||||
@@ -866,7 +857,7 @@ mod tests {
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new(
|
||||
let snapshot = config_source::ConfigTreeSnapshot::from_entries([config_source::ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal,
|
||||
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
|
||||
).unwrap()]).unwrap();
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::{Error, Result};
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct PromptProjectionCacheKey {
|
||||
workspace_id: String,
|
||||
config_revision: u64,
|
||||
source_digest: String,
|
||||
projection_digest: String,
|
||||
schema_fingerprint: String,
|
||||
@@ -23,7 +22,6 @@ impl PromptProjectionCacheKey {
|
||||
fn new(workspace_id: &str, state: &WorkspaceConfigState) -> Self {
|
||||
Self {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
config_revision: state.snapshot.revision,
|
||||
source_digest: state.snapshot.digest.clone(),
|
||||
projection_digest: state.projection_digest.clone(),
|
||||
schema_fingerprint: state.contract.schema_bundle.fingerprint.clone(),
|
||||
@@ -37,15 +35,15 @@ type PromptProjectionCell = OnceLock<std::result::Result<Arc<WorkspacePromptProj
|
||||
#[derive(Debug, Default)]
|
||||
struct PromptProjectionCacheState {
|
||||
entries: BTreeMap<PromptProjectionCacheKey, Arc<PromptProjectionCell>>,
|
||||
active: BTreeMap<String, PromptProjectionCacheKey>,
|
||||
}
|
||||
|
||||
/// WorkspaceApi-shared immutable Prompt projections keyed by authoritative Workspace config
|
||||
/// identity.
|
||||
///
|
||||
/// This cache is an evaluation optimization only. Callers must load the active
|
||||
/// [`WorkspaceConfigState`] from Server DB authority before resolving an entry. Advancing a
|
||||
/// Workspace replaces only its active cache entry; in-flight users retain their immutable `Arc`.
|
||||
/// [`WorkspaceConfigState`] from Server DB authority before resolving an entry.
|
||||
/// Content and evaluation contracts are distinct keys; in-flight users retain their
|
||||
/// immutable `Arc`. No ordering or freshness is inferred from a digest.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorkspacePromptProjectionCache {
|
||||
inner: Arc<Mutex<PromptProjectionCacheState>>,
|
||||
@@ -58,33 +56,25 @@ impl WorkspacePromptProjectionCache {
|
||||
state: &WorkspaceConfigState,
|
||||
) -> Result<Arc<WorkspacePromptProjection>> {
|
||||
let key = PromptProjectionCacheKey::new(workspace_id, state);
|
||||
let (cell, cached) = {
|
||||
let cell = {
|
||||
let mut cache = self.lock()?;
|
||||
if let Some(active) = cache.active.get(workspace_id) {
|
||||
if key.config_revision == active.config_revision && key != *active {
|
||||
return Err(Error::RegistryInconsistency(format!(
|
||||
"Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}",
|
||||
key.config_revision
|
||||
)));
|
||||
}
|
||||
if key.config_revision < active.config_revision {
|
||||
(Arc::new(PromptProjectionCell::new()), false)
|
||||
} else {
|
||||
let cell = cache
|
||||
.entries
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| Arc::new(PromptProjectionCell::new()))
|
||||
.clone();
|
||||
(cell, true)
|
||||
}
|
||||
} else {
|
||||
let cell = cache
|
||||
// Bound retained cells without treating any content identity as newer.
|
||||
// In-flight evaluation cells are never evicted, preserving single flight.
|
||||
if !cache.entries.contains_key(&key) && cache.entries.len() >= 64 {
|
||||
let evict = cache
|
||||
.entries
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| Arc::new(PromptProjectionCell::new()))
|
||||
.clone();
|
||||
(cell, true)
|
||||
.iter()
|
||||
.find(|(_, cell)| Arc::strong_count(cell) == 1)
|
||||
.map(|(key, _)| key.clone());
|
||||
if let Some(evict) = evict {
|
||||
cache.entries.remove(&evict);
|
||||
}
|
||||
}
|
||||
cache
|
||||
.entries
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| Arc::new(PromptProjectionCell::new()))
|
||||
.clone()
|
||||
};
|
||||
|
||||
let resolved = cell
|
||||
@@ -97,54 +87,29 @@ impl WorkspacePromptProjectionCache {
|
||||
let catalog = match resolved {
|
||||
Ok(catalog) => catalog,
|
||||
Err(error) => {
|
||||
if cached {
|
||||
self.lock()?.entries.remove(&key);
|
||||
}
|
||||
self.lock()?.entries.remove(&key);
|
||||
return Err(Error::Config(error));
|
||||
}
|
||||
};
|
||||
|
||||
if cached {
|
||||
self.record_resolved(workspace_id, &key, &cell)?;
|
||||
// Concurrent distinct evaluations may temporarily exceed the retention
|
||||
// limit. Trim completed cells after resolution without evicting in-flight
|
||||
// work or assigning a temporal order to content identities.
|
||||
let mut cache = self.lock()?;
|
||||
while cache.entries.len() > 64 {
|
||||
let evict = cache
|
||||
.entries
|
||||
.iter()
|
||||
.find(|(_, cell)| Arc::strong_count(cell) == 1)
|
||||
.map(|(key, _)| key.clone());
|
||||
let Some(evict) = evict else {
|
||||
break;
|
||||
};
|
||||
cache.entries.remove(&evict);
|
||||
}
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
fn record_resolved(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
key: &PromptProjectionCacheKey,
|
||||
cell: &Arc<PromptProjectionCell>,
|
||||
) -> Result<()> {
|
||||
let mut cache = self.lock()?;
|
||||
let active = cache.active.get(workspace_id).cloned();
|
||||
match active {
|
||||
Some(active) if active.config_revision > key.config_revision => {
|
||||
cache.entries.remove(key);
|
||||
}
|
||||
Some(active) if active.config_revision == key.config_revision => {
|
||||
if active != *key {
|
||||
cache.entries.remove(key);
|
||||
return Err(Error::RegistryInconsistency(format!(
|
||||
"Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}",
|
||||
key.config_revision
|
||||
)));
|
||||
}
|
||||
cache.entries.entry(key.clone()).or_insert(cell.clone());
|
||||
}
|
||||
_ => {
|
||||
cache.entries.insert(key.clone(), cell.clone());
|
||||
cache.active.insert(workspace_id.to_string(), key.clone());
|
||||
cache.entries.retain(|existing, _| {
|
||||
existing.workspace_id != workspace_id
|
||||
|| existing == key
|
||||
|| existing.config_revision > key.config_revision
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, PromptProjectionCacheState>> {
|
||||
self.inner.lock().map_err(|_| {
|
||||
Error::RegistryInconsistency("Prompt projection cache lock was poisoned".to_string())
|
||||
@@ -192,7 +157,7 @@ pub fn validate_evaluated_prompt_catalog(
|
||||
let prompts = projection.data_json.get("prompts").ok_or_else(|| {
|
||||
Error::InvalidInput("Workspace config projection has no prompts namespace".to_string())
|
||||
})?;
|
||||
EffectivePromptCatalog::from_projection(prompts, 0, "preview", "preview")
|
||||
EffectivePromptCatalog::from_projection(prompts, "preview", "preview")
|
||||
.map(|_| ())
|
||||
.map_err(|error| Error::InvalidInput(format!("invalid Prompt catalog: {error}")))
|
||||
}
|
||||
@@ -203,7 +168,7 @@ pub fn project_prompts_from_workspace_config(
|
||||
let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?;
|
||||
if evaluation.projection_digest != state.projection_digest {
|
||||
return Err(Error::RegistryInconsistency(
|
||||
"Prompt projection digest does not match the active Workspace config revision"
|
||||
"Prompt projection digest does not match the active Workspace config content"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
@@ -217,7 +182,6 @@ pub fn project_prompts_from_workspace_config(
|
||||
})?;
|
||||
let mut catalog = EffectivePromptCatalog::from_projection(
|
||||
prompts,
|
||||
state.snapshot.revision,
|
||||
state.contract.schema_bundle.fingerprint.clone(),
|
||||
state.contract.fingerprint.clone(),
|
||||
)
|
||||
@@ -249,23 +213,16 @@ mod tests {
|
||||
};
|
||||
|
||||
fn state(source: &str) -> WorkspaceConfigState {
|
||||
state_at(7, source)
|
||||
}
|
||||
|
||||
fn state_at(revision: u64, source: &str) -> WorkspaceConfigState {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
revision,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()],
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
@@ -285,134 +242,74 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_projection_cache_shares_immutable_entry_and_replaces_workspace_revision() {
|
||||
fn prompt_projection_cache_reuses_content_and_separates_workspaces() {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
let initial = state("{}");
|
||||
let first = cache.resolve("workspace-a", &initial).unwrap();
|
||||
let retry = cache.resolve("workspace-a", &initial).unwrap();
|
||||
let retry = cache.resolve("workspace-a", &state("{}")).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &retry));
|
||||
assert_eq!(cache.len(), 1);
|
||||
|
||||
let updated = state_at(
|
||||
8,
|
||||
r#"{ prompts = { common = { language = "UPDATED"; }; }; }"#,
|
||||
);
|
||||
let updated = state(r#"{ prompts = { common = { language = "UPDATED"; }; }; }"#);
|
||||
let replacement = cache.resolve("workspace-a", &updated).unwrap();
|
||||
assert!(!Arc::ptr_eq(&first, &replacement));
|
||||
assert_eq!(
|
||||
replacement.catalog().templates["common.language"],
|
||||
"UPDATED"
|
||||
);
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&first,
|
||||
&cache.resolve("workspace-a", &initial).unwrap()
|
||||
));
|
||||
assert_ne!(first.catalog().templates["common.language"], "UPDATED");
|
||||
let other = cache.resolve("workspace-b", &updated).unwrap();
|
||||
assert!(!Arc::ptr_eq(&replacement, &other));
|
||||
assert_eq!(cache.len(), 3);
|
||||
}
|
||||
|
||||
let other_workspace = cache.resolve("workspace-b", &updated).unwrap();
|
||||
assert!(!Arc::ptr_eq(&replacement, &other_workspace));
|
||||
#[test]
|
||||
fn prompt_projection_cache_separates_contracts_for_same_content() {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
let initial = state("{}");
|
||||
let mut changed = initial.clone();
|
||||
let bundle = WorkspaceConfigSchemaBundle::compose([
|
||||
PromptConfigSchemaProvider.contribution().unwrap(),
|
||||
ConfigSchemaContribution::new(
|
||||
"builtin:extra",
|
||||
"extra",
|
||||
"1",
|
||||
"{ extra = Bool default false; }",
|
||||
)
|
||||
.unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
changed.contract = ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
changed.contract.entrypoints.clone(),
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
bundle,
|
||||
);
|
||||
changed.projection_digest = SnapshotEnvironment::new(changed.snapshot.clone())
|
||||
.evaluate_contract(&changed.contract)
|
||||
.unwrap()
|
||||
.projection_digest;
|
||||
assert_eq!(initial.snapshot.digest, changed.snapshot.digest);
|
||||
let first = cache.resolve("workspace-a", &initial).unwrap();
|
||||
let second = cache.resolve("workspace-a", &changed).unwrap();
|
||||
assert!(!Arc::ptr_eq(&first, &second));
|
||||
assert!(Arc::ptr_eq(
|
||||
&first,
|
||||
&cache.resolve("workspace-a", &initial).unwrap()
|
||||
));
|
||||
assert_eq!(cache.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_projection_cache_does_not_let_stale_revision_evict_active_entry() {
|
||||
fn prompt_projection_cache_bounds_retained_content_cells() {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
let current = state_at(
|
||||
8,
|
||||
r#"{ prompts = { common = { language = "CURRENT"; }; }; }"#,
|
||||
);
|
||||
let stale = state_at(7, "{}");
|
||||
|
||||
let current_catalog = cache.resolve("workspace-a", ¤t).unwrap();
|
||||
let stale_catalog = cache.resolve("workspace-a", &stale).unwrap();
|
||||
let current_retry = cache.resolve("workspace-a", ¤t).unwrap();
|
||||
|
||||
assert_eq!(stale_catalog.catalog().config_revision, 7);
|
||||
assert!(Arc::ptr_eq(¤t_catalog, ¤t_retry));
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_projection_cache_rejects_same_revision_reinterpretation() {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
let first = state_at(7, "{}");
|
||||
let changed = state_at(
|
||||
7,
|
||||
r#"{ prompts = { common = { language = "CHANGED"; }; }; }"#,
|
||||
);
|
||||
|
||||
cache.resolve("workspace-a", &first).unwrap();
|
||||
let error = cache.resolve("workspace-a", &changed).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("without a config revision transition")
|
||||
);
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_projection_cache_post_init_rejects_concurrent_same_revision_identity() {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
let first = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}"));
|
||||
let conflicting = PromptProjectionCacheKey::new(
|
||||
"workspace-a",
|
||||
&state_at(
|
||||
7,
|
||||
r#"{ prompts = { common = { language = "CONFLICT"; }; }; }"#,
|
||||
),
|
||||
);
|
||||
let first_cell = Arc::new(PromptProjectionCell::new());
|
||||
let conflicting_cell = Arc::new(PromptProjectionCell::new());
|
||||
{
|
||||
let mut state = cache.lock().unwrap();
|
||||
state.entries.insert(first.clone(), first_cell.clone());
|
||||
state
|
||||
.entries
|
||||
.insert(conflicting.clone(), conflicting_cell.clone());
|
||||
for index in 0..80 {
|
||||
let source = format!("{{ prompts = {{ common = {{ language = \"{index}\"; }}; }}; }}");
|
||||
cache.resolve("workspace-a", &state(&source)).unwrap();
|
||||
}
|
||||
|
||||
cache
|
||||
.record_resolved("workspace-a", &first, &first_cell)
|
||||
.unwrap();
|
||||
let error = cache
|
||||
.record_resolved("workspace-a", &conflicting, &conflicting_cell)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("without a config revision transition")
|
||||
);
|
||||
let state = cache.lock().unwrap();
|
||||
assert_eq!(state.active["workspace-a"], first);
|
||||
assert!(!state.entries.contains_key(&conflicting));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_projection_cache_keeps_newer_inflight_entry_when_older_finishes_first() {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
let older = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}"));
|
||||
let newer = PromptProjectionCacheKey::new(
|
||||
"workspace-a",
|
||||
&state_at(8, r#"{ prompts = { common = { language = "NEW"; }; }; }"#),
|
||||
);
|
||||
let older_cell = Arc::new(PromptProjectionCell::new());
|
||||
let newer_cell = Arc::new(PromptProjectionCell::new());
|
||||
{
|
||||
let mut state = cache.lock().unwrap();
|
||||
state.entries.insert(older.clone(), older_cell.clone());
|
||||
state.entries.insert(newer.clone(), newer_cell.clone());
|
||||
}
|
||||
|
||||
cache
|
||||
.record_resolved("workspace-a", &older, &older_cell)
|
||||
.unwrap();
|
||||
assert!(cache.lock().unwrap().entries.contains_key(&newer));
|
||||
|
||||
cache
|
||||
.record_resolved("workspace-a", &newer, &newer_cell)
|
||||
.unwrap();
|
||||
let state = cache.lock().unwrap();
|
||||
assert_eq!(state.active["workspace-a"], newer);
|
||||
assert_eq!(state.entries.len(), 1);
|
||||
assert!(state.entries.contains_key(&newer));
|
||||
assert_eq!(cache.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -442,7 +339,7 @@ mod tests {
|
||||
let baseline = project_prompts_from_workspace_config(&state("{}")).unwrap();
|
||||
let state = state(r#"{ prompts = { common = { language = "OVERRIDE"; }; }; }"#);
|
||||
let catalog = project_prompts_from_workspace_config(&state).unwrap();
|
||||
assert_eq!(catalog.config_revision, 7);
|
||||
assert_eq!(catalog.source_digest, state.snapshot.digest);
|
||||
assert_eq!(catalog.templates["common.language"], "OVERRIDE");
|
||||
for (key, value) in baseline.templates {
|
||||
if key != "common.language" {
|
||||
@@ -462,15 +359,12 @@ mod tests {
|
||||
r#"{ prompts = { common = { language = "{%- include \"missing\" -%}"; }; }; }"#,
|
||||
r#"{ prompts = { common = { language = "{% include \"common.workspace\" %}"; workspace = "{% include \"common.language\" %}"; }; }; }"#,
|
||||
] {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
0,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()],
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
@@ -496,15 +390,12 @@ mod tests {
|
||||
"{ prompts = { common = { unknown = \"bad\"; }; }; }",
|
||||
"{ prompts = { common = { language = 42; }; }; }",
|
||||
] {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
0,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()],
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
|
||||
@@ -18,7 +18,6 @@ pub struct ConfiguredRepository {
|
||||
pub repository_key: String,
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
pub observed_at: Option<String>,
|
||||
@@ -321,7 +320,6 @@ impl RepositoryRegistryReader {
|
||||
kind: repository.provider.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
source: repository.source.clone(),
|
||||
source_revision: repository.source_revision,
|
||||
source_fingerprint: repository.source_fingerprint.clone(),
|
||||
observed_status: repository.observed_status,
|
||||
observed_at: repository.observed_at.clone(),
|
||||
@@ -606,7 +604,6 @@ mod tests {
|
||||
provider: "git".into(),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
source_revision: 1,
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: None,
|
||||
@@ -638,7 +635,6 @@ mod tests {
|
||||
provider: "git".into(),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
source_revision: 1,
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: None,
|
||||
@@ -648,6 +644,10 @@ mod tests {
|
||||
let projection = reader.list();
|
||||
let summary = &projection.items[0];
|
||||
assert_eq!(summary.source.kind, server_api::RepositorySourceKind::Ssh);
|
||||
assert_eq!(
|
||||
summary.source_fingerprint,
|
||||
crate::repository_source::repository_source_fingerprint(&summary.source)
|
||||
);
|
||||
assert_eq!(
|
||||
summary.observed_status,
|
||||
RepositoryObservedStatus::Unverified
|
||||
@@ -761,7 +761,6 @@ mod tests {
|
||||
&source_descriptor,
|
||||
),
|
||||
source: source_descriptor,
|
||||
source_revision: 1,
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: Some(path.to_path_buf()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,8 +99,6 @@ impl BackendResourceBroker {
|
||||
operation: BackendResourceOperation::FetchArchive,
|
||||
expires_at_unix_seconds: expires_at.timestamp(),
|
||||
nonce: nonce.clone(),
|
||||
revision: archive.reference.digest.clone(),
|
||||
generation: None,
|
||||
max_bytes: DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES,
|
||||
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
|
||||
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
@@ -126,7 +124,6 @@ impl BackendResourceBroker {
|
||||
workspace_id: impl Into<String>,
|
||||
runtime_id: &str,
|
||||
resource_id: impl Into<String>,
|
||||
revision: impl Into<String>,
|
||||
expires_at_unix_seconds: i64,
|
||||
secret: RepositorySshAccessSecret,
|
||||
) -> Result<BackendResourceHandle, BackendResourceError> {
|
||||
@@ -142,7 +139,6 @@ impl BackendResourceBroker {
|
||||
}
|
||||
let workspace_id = workspace_id.into();
|
||||
let resource_id = resource_id.into();
|
||||
let revision = revision.into();
|
||||
let nonce = Uuid::now_v7().to_string();
|
||||
let handle = BackendResourceHandle {
|
||||
kind: BackendResourceKind::RepositorySshAccess,
|
||||
@@ -155,8 +151,6 @@ impl BackendResourceBroker {
|
||||
operation: BackendResourceOperation::FetchOnce,
|
||||
expires_at_unix_seconds,
|
||||
nonce: nonce.clone(),
|
||||
revision,
|
||||
generation: None,
|
||||
max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
|
||||
content_type: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
@@ -404,13 +398,12 @@ mod tests {
|
||||
"workspace-test",
|
||||
"runtime-test",
|
||||
"repository-access-test",
|
||||
"1",
|
||||
i64::MAX,
|
||||
RepositorySshAccessSecret {
|
||||
credential_candidates: vec![
|
||||
worker_runtime::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-test".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "sha256:test-credential-key".to_string(),
|
||||
private_key: "private-key-bytes".to_string(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -38,7 +38,7 @@ pub enum ArchiveRetention {
|
||||
pub struct WorkerRetentionPolicy {
|
||||
pub workspace_id: String,
|
||||
pub policy_id: String,
|
||||
pub revision: u64,
|
||||
pub policy_digest: String,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub metadata_disposition: MetadataDisposition,
|
||||
pub archive_retention: ArchiveRetention,
|
||||
@@ -97,9 +97,9 @@ pub struct WorkerRemovalPlan {
|
||||
pub input_fingerprint: String,
|
||||
pub workspace_id: String,
|
||||
pub worker: RuntimeWorkerRef,
|
||||
pub worker_revision: String,
|
||||
pub worker_updated_at: String,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub policy_digest: String,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub metadata_disposition: MetadataDisposition,
|
||||
pub archive_retention: ArchiveRetention,
|
||||
@@ -132,7 +132,7 @@ pub struct WorkerTombstone {
|
||||
pub removed_at: String,
|
||||
pub archive_id: Option<String>,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub policy_digest: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ pub struct WorkerSessionArchiveRecord {
|
||||
pub checksum_sha256: String,
|
||||
pub content_bytes: u64,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub policy_digest: String,
|
||||
pub operation_id: String,
|
||||
pub committed_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
@@ -168,8 +168,8 @@ pub enum WorkerRetentionError {
|
||||
Store(#[from] StoreError),
|
||||
#[error("Worker retention policy is not configured for Workspace {workspace_id}")]
|
||||
PolicyMissing { workspace_id: String },
|
||||
#[error("Worker retention policy revision conflict: expected {expected}, current {actual}")]
|
||||
PolicyRevisionConflict { expected: u64, actual: u64 },
|
||||
#[error("Worker retention policy digest conflict: expected {expected}, current {actual}")]
|
||||
PolicyDigestConflict { expected: String, actual: String },
|
||||
#[error("Worker was not found in the requested Workspace")]
|
||||
WorkerNotFound,
|
||||
#[error("Worker belongs to a different Workspace")]
|
||||
@@ -195,21 +195,24 @@ impl SqliteWorkspaceStore {
|
||||
pub fn update_worker_retention_policy(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
expected: u64,
|
||||
expected: &str,
|
||||
update: &WorkerRetentionPolicyUpdate,
|
||||
) -> Result<WorkerRetentionPolicy, WorkerRetentionError> {
|
||||
validate_policy(update)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let current=load_policy(&tx,workspace_id)?.ok_or_else(|| StoreError::InvalidInput(format!("policy-missing:{workspace_id}")))?;
|
||||
if current.revision!=expected { return Err(StoreError::InvalidInput(format!("policy-conflict:{expected}:{}",current.revision))); }
|
||||
let revision=current.revision+1; let now=Utc::now().to_rfc3339();
|
||||
tx.execute("INSERT INTO workspace_worker_retention_policy_revisions
|
||||
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",params![workspace_id,update.policy_id,revision,sess(update.session_disposition),meta(update.metadata_disposition),archive_kind(update.archive_retention),archive_seconds(update.archive_retention),diag(update.diagnostics_disposition),update.diagnostics_retention_seconds,now])?;
|
||||
let changed=tx.execute("UPDATE workspace_worker_retention_policies SET policy_id=?1,revision=?2,updated_at=?3 WHERE workspace_id=?4 AND revision=?5",
|
||||
params![update.policy_id,revision,now,workspace_id,expected])?;
|
||||
if changed!=1 { return Err(StoreError::InvalidInput(format!("policy-conflict:{expected}:{revision}"))); }
|
||||
if current.policy_digest != expected {
|
||||
return Err(StoreError::InvalidInput(format!("policy-conflict:{}", serde_json::to_string(&(expected, ¤t.policy_digest)).unwrap())));
|
||||
}
|
||||
let digest = worker_retention_policy_digest(update);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
tx.execute("INSERT OR IGNORE INTO workspace_worker_retention_policy_snapshots
|
||||
(workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",params![workspace_id,update.policy_id,digest,sess(update.session_disposition),meta(update.metadata_disposition),archive_kind(update.archive_retention),archive_seconds(update.archive_retention),diag(update.diagnostics_disposition),update.diagnostics_retention_seconds,now])?;
|
||||
let changed=tx.execute("UPDATE workspace_worker_retention_policies SET policy_id=?1,policy_digest=?2,updated_at=?3 WHERE workspace_id=?4 AND policy_digest=?5",
|
||||
params![update.policy_id,digest,now,workspace_id,expected])?;
|
||||
if changed!=1 { return Err(StoreError::InvalidInput(format!("policy-conflict:{}", serde_json::to_string(&(expected, ¤t.policy_digest)).unwrap()))); }
|
||||
tx.commit()?; load_policy(conn,workspace_id)?.ok_or_else(|| StoreError::InvalidInput("updated policy missing".into()))
|
||||
}).map_err(map_error)
|
||||
}
|
||||
@@ -243,7 +246,7 @@ impl SqliteWorkspaceStore {
|
||||
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
|
||||
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
|
||||
let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked};
|
||||
tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?20)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?;
|
||||
tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_updated_at,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?20)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,policy.policy_id,policy.policy_digest,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?;
|
||||
let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?;
|
||||
if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));}
|
||||
tx.commit()?; Ok(plan)
|
||||
@@ -270,14 +273,14 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
if !plan.blockers.is_empty(){return Err(StoreError::InvalidInput(format!("blocked:{}",serde_json::to_string(&plan.blockers).unwrap())));}
|
||||
let policy=load_policy(&tx,workspace_id)?.ok_or_else(||StoreError::InvalidInput(format!("policy-missing:{workspace_id}")))?;
|
||||
if policy.policy_id!=plan.policy_id||policy.revision!=plan.policy_revision{
|
||||
mark_stale(&tx,&plan,"policy revision changed")?;tx.commit()?;
|
||||
return Err(stale_error(&plan,"policy revision changed"));
|
||||
if policy.policy_id!=plan.policy_id||policy.policy_digest!=plan.policy_digest{
|
||||
mark_stale(&tx,&plan,"policy content changed")?;tx.commit()?;
|
||||
return Err(stale_error(&plan,"policy content changed"));
|
||||
}
|
||||
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput(format!("stale:{plan_id}:Worker missing")))?;
|
||||
if worker.updated_at!=plan.worker_revision{
|
||||
mark_stale(&tx,&plan,"Worker revision changed")?;tx.commit()?;
|
||||
return Err(stale_error(&plan,"Worker revision changed"));
|
||||
if worker.updated_at!=plan.worker_updated_at{
|
||||
mark_stale(&tx,&plan,"Worker updated_at changed")?;tx.commit()?;
|
||||
return Err(stale_error(&plan,"Worker updated_at changed"));
|
||||
}
|
||||
if worker.retention_state=="pinned"{
|
||||
mark_stale(&tx,&plan,"hold added")?;tx.commit()?;
|
||||
@@ -295,7 +298,7 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
|
||||
/// Revalidates Backend authority and derives the complete Runtime request
|
||||
/// from the immutable plan. Callers cannot substitute generation or
|
||||
/// from the immutable plan. Callers cannot substitute Worker timestamps or
|
||||
/// dispositions without causing a fingerprint/manifest mismatch.
|
||||
pub fn prepare_worker_removal_execution(
|
||||
&self,
|
||||
@@ -327,13 +330,11 @@ impl SqliteWorkspaceStore {
|
||||
workspace_id: plan.workspace_id.clone(),
|
||||
source_runtime_id: plan.worker.runtime_id.clone(),
|
||||
worker_id: worker_id,
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
source_created_at: worker.created_at,
|
||||
removed_at,
|
||||
effective_profile: worker.profile,
|
||||
retention_class: None,
|
||||
policy_id: plan.policy_id.clone(),
|
||||
policy_revision: plan.policy_revision,
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
},
|
||||
@@ -354,7 +355,7 @@ impl SqliteWorkspaceStore {
|
||||
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
|
||||
AND state IN ('planned','executing','failed','succeeded')
|
||||
AND (
|
||||
state='succeeded' OR worker_revision=(
|
||||
state='succeeded' OR worker_updated_at=(
|
||||
SELECT updated_at FROM worker_registry
|
||||
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
|
||||
)
|
||||
@@ -398,7 +399,6 @@ impl SqliteWorkspaceStore {
|
||||
workspace_id: plan.workspace_id.clone(),
|
||||
source_runtime_id: plan.worker.runtime_id.clone(),
|
||||
worker_id: worker_id,
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
source_created_at: worker
|
||||
.as_ref()
|
||||
.map(|worker| worker.created_at.clone())
|
||||
@@ -410,7 +410,6 @@ impl SqliteWorkspaceStore {
|
||||
.unwrap_or_else(|| Some("removed".to_string())),
|
||||
retention_class: None,
|
||||
policy_id: plan.policy_id.clone(),
|
||||
policy_revision: plan.policy_revision,
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
},
|
||||
@@ -458,8 +457,7 @@ impl SqliteWorkspaceStore {
|
||||
if plan.state != WorkerRemovalPlanState::Executing {
|
||||
return Err(StoreError::InvalidInput(format!("stale:{}:plan state {} is not committable", plan.plan_id, state_s(plan.state))));
|
||||
}
|
||||
if result.expected_worker_revision != plan.worker_revision
|
||||
|| result.worker_id.to_string() != plan.worker.worker_id
|
||||
if result.worker_id.to_string() != plan.worker.worker_id
|
||||
|| result.session_disposition != plan.session_disposition
|
||||
|| result.diagnostics_disposition != plan.diagnostics_disposition
|
||||
{
|
||||
@@ -467,15 +465,15 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
if !result.source_removed{return Err(StoreError::InvalidInput("Runtime source was not removed".into()));}
|
||||
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?;
|
||||
if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));}
|
||||
if worker.updated_at!=plan.worker_updated_at{return Err(StoreError::InvalidInput(format!("stale:{}:Worker updated_at changed",plan.plan_id)));}
|
||||
if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); }
|
||||
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
|
||||
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); }
|
||||
let now=Utc::now().to_rfc3339();
|
||||
if let Some(a)=&result.archive{
|
||||
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
|
||||
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.operation_id!=operation_id||a.input_fingerprint!=fp{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
|
||||
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
|
||||
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
|
||||
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_digest,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_digest,operation_id,now,expires_at])?;
|
||||
let observe_grants = {
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT grant_id, controller_runtime_id, controller_worker_id,
|
||||
@@ -529,15 +527,15 @@ impl SqliteWorkspaceStore {
|
||||
let seconds=plan.diagnostics_retention_seconds.ok_or_else(||StoreError::InvalidInput("diagnostics retention deadline missing".into()))?;
|
||||
let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("diagnostics retention deadline overflow".into()))?;
|
||||
let expires_at=(Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339();
|
||||
tx.execute("INSERT OR IGNORE INTO worker_diagnostics_archives(operation_id,workspace_id,runtime_id,worker_id,policy_id,policy_revision,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![operation_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.policy_id,plan.policy_revision,now,expires_at])?;
|
||||
tx.execute("INSERT OR IGNORE INTO worker_diagnostics_archives(operation_id,workspace_id,runtime_id,worker_id,policy_id,policy_digest,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![operation_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.policy_id,plan.policy_digest,now,expires_at])?;
|
||||
}
|
||||
DiagnosticsDisposition::Purge => {}
|
||||
}
|
||||
if plan.metadata_disposition==MetadataDisposition::Tombstone{
|
||||
tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_revision,operation_id])?;
|
||||
tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_digest,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_digest,operation_id])?;
|
||||
}
|
||||
tx.execute("UPDATE worker_workdir_links SET unlinked_at=?4 WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND unlinked_at IS NULL",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,now])?;
|
||||
let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_revision])?;
|
||||
let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_updated_at])?;
|
||||
tx.execute("UPDATE worker_create_reservations SET state='removed',updated_at=?4 WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND state IN ('reserved','created')",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,now])?;
|
||||
if deleted!=1{return Err(StoreError::InvalidInput(format!("stale:{}:removal fence changed",plan.plan_id)));}
|
||||
let catalog=commit_worker_catalog_removal(&tx,workspace_id,&plan.worker)?;
|
||||
@@ -657,7 +655,7 @@ impl SqliteWorkspaceStore {
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> crate::Result<Option<WorkerTombstone>> {
|
||||
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
|
||||
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_digest,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_digest:r.get(6)?,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
|
||||
}
|
||||
|
||||
pub fn worker_session_archive(
|
||||
@@ -670,7 +668,7 @@ impl SqliteWorkspaceStore {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT archive_id, checksum_sha256, content_bytes, policy_id,
|
||||
policy_revision, operation_id, committed_at, expires_at
|
||||
policy_digest, operation_id, committed_at, expires_at
|
||||
FROM worker_session_archives
|
||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||
AND session_id = ?4",
|
||||
@@ -689,7 +687,7 @@ impl SqliteWorkspaceStore {
|
||||
checksum_sha256: row.get(1)?,
|
||||
content_bytes: row.get::<_, i64>(2)? as u64,
|
||||
policy_id: row.get(3)?,
|
||||
policy_revision: row.get::<_, i64>(4)? as u64,
|
||||
policy_digest: row.get(4)?,
|
||||
operation_id: row.get(5)?,
|
||||
committed_at: row.get(6)?,
|
||||
expires_at: row.get(7)?,
|
||||
@@ -789,12 +787,12 @@ fn load_worker(c: &Connection, w: &str, r: &RuntimeWorkerRef) -> crate::Result<O
|
||||
}
|
||||
fn load_policy(c: &Connection, w: &str) -> crate::Result<Option<WorkerRetentionPolicy>> {
|
||||
c.query_row(
|
||||
"SELECT p.policy_id,p.revision,r.session_disposition,r.metadata_disposition,
|
||||
"SELECT p.policy_id,p.policy_digest,r.session_disposition,r.metadata_disposition,
|
||||
r.archive_retention_kind,r.archive_retention_seconds,
|
||||
r.diagnostics_disposition,r.diagnostics_retention_seconds,r.created_at,p.updated_at
|
||||
FROM workspace_worker_retention_policies p
|
||||
JOIN workspace_worker_retention_policy_revisions r
|
||||
ON r.workspace_id=p.workspace_id AND r.policy_id=p.policy_id AND r.revision=p.revision
|
||||
JOIN workspace_worker_retention_policy_snapshots r
|
||||
ON r.workspace_id=p.workspace_id AND r.policy_id=p.policy_id AND r.policy_digest=p.policy_digest
|
||||
WHERE p.workspace_id=?1",
|
||||
params![w],
|
||||
|row| {
|
||||
@@ -806,7 +804,7 @@ fn load_policy(c: &Connection, w: &str) -> crate::Result<Option<WorkerRetentionP
|
||||
Ok(WorkerRetentionPolicy {
|
||||
workspace_id: w.into(),
|
||||
policy_id: row.get(0)?,
|
||||
revision: row.get::<_, i64>(1)? as u64,
|
||||
policy_digest: row.get(1)?,
|
||||
session_disposition: parse_s(&session)?,
|
||||
metadata_disposition: parse_m(&metadata)?,
|
||||
archive_retention: parse_archive(&archive_kind, archive_seconds)?,
|
||||
@@ -829,7 +827,7 @@ fn load_plan_op(c: &Connection, id: &str) -> crate::Result<Option<WorkerRemovalP
|
||||
fn load_plan_q(c: &Connection, key: &str, id: &str) -> crate::Result<Option<WorkerRemovalPlan>> {
|
||||
let query = format!(
|
||||
"SELECT plan_id,operation_id,input_fingerprint,workspace_id,runtime_id,worker_id,
|
||||
worker_revision,policy_id,policy_revision,session_disposition,
|
||||
worker_updated_at,policy_id,policy_digest,session_disposition,
|
||||
metadata_disposition,archive_retention_kind,archive_retention_seconds,
|
||||
diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,
|
||||
state,reason,created_at,updated_at,failure_category
|
||||
@@ -852,9 +850,9 @@ fn load_plan_q(c: &Connection, key: &str, id: &str) -> crate::Result<Option<Work
|
||||
runtime_id: row.get(4)?,
|
||||
worker_id: row.get(5)?,
|
||||
},
|
||||
worker_revision: row.get(6)?,
|
||||
worker_updated_at: row.get(6)?,
|
||||
policy_id: row.get(7)?,
|
||||
policy_revision: row.get::<_, i64>(8)? as u64,
|
||||
policy_digest: row.get(8)?,
|
||||
session_disposition: parse_s(&session)?,
|
||||
metadata_disposition: parse_m(&metadata)?,
|
||||
archive_retention: parse_archive(&archive_kind, archive_seconds)?,
|
||||
@@ -891,7 +889,7 @@ fn stale_error(plan: &WorkerRemovalPlan, reason: &str) -> StoreError {
|
||||
}
|
||||
fn fingerprint(
|
||||
r: &WorkerRemovalPlanRequest,
|
||||
worker_revision: &str,
|
||||
worker_updated_at: &str,
|
||||
i: &WorkerRetentionInventory,
|
||||
p: &WorkerRetentionPolicy,
|
||||
b: &[WorkerRemovalBlocker],
|
||||
@@ -900,11 +898,11 @@ fn fingerprint(
|
||||
r.workspace_id,
|
||||
r.worker.runtime_id,
|
||||
r.worker.worker_id,
|
||||
worker_revision,
|
||||
worker_updated_at,
|
||||
i.session_id,
|
||||
i.segment_ids,
|
||||
p.policy_id,
|
||||
p.revision,
|
||||
p.policy_digest,
|
||||
p.session_disposition,
|
||||
p.metadata_disposition,
|
||||
p.archive_retention,
|
||||
@@ -916,6 +914,21 @@ fn fingerprint(
|
||||
.map(|v| hash(&v))
|
||||
.map_err(|e| StoreError::InvalidInput(e.to_string()))
|
||||
}
|
||||
/// Content identity of the complete resolved policy, excluding observation timestamps.
|
||||
/// The ordered tuple is shared with schema seeding and migration; no update counter is used.
|
||||
pub fn worker_retention_policy_digest(policy: &WorkerRetentionPolicyUpdate) -> String {
|
||||
let bytes = serde_json::to_vec(&serde_json::json!([
|
||||
policy.policy_id,
|
||||
policy.session_disposition,
|
||||
policy.metadata_disposition,
|
||||
policy.archive_retention,
|
||||
policy.diagnostics_disposition,
|
||||
policy.diagnostics_retention_seconds,
|
||||
]))
|
||||
.expect("retention policy content is serializable");
|
||||
format!("sha256:{}", hash(&bytes))
|
||||
}
|
||||
|
||||
fn hash(b: &[u8]) -> String {
|
||||
Sha256::digest(b)
|
||||
.iter()
|
||||
@@ -977,11 +990,8 @@ fn map_error(e: StoreError) -> WorkerRetentionError {
|
||||
};
|
||||
}
|
||||
if let Some(x) = m.strip_prefix("policy-conflict:") {
|
||||
let mut s = x.split(':');
|
||||
return WorkerRetentionError::PolicyRevisionConflict {
|
||||
expected: s.next().and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
actual: s.next().and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
};
|
||||
let (expected, actual) = serde_json::from_str::<(String, String)>(x).unwrap_or_default();
|
||||
return WorkerRetentionError::PolicyDigestConflict { expected, actual };
|
||||
}
|
||||
if m == "cross-workspace" {
|
||||
return WorkerRetentionError::CrossWorkspace;
|
||||
@@ -1121,7 +1131,7 @@ mod tests {
|
||||
c.execute(
|
||||
"INSERT INTO worker_registry(\
|
||||
workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at\
|
||||
) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')",
|
||||
) VALUES('w',?1,'r','one','builtin:coder','normal','created','2026-08-24T00:00:00Z')",
|
||||
[worker_id().to_string()],
|
||||
)?;
|
||||
c.execute(
|
||||
@@ -1169,24 +1179,65 @@ mod tests {
|
||||
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
||||
diagnostics_retention_seconds: None,
|
||||
};
|
||||
let updated = s.update_worker_retention_policy("w", 1, &u).unwrap();
|
||||
assert_eq!(updated.revision, 2);
|
||||
let updated = s
|
||||
.update_worker_retention_policy("w", &p.policy_digest, &u)
|
||||
.unwrap();
|
||||
assert_eq!(updated.policy_digest, worker_retention_policy_digest(&u));
|
||||
assert_eq!(
|
||||
updated.archive_retention,
|
||||
ArchiveRetention::ForSeconds { seconds: 3_600 }
|
||||
);
|
||||
assert!(matches!(
|
||||
s.update_worker_retention_policy("w", 1, &u),
|
||||
Err(WorkerRetentionError::PolicyRevisionConflict { .. })
|
||||
s.update_worker_retention_policy("w", &p.policy_digest, &u),
|
||||
Err(WorkerRetentionError::PolicyDigestConflict { .. })
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn identical_policy_content_keeps_digest_and_removal_plan() {
|
||||
let store = setup();
|
||||
let policy = store.worker_retention_policy("w").unwrap().unwrap();
|
||||
let update = WorkerRetentionPolicyUpdate {
|
||||
policy_id: policy.policy_id.clone(),
|
||||
session_disposition: policy.session_disposition,
|
||||
metadata_disposition: policy.metadata_disposition,
|
||||
archive_retention: policy.archive_retention,
|
||||
diagnostics_disposition: policy.diagnostics_disposition,
|
||||
diagnostics_retention_seconds: policy.diagnostics_retention_seconds,
|
||||
};
|
||||
assert_eq!(
|
||||
policy.policy_digest,
|
||||
worker_retention_policy_digest(&update)
|
||||
);
|
||||
let before = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
let after = store
|
||||
.update_worker_retention_policy("w", &policy.policy_digest, &update)
|
||||
.unwrap();
|
||||
assert_eq!(policy.policy_digest, after.policy_digest);
|
||||
assert_eq!(before, store.plan_worker_removal(&req(), &inv()).unwrap());
|
||||
store
|
||||
.begin_worker_removal("w", &before.plan_id, &before.input_fingerprint)
|
||||
.unwrap();
|
||||
let mut changed = update.clone();
|
||||
changed.metadata_disposition = MetadataDisposition::Purge;
|
||||
assert_ne!(
|
||||
worker_retention_policy_digest(&update),
|
||||
worker_retention_policy_digest(&changed)
|
||||
);
|
||||
changed = update.clone();
|
||||
changed.archive_retention = ArchiveRetention::ForSeconds { seconds: 3600 };
|
||||
assert_ne!(
|
||||
worker_retention_policy_digest(&update),
|
||||
worker_retention_policy_digest(&changed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_plan_hold_and_cross_workspace() {
|
||||
let s = setup();
|
||||
let a = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
let b = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
assert_eq!(a.plan_id, b.plan_id);
|
||||
assert_eq!(a.worker_revision, "rev1");
|
||||
assert_eq!(a.worker_updated_at, "2026-08-24T00:00:00Z");
|
||||
s.with_conn(|c| {
|
||||
c.execute(
|
||||
"UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w'",
|
||||
@@ -1226,7 +1277,15 @@ mod tests {
|
||||
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
||||
diagnostics_retention_seconds: None,
|
||||
};
|
||||
s.update_worker_retention_policy("w", 1, &u).unwrap();
|
||||
s.update_worker_retention_policy(
|
||||
"w",
|
||||
&s.worker_retention_policy("w")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.policy_digest,
|
||||
&u,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint),
|
||||
Err(WorkerRetentionError::StalePlan { .. })
|
||||
@@ -1295,7 +1354,10 @@ mod tests {
|
||||
prepared.runtime_request.session_disposition,
|
||||
SessionDisposition::Archive
|
||||
);
|
||||
assert_eq!(prepared.runtime_request.policy_revision, 1);
|
||||
assert_eq!(
|
||||
prepared.runtime_request.input_fingerprint,
|
||||
prepared.plan.input_fingerprint
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.runtime_request.worker_id,
|
||||
WorkerId::from_legacy_u64(1)
|
||||
@@ -1337,7 +1399,7 @@ mod tests {
|
||||
.unwrap();
|
||||
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 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','2026-08-24T00:00:00Z')", [])?;
|
||||
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?;
|
||||
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'", [])?;
|
||||
@@ -1350,7 +1412,7 @@ mod tests {
|
||||
let result = WorkerRetentionExecutionResult {
|
||||
operation_id: p.operation_id.clone(),
|
||||
input_fingerprint: p.input_fingerprint.clone(),
|
||||
expected_worker_revision: p.worker_revision.clone(),
|
||||
|
||||
worker_id: worker_id(),
|
||||
session_disposition: p.session_disposition,
|
||||
diagnostics_disposition: p.diagnostics_disposition,
|
||||
@@ -1371,7 +1433,7 @@ mod tests {
|
||||
content_bytes: 1,
|
||||
content_file_count: 1,
|
||||
policy_id: p.policy_id.clone(),
|
||||
policy_revision: p.policy_revision,
|
||||
|
||||
operation_id: p.operation_id.clone(),
|
||||
input_fingerprint: p.input_fingerprint.clone(),
|
||||
}),
|
||||
@@ -1608,7 +1670,11 @@ mod tests {
|
||||
store
|
||||
.update_worker_retention_policy(
|
||||
"w",
|
||||
1,
|
||||
&store
|
||||
.worker_retention_policy("w")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.policy_digest,
|
||||
&WorkerRetentionPolicyUpdate {
|
||||
policy_id: "purge".into(),
|
||||
session_disposition: SessionDisposition::Purge,
|
||||
@@ -1630,7 +1696,6 @@ mod tests {
|
||||
let result = WorkerRetentionExecutionResult {
|
||||
operation_id: plan.operation_id.clone(),
|
||||
input_fingerprint: plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
worker_id: worker_id(),
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
@@ -1817,7 +1882,7 @@ mod tests {
|
||||
let result = WorkerRetentionExecutionResult {
|
||||
operation_id: plan.operation_id.clone(),
|
||||
input_fingerprint: plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
|
||||
worker_id: worker_id(),
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
@@ -1897,7 +1962,7 @@ mod tests {
|
||||
let result = WorkerRetentionExecutionResult {
|
||||
operation_id: plan.operation_id.clone(),
|
||||
input_fingerprint: plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
|
||||
worker_id: worker_id(),
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
@@ -1949,14 +2014,22 @@ mod tests {
|
||||
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
||||
diagnostics_retention_seconds: None,
|
||||
};
|
||||
s.update_worker_retention_policy("w", 1, &u).unwrap();
|
||||
s.update_worker_retention_policy(
|
||||
"w",
|
||||
&s.worker_retention_policy("w")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.policy_digest,
|
||||
&u,
|
||||
)
|
||||
.unwrap();
|
||||
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint)
|
||||
.unwrap();
|
||||
let r = WorkerRetentionExecutionResult {
|
||||
operation_id: p.operation_id.clone(),
|
||||
input_fingerprint: p.input_fingerprint.clone(),
|
||||
expected_worker_revision: p.worker_revision.clone(),
|
||||
|
||||
worker_id: worker_id(),
|
||||
session_disposition: SessionDisposition::Purge,
|
||||
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
||||
@@ -1980,7 +2053,15 @@ mod tests {
|
||||
diagnostics_retention_seconds: None,
|
||||
};
|
||||
store
|
||||
.update_worker_retention_policy("w", 1, &purge)
|
||||
.update_worker_retention_policy(
|
||||
"w",
|
||||
&store
|
||||
.worker_retention_policy("w")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.policy_digest,
|
||||
&purge,
|
||||
)
|
||||
.unwrap();
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
store
|
||||
@@ -1989,7 +2070,7 @@ mod tests {
|
||||
let result = WorkerRetentionExecutionResult {
|
||||
operation_id: plan.operation_id.clone(),
|
||||
input_fingerprint: plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
|
||||
worker_id: worker_id(),
|
||||
session_disposition: SessionDisposition::Purge,
|
||||
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
||||
@@ -2067,7 +2148,7 @@ mod tests {
|
||||
let mut result = WorkerRetentionExecutionResult {
|
||||
operation_id: plan.operation_id.clone(),
|
||||
input_fingerprint: plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
|
||||
worker_id: worker_id(),
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
@@ -2117,15 +2198,15 @@ mod tests {
|
||||
summary_ref: None,
|
||||
diagnostics_ref: None,
|
||||
created_at: "created".into(),
|
||||
updated_at: "rev2".into(),
|
||||
updated_at: "2026-08-24T00:00:01Z".into(),
|
||||
};
|
||||
store.upsert_worker_registry(&stale).unwrap();
|
||||
let revision: String = store.with_conn(|conn| conn.query_row(
|
||||
let updated_at: String = store.with_conn(|conn| conn.query_row(
|
||||
"SELECT updated_at FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
|
||||
[worker_id().to_string()],
|
||||
|row| row.get(0),
|
||||
).map_err(StoreError::from)).unwrap();
|
||||
assert_eq!(revision, "rev1");
|
||||
assert_eq!(updated_at, "2026-08-24T00:00:00Z");
|
||||
|
||||
let assignment = TicketWorkerAssignmentRecord {
|
||||
workspace_id: "w".into(),
|
||||
@@ -2152,7 +2233,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_result_must_match_prepared_worker_revision() {
|
||||
fn runtime_result_must_match_prepared_input_fingerprint() {
|
||||
let s = setup();
|
||||
let plan = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
let prepared = s
|
||||
@@ -2161,7 +2242,7 @@ mod tests {
|
||||
let mut runtime_result = WorkerRetentionExecutionResult {
|
||||
operation_id: prepared.plan.operation_id.clone(),
|
||||
input_fingerprint: prepared.plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: prepared.plan.worker_revision.clone(),
|
||||
|
||||
worker_id: worker_id(),
|
||||
session_disposition: prepared.plan.session_disposition,
|
||||
diagnostics_disposition: prepared.plan.diagnostics_disposition,
|
||||
@@ -2169,7 +2250,7 @@ mod tests {
|
||||
source_removed: true,
|
||||
diagnostics_retained: false,
|
||||
};
|
||||
runtime_result.expected_worker_revision = "stale-revision".to_string();
|
||||
runtime_result.input_fingerprint = "stale-input-fingerprint".to_string();
|
||||
let error = s
|
||||
.commit_worker_removal(
|
||||
"w",
|
||||
@@ -2180,7 +2261,7 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
!error.to_string().is_empty(),
|
||||
"mismatched Runtime revision must be rejected"
|
||||
"mismatched Runtime input fingerprint must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2237,7 +2318,7 @@ mod tests {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE worker_registry SET updated_at='rev2' WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
|
||||
"UPDATE worker_registry SET updated_at='2026-08-24T00:00:01Z' WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
|
||||
[worker_id().to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -2251,7 +2332,7 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
let replacement = store.plan_worker_removal(&request, &inv()).unwrap();
|
||||
assert_eq!(replacement.worker_revision, "rev2");
|
||||
assert_eq!(replacement.worker_updated_at, "2026-08-24T00:00:01Z");
|
||||
assert_ne!(replacement.plan_id, plan.plan_id);
|
||||
}
|
||||
|
||||
@@ -2266,7 +2347,7 @@ mod tests {
|
||||
let runtime_result = WorkerRetentionExecutionResult {
|
||||
operation_id: prepared.plan.operation_id.clone(),
|
||||
input_fingerprint: prepared.plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: prepared.plan.worker_revision.clone(),
|
||||
|
||||
worker_id: worker_id(),
|
||||
session_disposition: prepared.plan.session_disposition,
|
||||
diagnostics_disposition: prepared.plan.diagnostics_disposition,
|
||||
@@ -2287,7 +2368,7 @@ mod tests {
|
||||
content_bytes: 1,
|
||||
content_file_count: 1,
|
||||
policy_id: prepared.plan.policy_id.clone(),
|
||||
policy_revision: prepared.plan.policy_revision,
|
||||
|
||||
operation_id: prepared.plan.operation_id.clone(),
|
||||
input_fingerprint: prepared.plan.input_fingerprint.clone(),
|
||||
}),
|
||||
@@ -2332,8 +2413,8 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Succeeded);
|
||||
assert_eq!(
|
||||
recovered.runtime_request.expected_worker_revision,
|
||||
plan.worker_revision
|
||||
recovered.runtime_request.input_fingerprint,
|
||||
plan.input_fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ impl WorkspaceConfigSchemaProvider for RuntimeConfigSchemaProvider {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeConfigProjection {
|
||||
pub config_revision: u64,
|
||||
pub projection_digest: String,
|
||||
pub default_runtime_id: Option<String>,
|
||||
}
|
||||
@@ -52,7 +51,6 @@ pub fn project_runtime_from_workspace_config(
|
||||
.any(|entry| entry.provider_id == "builtin:runtime");
|
||||
if !has_runtime_schema {
|
||||
return Ok(RuntimeConfigProjection {
|
||||
config_revision: state.snapshot.revision,
|
||||
projection_digest: state.projection_digest.clone(),
|
||||
default_runtime_id: None,
|
||||
});
|
||||
@@ -71,7 +69,6 @@ pub fn project_runtime_from_workspace_config(
|
||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
||||
let default_runtime_id = normalize_runtime_id(&config.runtime.default_runtime_id)?;
|
||||
Ok(RuntimeConfigProjection {
|
||||
config_revision: state.snapshot.revision,
|
||||
projection_digest: evaluation.projection_digest,
|
||||
default_runtime_id,
|
||||
})
|
||||
@@ -103,15 +100,12 @@ mod tests {
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()],
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let contract = config_source::ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
@@ -131,7 +125,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_projection_reads_default_and_preserves_revision_evidence() {
|
||||
fn runtime_projection_reads_default_and_preserves_content_evidence() {
|
||||
let projection = project_runtime_from_workspace_config(
|
||||
"workspace",
|
||||
&state(
|
||||
@@ -140,10 +134,48 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projection.default_runtime_id.as_deref(), Some("arcadia"));
|
||||
assert_eq!(projection.config_revision, 7);
|
||||
assert!(!projection.projection_digest.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_projection_digest_tracks_content_not_observation_order() {
|
||||
let first =
|
||||
state(r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#);
|
||||
let same =
|
||||
state(r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#);
|
||||
let changed =
|
||||
state(r#"{ runtime = { default_runtime_id = "other"; }; } as WorkspaceConfigSchema"#);
|
||||
let first_projection = project_runtime_from_workspace_config("workspace", &first).unwrap();
|
||||
assert_eq!(
|
||||
first_projection,
|
||||
project_runtime_from_workspace_config("workspace", &same).unwrap()
|
||||
);
|
||||
let changed_projection =
|
||||
project_runtime_from_workspace_config("workspace", &changed).unwrap();
|
||||
assert_ne!(
|
||||
first_projection.projection_digest,
|
||||
changed_projection.projection_digest
|
||||
);
|
||||
assert_eq!(
|
||||
changed_projection.default_runtime_id.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
assert_eq!(
|
||||
first_projection,
|
||||
project_runtime_from_workspace_config("workspace", &first).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_projection_rejects_mismatched_content_evidence() {
|
||||
let mut active = state("{} as WorkspaceConfigSchema");
|
||||
active.projection_digest = "sha256:tampered".to_string();
|
||||
assert!(matches!(
|
||||
project_runtime_from_workspace_config("workspace", &active),
|
||||
Err(Error::RegistryInconsistency(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_projection_treats_missing_default_as_unconfigured() {
|
||||
let projection = project_runtime_from_workspace_config(
|
||||
@@ -165,15 +197,12 @@ mod tests {
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
6,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{ legacy = { enabled = true; }; } as WorkspaceConfigSchema",
|
||||
)
|
||||
.unwrap()],
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{ legacy = { enabled = true; }; } as WorkspaceConfigSchema",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let contract = config_source::ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
@@ -193,7 +222,6 @@ mod tests {
|
||||
|
||||
let projection = project_runtime_from_workspace_config("workspace", &state).unwrap();
|
||||
assert_eq!(projection.default_runtime_id, None);
|
||||
assert_eq!(projection.config_revision, 6);
|
||||
assert_eq!(projection.projection_digest, projection_digest);
|
||||
}
|
||||
|
||||
@@ -204,15 +232,12 @@ mod tests {
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema",
|
||||
)
|
||||
.unwrap()],
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let contract = config_source::ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
|
||||
@@ -70,7 +70,6 @@ fn create_request(name: &str) -> CreateWorkerRequest {
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Included in store::tests so both paths use the real retained migration chain.
|
||||
#[test]
|
||||
fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
||||
fn signature(conn: &Connection, table: &str) -> Vec<Vec<String>> {
|
||||
let queries = [
|
||||
"SELECT name, type, CAST(\"notnull\" AS TEXT), COALESCE(dflt_value, '<NULL>'), CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name",
|
||||
"SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) ORDER BY \"table\", \"from\", \"to\", seq",
|
||||
"SELECT CAST(i.\"unique\" AS TEXT), CAST(i.partial AS TEXT), (SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1, 2, 3",
|
||||
"SELECT name, sql FROM sqlite_schema WHERE type='trigger' AND tbl_name=?1 ORDER BY name",
|
||||
];
|
||||
queries
|
||||
.into_iter()
|
||||
.flat_map(|sql| {
|
||||
let mut statement = conn.prepare(sql).unwrap();
|
||||
let count = statement.column_count();
|
||||
statement
|
||||
.query_map([table], |row| {
|
||||
(0..count)
|
||||
.map(|i| row.get::<_, String>(i))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.map(|row| {
|
||||
row.unwrap()
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '"')
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("upgraded.db");
|
||||
prepare_retained_schema(&path, 85);
|
||||
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let mut mismatches = Vec::new();
|
||||
for table in [
|
||||
"workspace_config_trees",
|
||||
"workspace_config_tree_history",
|
||||
"workspace_memory_settings",
|
||||
"worker_create_reservations",
|
||||
"backend_jobs",
|
||||
"backend_job_attempts",
|
||||
"flow_sources",
|
||||
"flow_source_contents",
|
||||
"repositories",
|
||||
"workspace_runtime_bindings",
|
||||
"workspace_runtime_verifications",
|
||||
"workspace_runtime_binding_audit",
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
"workspace_signing_identity_audit",
|
||||
"runtime_removal_operations",
|
||||
"workdir_removal_operations",
|
||||
"workspace_deletion_operations",
|
||||
"workspace_worker_retention_policies",
|
||||
"workspace_worker_retention_policy_snapshots",
|
||||
"worker_removal_operations",
|
||||
"worker_session_archives",
|
||||
"worker_diagnostics_archives",
|
||||
"worker_tombstones",
|
||||
"repository_secret_audit_events",
|
||||
"repository_secret_operations",
|
||||
"repository_ssh_credential_keys",
|
||||
"repository_ssh_credentials",
|
||||
"repository_ssh_host_trust_keys",
|
||||
"repository_ssh_host_trusts",
|
||||
"server_secret_objects",
|
||||
"workdir_create_operations",
|
||||
"workdir_create_credential_candidates",
|
||||
"workdir_create_credential_retentions",
|
||||
"workdir_create_legacy_ssh_archives",
|
||||
"repository_secret_legacy_receipts",
|
||||
] {
|
||||
let expected = fresh.with_conn(|conn| Ok(signature(conn, table))).unwrap();
|
||||
let actual = upgraded
|
||||
.with_conn(|conn| Ok(signature(conn, table)))
|
||||
.unwrap();
|
||||
assert!(!expected.is_empty(), "missing canonical table {table}");
|
||||
if actual != expected {
|
||||
mismatches.push(format!(
|
||||
"{table}:\n upgraded-only: {:?}\n fresh-only: {:?}",
|
||||
actual
|
||||
.iter()
|
||||
.filter(|row| !expected.contains(row))
|
||||
.collect::<Vec<_>>(),
|
||||
expected
|
||||
.iter()
|
||||
.filter(|row| !actual.contains(row))
|
||||
.collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
|
||||
}
|
||||
+1146
-625
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ pub(super) fn active_grant(
|
||||
|| attempt.state != BackendJobAttemptState::Dispatched
|
||||
|| attempt.worker.as_ref() != Some(worker)
|
||||
|| job.current_attempt != attempt.attempt
|
||||
|| attempt.input_revision != job.request.input_revision
|
||||
|| attempt.input_digest != job.request.input_digest()?
|
||||
|| chrono::DateTime::parse_from_rfc3339(&attempt.deadline_at)
|
||||
.map_err(|e| Error::Store(format!("invalid Job deadline: {e}")))?
|
||||
<= Utc::now()
|
||||
@@ -161,7 +161,7 @@ pub(super) fn dispatch(
|
||||
total_bytes: u64,
|
||||
) -> ApiResult<MemoryConsolidationOutput> {
|
||||
let store = open_subjektiv_store(api)?;
|
||||
let subject = store
|
||||
store
|
||||
.subject(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.ok_or_else(|| Error::SubjektivSubjectNotFound(subject_id.into()))?;
|
||||
@@ -177,7 +177,6 @@ pub(super) fn dispatch(
|
||||
let request = BackendJobRequest {
|
||||
job_id: format!("subjektiv-consolidation:{}", Uuid::new_v4()),
|
||||
purpose: PURPOSE.into(),
|
||||
input_revision: subject.store_revision.to_string(),
|
||||
input_ref: format!("subjektiv://{subject_id}/consolidation"),
|
||||
input: serde_json::json!({"subject_id": subject_id, "candidate_ids": candidate_ids}),
|
||||
instruction: "Resolve ONLY the immutable candidate_ids batch using MemoryStagingList/Read, confirmed Memory reads and MemoryApplyCandidate. Already resolved candidates are retained receipts, not work to repeat. Leave newly arriving candidates to a subsequent Job. When every batch candidate has a durable disposition, call SubmitBackendJobResult with {subject_id, candidate_ids}; its Host capability awaits bounded clean-context surface generation and adds the actual surface outcome before submission. Final prose is not completion.".into(),
|
||||
|
||||
@@ -106,7 +106,7 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au
|
||||
let create = json!({"operation":"create_text","parent":reference(&root),"name":"日本語.md","text":"# Shared\n","content_type":"text/markdown"});
|
||||
let first = mutate(&api, &token, "create", create.clone(), StatusCode::OK).await;
|
||||
let id = first["entry"]["entry"]["node_id"].as_str().unwrap();
|
||||
let rev = first["entry"]["revision"].as_str().unwrap();
|
||||
let rev = first["entry"]["last_mutation_id"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
first,
|
||||
mutate(&api, &token, "create", create.clone(), StatusCode::OK).await,
|
||||
@@ -137,7 +137,7 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au
|
||||
)
|
||||
.await;
|
||||
assert_eq!(text["text"], "# Shared\n");
|
||||
let wrong=mutate(&api,&token,"foreign",json!({"operation":"delete","id":{"workspace_id":"foreign","node_id":id},"expected_revision":rev}),StatusCode::FORBIDDEN).await;
|
||||
let wrong=mutate(&api,&token,"foreign",json!({"operation":"delete","id":{"workspace_id":"foreign","node_id":id},"expected_mutation_id":rev}),StatusCode::FORBIDDEN).await;
|
||||
assert_eq!(wrong["code"], "denied");
|
||||
user_call(
|
||||
&api,
|
||||
@@ -156,19 +156,19 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
let updated=mutate(&api,&token,"update",json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"new","content_type":"text/plain"}),StatusCode::OK).await;
|
||||
let updated=mutate(&api,&token,"update",json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"new","content_type":"text/plain"}),StatusCode::OK).await;
|
||||
for operation in [
|
||||
json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"lost","content_type":"text/plain"}),
|
||||
json!({"operation":"delete","id":reference(id),"expected_revision":rev}),
|
||||
json!({"operation":"relocate","id":reference(id),"expected_revision":rev,"parent":reference(&root),"name":"lost"}),
|
||||
json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"lost","content_type":"text/plain"}),
|
||||
json!({"operation":"delete","id":reference(id),"expected_mutation_id":rev}),
|
||||
json!({"operation":"relocate","id":reference(id),"expected_mutation_id":rev,"parent":reference(&root),"name":"lost"}),
|
||||
] {
|
||||
mutate(&api, &token, "stale", operation, StatusCode::CONFLICT).await;
|
||||
}
|
||||
user_call(&api,&token,"GET",&format!("{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_revision={rev}&offset=0&length=1",base()),None,StatusCode::CONFLICT).await;
|
||||
let renamed=mutate(&api,&token,"rename",json!({"operation":"relocate","id":reference(id),"expected_revision":updated["entry"]["revision"],"parent":reference(&root),"name":"renamed.md"}),StatusCode::OK).await;
|
||||
user_call(&api,&token,"GET",&format!("{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_mutation_id={rev}&offset=0&length=1",base()),None,StatusCode::CONFLICT).await;
|
||||
let renamed=mutate(&api,&token,"rename",json!({"operation":"relocate","id":reference(id),"expected_mutation_id":updated["entry"]["last_mutation_id"],"parent":reference(&root),"name":"renamed.md"}),StatusCode::OK).await;
|
||||
// URL is independent of name/parent; metadata lookup still resolves the same ID.
|
||||
assert_eq!(renamed["entry"]["entry"], first["entry"]["entry"]);
|
||||
let deleted=mutate(&api,&token,"delete",json!({"operation":"delete","id":reference(id),"expected_revision":renamed["entry"]["revision"]}),StatusCode::OK).await;
|
||||
let deleted=mutate(&api,&token,"delete",json!({"operation":"delete","id":reference(id),"expected_mutation_id":renamed["entry"]["last_mutation_id"]}),StatusCode::OK).await;
|
||||
assert!(deleted["entry"].is_null());
|
||||
user_call(
|
||||
&api,
|
||||
@@ -536,7 +536,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real
|
||||
parent_id: Some(root.entry.node_id.clone()),
|
||||
name: Some(request.into()),
|
||||
id: None,
|
||||
expected_revision: None,
|
||||
expected_mutation_id: None,
|
||||
content_type: media.into(),
|
||||
size: bytes.len() as u32,
|
||||
sha256: digest(bytes),
|
||||
@@ -552,7 +552,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real
|
||||
DriveDownloadQuery {
|
||||
entry_workspace_id: workspace.clone(),
|
||||
id: saved.entry.node_id.clone(),
|
||||
expected_revision: Some(saved.revision.clone()),
|
||||
expected_mutation_id: Some(saved.last_mutation_id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -574,7 +574,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real
|
||||
DriveReadChunkQuery {
|
||||
entry_workspace_id: workspace.clone(),
|
||||
id: saved.entry.node_id,
|
||||
expected_revision: saved.revision,
|
||||
expected_mutation_id: saved.last_mutation_id,
|
||||
offset: 0,
|
||||
length: 65536,
|
||||
},
|
||||
@@ -770,7 +770,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
|
||||
let root = root_id(&api, &owner).await;
|
||||
let saved=mutate(&api,&owner,"matrix-file",json!({"operation":"create_text","parent":reference(&root),"name":"matrix","text":"bytes","content_type":"text/plain"}),StatusCode::OK).await;
|
||||
let id = saved["entry"]["entry"]["node_id"].as_str().unwrap();
|
||||
let rev = saved["entry"]["revision"].as_str().unwrap();
|
||||
let rev = saved["entry"]["last_mutation_id"].as_str().unwrap();
|
||||
let (_runtime, workers) = workers(&api).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id);
|
||||
@@ -790,7 +790,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
|
||||
base()
|
||||
),
|
||||
format!(
|
||||
"{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_revision={rev}&offset=0&length=4",
|
||||
"{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_mutation_id={rev}&offset=0&length=4",
|
||||
base()
|
||||
),
|
||||
format!(
|
||||
@@ -885,9 +885,9 @@ async fn drive_concurrent_http_cas_has_one_winner_and_name_search_pages_are_scop
|
||||
let root = root_id(&api, &token).await;
|
||||
let saved=mutate(&api,&token,"concurrent-file",json!({"operation":"create_text","parent":reference(&root),"name":"candidate","text":"find me","content_type":"text/plain"}),StatusCode::OK).await;
|
||||
let id = &saved["entry"]["entry"]["node_id"];
|
||||
let rev = &saved["entry"]["revision"];
|
||||
let rev = &saved["entry"]["last_mutation_id"];
|
||||
let request = |identity: &str, text: &str| {
|
||||
Request::builder().method("POST").uri(format!("{}/mutate",base())).header("authorization",format!("Bearer {token}")).header(CONTENT_TYPE,"application/json").body(Body::from(json!({"request_id":identity,"mutation":{"operation":"update_text","id":reference(id.as_str().unwrap()),"expected_revision":rev,"text":text,"content_type":"text/plain"}}).to_string())).unwrap()
|
||||
Request::builder().method("POST").uri(format!("{}/mutate",base())).header("authorization",format!("Bearer {token}")).header(CONTENT_TYPE,"application/json").body(Body::from(json!({"request_id":identity,"mutation":{"operation":"update_text","id":reference(id.as_str().unwrap()),"expected_mutation_id":rev,"text":text,"content_type":"text/plain"}}).to_string())).unwrap()
|
||||
};
|
||||
let app = build_router(api.clone());
|
||||
let (a, b) = tokio::join!(
|
||||
@@ -955,7 +955,7 @@ async fn drive_concurrent_http_cas_has_one_winner_and_name_search_pages_are_scop
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_download_stream_expires_instead_of_mixing_revisions() {
|
||||
async fn drive_download_stream_expires_instead_of_mixing_last_mutation_ids() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
@@ -981,7 +981,7 @@ async fn drive_download_stream_expires_instead_of_mixing_revisions() {
|
||||
.unwrap();
|
||||
let saved = response_json(response, StatusCode::OK).await;
|
||||
let id = saved["entry"]["entry"]["node_id"].as_str().unwrap();
|
||||
let rev = saved["entry"]["revision"].as_str().unwrap();
|
||||
let rev = saved["entry"]["last_mutation_id"].as_str().unwrap();
|
||||
let response = build_router(api.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -996,7 +996,7 @@ async fn drive_download_stream_expires_instead_of_mixing_revisions() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
mutate(&api,&token,"stream-update",json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"generation b","content_type":"text/plain"}),StatusCode::OK).await;
|
||||
mutate(&api,&token,"stream-update",json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"generation b","content_type":"text/plain"}),StatusCode::OK).await;
|
||||
let mut stream = response.into_body().into_data_stream();
|
||||
let first = stream.next().await.unwrap().unwrap();
|
||||
assert_eq!(first.as_ref(), vec![b'a'; 64 * 1024]);
|
||||
|
||||
@@ -57,7 +57,7 @@ async fn cached_drive_handle_rejects_mutation_after_deletion_reservation() {
|
||||
&config.workspace_id,
|
||||
&WorkspaceDeletionRequest {
|
||||
operation_id: "delete-cached-drive".into(),
|
||||
expected_revision: preflight.expected_revision,
|
||||
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
|
||||
confirmation: preflight.display_name,
|
||||
},
|
||||
)
|
||||
@@ -137,7 +137,7 @@ async fn deleting_workspace_reopens_fenced_drive_and_purges_blobs_before_feature
|
||||
&config.workspace_id,
|
||||
&WorkspaceDeletionRequest {
|
||||
operation_id: "delete-drive-workspace".into(),
|
||||
expected_revision: preflight.expected_revision,
|
||||
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
|
||||
confirmation: preflight.display_name,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -110,11 +110,12 @@ impl RealDriveFixture {
|
||||
base_url: "https://runtime.example.invalid".into(),
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
binding_id: "binding-drive-test".to_string(),
|
||||
state: StoredRuntimeBindingState::Verified,
|
||||
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_key_generation: None,
|
||||
workspace_public_key_fingerprint: None,
|
||||
workspace_trust_id: None,
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
revoked_at: None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use crate::subjektiv::{
|
||||
CandidateDecision, CandidateDecisionRequest, MemoryDraft, MemoryRevisionTarget, SubjectRole,
|
||||
CandidateDecision, CandidateDecisionRequest, MemoryChangeTarget, MemoryDraft, SubjectRole,
|
||||
SubjectStagingRecord, SurfaceAvailability,
|
||||
};
|
||||
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence, StagingRecord};
|
||||
@@ -16,7 +16,7 @@ fn stage(store: &crate::subjektiv::SubjektivStore, subject: &str, id: &str) {
|
||||
worker_id: None,
|
||||
flow_selector: None,
|
||||
flow_definition_id: None,
|
||||
flow_definition_revision: None,
|
||||
flow_definition_fingerprint: None,
|
||||
};
|
||||
let record = StagingRecord::from_candidate(
|
||||
id,
|
||||
@@ -173,7 +173,7 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup()
|
||||
candidate_id: "candidate-first".into(),
|
||||
reason: "retain the learned constraint".into(),
|
||||
decision: CandidateDecision::Apply {
|
||||
target: MemoryRevisionTarget::Create,
|
||||
target: MemoryChangeTarget::Create,
|
||||
draft: MemoryDraft::active(
|
||||
CandidateKind::Constraint,
|
||||
"Keep committed memory",
|
||||
@@ -191,7 +191,7 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup()
|
||||
receipt.memory.as_ref().unwrap().id,
|
||||
replay.memory.as_ref().unwrap().id
|
||||
);
|
||||
assert_eq!(receipt.store_revision, replay.store_revision);
|
||||
assert_eq!(receipt.memory_fingerprint, replay.memory_fingerprint);
|
||||
let generation = store
|
||||
.prepare_job_surface_generation(
|
||||
&subject.id,
|
||||
@@ -211,13 +211,13 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(failed, server_api::SubjektivMemoryBackendResponse::SurfaceFailed(output) if output.status == "failed_confirmed" && output.store_revision == generation.store_revision)
|
||||
matches!(failed, server_api::SubjektivMemoryBackendResponse::SurfaceFailed(output) if output.status == "failed_confirmed" && output.memory_fingerprint == generation.memory_fingerprint)
|
||||
);
|
||||
let result = serde_json::json!({"subject_id": subject.id, "candidate_ids": ["candidate-first"], "surface": {"availability":"failed", "generation_id":generation.id, "store_revision":generation.store_revision,"reason_code":"editor_failed"}});
|
||||
let result = serde_json::json!({"subject_id": subject.id, "candidate_ids": ["candidate-first"], "surface": {"availability":"failed", "generation_id":generation.id, "memory_fingerprint":generation.memory_fingerprint,"reason_code":"editor_failed"}});
|
||||
let submission = BackendJobResultSubmission {
|
||||
job_id: job.request.job_id.clone(),
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
input_revision: job.request.input_revision.clone(),
|
||||
input_digest: job.request.input_digest().unwrap(),
|
||||
result,
|
||||
};
|
||||
assert!(
|
||||
@@ -359,8 +359,8 @@ async fn subjektiv_job_surface_only_requires_attempt_owned_generation_and_real_o
|
||||
let submission = BackendJobResultSubmission {
|
||||
job_id: job.request.job_id.clone(),
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
input_revision: job.request.input_revision.clone(),
|
||||
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":[],"surface":{"availability":"ready","generation_id":generation.id,"store_revision":generation.store_revision,"snapshot_id":snapshot.id}}),
|
||||
input_digest: job.request.input_digest().unwrap(),
|
||||
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":[],"surface":{"availability":"ready","generation_id":generation.id,"memory_fingerprint":generation.memory_fingerprint,"snapshot_id":snapshot.id}}),
|
||||
};
|
||||
api.accept_backend_job_result(worker, &submission).unwrap();
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
@@ -395,7 +395,6 @@ async fn backend_job_profile_resolution_rejects_ungranted_requirements_without_f
|
||||
let mut request = BackendJobRequest {
|
||||
job_id: "profile-resolution".into(),
|
||||
purpose: "any-purpose".into(),
|
||||
input_revision: "1".into(),
|
||||
input_ref: "test://profiles".into(),
|
||||
input: serde_json::json!({}),
|
||||
instruction: "return structured result".into(),
|
||||
@@ -450,7 +449,7 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove
|
||||
let entrypoint = state.contract.entrypoints.first().unwrap();
|
||||
let root = state.snapshot.entries.get(entrypoint).unwrap();
|
||||
let config = config_commit_request_from_api(server_api::ConfigCommitRequest {
|
||||
base_revision: state.snapshot.revision, base_digest: state.snapshot.digest.clone(),
|
||||
base_digest: state.snapshot.digest.clone(),
|
||||
entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(),
|
||||
changes: vec![server_api::ConfigTreeChange::Update {
|
||||
path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(),
|
||||
@@ -464,7 +463,6 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove
|
||||
let request = BackendJobRequest {
|
||||
job_id: "project-recipe-job".into(),
|
||||
purpose: "no-profile-switch".into(),
|
||||
input_revision: "r1".into(),
|
||||
input_ref: "test://recipe".into(),
|
||||
input: serde_json::json!({}),
|
||||
instruction: "Submit the result".into(),
|
||||
@@ -671,8 +669,8 @@ async fn subjektiv_job_long_receipts_and_escaped_ids_fit_result_budget_without_l
|
||||
let submission = BackendJobResultSubmission {
|
||||
job_id: job.request.job_id.clone(),
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
input_revision: job.request.input_revision.clone(),
|
||||
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":ids,"surface":{"availability":"ready","generation_id":generation.id,"store_revision":generation.store_revision,"snapshot_id":snapshot.id}}),
|
||||
input_digest: job.request.input_digest().unwrap(),
|
||||
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":ids,"surface":{"availability":"ready","generation_id":generation.id,"memory_fingerprint":generation.memory_fingerprint,"snapshot_id":snapshot.id}}),
|
||||
};
|
||||
seed_worker_session_for_cleanup(dir.path(), worker);
|
||||
let accepted = api.accept_backend_job_result(worker, &submission).unwrap();
|
||||
|
||||
@@ -122,7 +122,7 @@ impl EvidenceApiFixture {
|
||||
}
|
||||
|
||||
fn revision(&self) -> String {
|
||||
ticket_item_checker::item_revision(
|
||||
ticket_item_checker::content_digest(
|
||||
&self.backend.show(self.ticket_id.clone().into()).unwrap(),
|
||||
)
|
||||
}
|
||||
@@ -150,7 +150,7 @@ impl EvidenceApiFixture {
|
||||
.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: EVIDENCE_MR.into(),
|
||||
ticket_id: self.ticket_id.clone(),
|
||||
ticket_item_revision: self.revision(),
|
||||
ticket_content_digest: self.revision(),
|
||||
ticket_merge_request_subjects: self.subjects(),
|
||||
subject_ref: EVIDENCE_SOURCE.into(),
|
||||
child_session_id,
|
||||
@@ -194,7 +194,7 @@ impl EvidenceApiFixture {
|
||||
fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion {
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "evidence-ticket-completion".into(),
|
||||
expected_item_revision: self.revision(),
|
||||
expected_content_digest: self.revision(),
|
||||
expected_state: TicketWorkflowState::InProgress,
|
||||
reason: "Implementation judged complete independently of MR attestation".into(),
|
||||
references: Vec::new(),
|
||||
@@ -247,7 +247,7 @@ impl EvidenceApiFixture {
|
||||
assert_eq!(ids, expected, "{filter}");
|
||||
if matches {
|
||||
let shown = self.show().await;
|
||||
assert_eq!(result.items[0].item_revision, shown.item_revision);
|
||||
assert_eq!(result.items[0].content_digest, shown.content_digest);
|
||||
assert_eq!(result.items[0].evidence, shown.evidence);
|
||||
assert_eq!(result.items[0].merge_requests, shown.merge_requests);
|
||||
}
|
||||
@@ -306,7 +306,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
|
||||
.backend
|
||||
.complete(&fixture.ticket_id, fixture.completion(&approval))
|
||||
.unwrap();
|
||||
assert_eq!(ticket::ticket_item_revision(&completion), revision);
|
||||
assert_eq!(ticket::ticket_content_digest(&completion), revision);
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
@@ -326,7 +326,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
|
||||
|
||||
let shown = fixture.show().await;
|
||||
assert_eq!(shown.state, "done");
|
||||
assert_eq!(shown.item_revision, revision);
|
||||
assert_eq!(shown.content_digest, revision);
|
||||
assert!(
|
||||
shown.evidence.complete_for_integration,
|
||||
"{:?}",
|
||||
@@ -366,9 +366,9 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
|
||||
assert!(empty.merge_request.thread.is_empty());
|
||||
for detail in [first, empty] {
|
||||
assert_eq!(detail.source.status, "known");
|
||||
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
|
||||
assert_eq!(detail.source.resolved_ref.as_deref(), Some(EVIDENCE_SOURCE));
|
||||
assert_eq!(detail.target.status, "known");
|
||||
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
|
||||
assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET));
|
||||
assert_eq!(detail.source.observed_at, merge.created_at.to_rfc3339());
|
||||
assert_eq!(detail.source.diagnostic, None);
|
||||
assert_eq!(detail.target.diagnostic, None);
|
||||
@@ -382,7 +382,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
let merge = fixture.integrate(&integration);
|
||||
fixture.rescope();
|
||||
let revision = fixture.revision();
|
||||
assert_ne!(integration.ticket_item_revision, revision);
|
||||
assert_ne!(integration.ticket_content_digest, revision);
|
||||
|
||||
let stale = fixture.show().await;
|
||||
assert!(stale.evidence.approved_current_subject); // integration proof survives
|
||||
@@ -396,7 +396,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
);
|
||||
fixture.assert_filters(false, true, true).await;
|
||||
let mut outdated_completion = fixture.completion(&integration);
|
||||
outdated_completion.expected_item_revision = integration.ticket_item_revision.clone();
|
||||
outdated_completion.expected_content_digest = integration.ticket_content_digest.clone();
|
||||
assert!(
|
||||
fixture
|
||||
.backend
|
||||
@@ -407,7 +407,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
|
||||
let latest = fixture.approve("latest-revision");
|
||||
assert_eq!(latest.created_at, integration.created_at);
|
||||
assert_eq!(latest.ticket_item_revision, revision);
|
||||
assert_eq!(latest.ticket_content_digest, revision);
|
||||
assert_eq!(latest.ticket_merge_request_subjects, fixture.subjects());
|
||||
let fresh = fixture.show().await;
|
||||
assert!(
|
||||
@@ -484,13 +484,13 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
assert_eq!(stored.merged_result().unwrap(), &merge);
|
||||
let detail = fixture.detail(repaired.sequence - 1, 1).await;
|
||||
assert_eq!(detail.merge_request.thread.len(), 1);
|
||||
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
|
||||
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
|
||||
assert_eq!(detail.source.resolved_ref.as_deref(), Some(EVIDENCE_SOURCE));
|
||||
assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET));
|
||||
let completion = fixture
|
||||
.backend
|
||||
.complete(&fixture.ticket_id, fixture.completion(&repaired))
|
||||
.unwrap();
|
||||
assert_eq!(ticket::ticket_item_revision(&completion), revision);
|
||||
assert_eq!(ticket::ticket_content_digest(&completion), revision);
|
||||
assert_eq!(fixture.show().await.state, "done");
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
}
|
||||
@@ -560,7 +560,7 @@ async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescop
|
||||
let detail = fixture.detail(approval.sequence, 1).await;
|
||||
assert!(detail.merge_request.thread.is_empty());
|
||||
assert_eq!(detail.source.status, "unknown");
|
||||
assert_eq!(detail.source.revision_ref, None);
|
||||
assert_eq!(detail.source.resolved_ref, None);
|
||||
assert_eq!(
|
||||
detail.source.diagnostic.unwrap().code,
|
||||
"source_ref_runtime_unavailable"
|
||||
@@ -649,7 +649,7 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta
|
||||
&fixture.ticket_id,
|
||||
ticket::TicketStateUpdate {
|
||||
operation_key: "close-with-open-mr".into(),
|
||||
expected_item_revision: fixture.revision(),
|
||||
expected_content_digest: fixture.revision(),
|
||||
expected_state: TicketWorkflowState::InProgress,
|
||||
state: TicketWorkflowState::Closed,
|
||||
reason: "The request was withdrawn; no integration or approval is asserted".into(),
|
||||
@@ -686,7 +686,7 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev
|
||||
&reference.id,
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "research-result".into(),
|
||||
expected_item_revision: ticket::ticket_item_revision(&ticket),
|
||||
expected_content_digest: ticket::ticket_content_digest(&ticket),
|
||||
expected_state: TicketWorkflowState::Planning,
|
||||
reason: "Answer recorded in the Ticket thread".into(),
|
||||
references: Vec::new(),
|
||||
@@ -760,7 +760,7 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re
|
||||
)
|
||||
.await;
|
||||
let close = json!({
|
||||
"operation_key":"guarded-close","expected_item_revision":ticket::ticket_item_revision(&before),
|
||||
"operation_key":"guarded-close","expected_content_digest":ticket::ticket_content_digest(&before),
|
||||
"expected_state":"planning","reason":"No further work required","author":"spoofed-client-author"
|
||||
});
|
||||
let response = app
|
||||
@@ -786,7 +786,7 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re
|
||||
Some(&"guarded-close".to_string())
|
||||
);
|
||||
let reopen = json!({
|
||||
"operation_key":"reopen","expected_item_revision":ticket::ticket_item_revision(&closed),
|
||||
"operation_key":"reopen","expected_content_digest":ticket::ticket_content_digest(&closed),
|
||||
"expected_state":"closed","state":"planning","reason":"A new question arrived"
|
||||
});
|
||||
let response = app
|
||||
|
||||
@@ -201,13 +201,13 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
recovery.item_revision,
|
||||
recovery.content_digest,
|
||||
fixture
|
||||
.api
|
||||
.authority
|
||||
.ticket(&fixture.ticket_id)
|
||||
.unwrap()
|
||||
.item_revision
|
||||
.content_digest
|
||||
);
|
||||
assert_eq!(recovery.selected.len(), request.workdir_bindings.len());
|
||||
for selected in &request.workdir_bindings {
|
||||
@@ -458,7 +458,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let admitted_revision = api.authority.ticket(&ticket_id).unwrap().item_revision;
|
||||
let admitted_revision = api.authority.ticket(&ticket_id).unwrap().content_digest;
|
||||
let request = server_api::SetTicketRoleAssignmentRequest {
|
||||
operation_id: "claim-runtime-reconciliation".into(),
|
||||
principal: server_api::TicketAssignmentPrincipal::Worker {
|
||||
@@ -515,7 +515,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
|
||||
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovery.item_revision, admitted_revision);
|
||||
assert_eq!(recovery.content_digest, admitted_revision);
|
||||
assert!(recovery.selected.is_empty());
|
||||
assert!(recovery.original_links.is_empty());
|
||||
assert!(recovery.effective_links.is_empty());
|
||||
@@ -571,7 +571,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
|
||||
pending.request_fingerprint
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket_id).unwrap().item_revision,
|
||||
api.authority.ticket(&ticket_id).unwrap().content_digest,
|
||||
admitted_revision
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -183,8 +183,7 @@ impl LaunchFixture {
|
||||
.public_key_fingerprint
|
||||
.clone()
|
||||
.unwrap(),
|
||||
identity_revision: workspace_identity.revision,
|
||||
trust_generation: workspace_identity.revision,
|
||||
trust_id: "trust-launch-test".to_string(),
|
||||
state: WorkspaceIssuerTrustState::Active,
|
||||
registered_at_unix: 1,
|
||||
updated_at_unix: 1,
|
||||
@@ -206,11 +205,12 @@ impl LaunchFixture {
|
||||
base_url: endpoint.clone(),
|
||||
public_key: runtime_identity.public_key.clone(),
|
||||
public_key_fingerprint: signer.public_key_fingerprint().to_string(),
|
||||
binding_revision: 1,
|
||||
binding_id: "binding-launch-test".to_string(),
|
||||
state: StoredRuntimeBindingState::Configured,
|
||||
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some(workspace_identity.key_id),
|
||||
workspace_key_generation: Some(workspace_identity.revision),
|
||||
workspace_public_key_fingerprint: workspace_identity.public_key_fingerprint.clone(),
|
||||
workspace_trust_id: Some("trust-launch-test".to_string()),
|
||||
created_at: TEST_CREATED_AT.into(),
|
||||
updated_at: TEST_CREATED_AT.into(),
|
||||
revoked_at: None,
|
||||
@@ -221,6 +221,13 @@ impl LaunchFixture {
|
||||
.upsert_workspace_runtime_binding_record(binding.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let binding = fixture
|
||||
.api
|
||||
.store
|
||||
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, &fixture.runtime_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
fixture.runtime_server = Some(tokio::spawn(async move {
|
||||
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
|
||||
runtime,
|
||||
@@ -471,7 +478,6 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r
|
||||
.unwrap();
|
||||
let main = config_source::VirtualPath::parse("main.dcdl").unwrap();
|
||||
let request = crate::config_source::ConfigCommitRequest {
|
||||
base_revision: state.snapshot.revision,
|
||||
base_digest: state.snapshot.digest.clone(),
|
||||
entrypoints: state.contract.entrypoints.clone(),
|
||||
changes: vec![config_source::ConfigTreeChange::Update {
|
||||
@@ -1025,7 +1031,7 @@ async fn choosing_reviewer_profile_does_not_register_a_reviewer_child_or_grant_m
|
||||
.request_review(merge_request::RequestMergeRequestReview {
|
||||
merge_request_id: mr_id.into(),
|
||||
ticket_id: ticket_id.clone(),
|
||||
ticket_item_revision: ticket_item_checker::item_revision(
|
||||
ticket_content_digest: ticket_item_checker::content_digest(
|
||||
&browser_ticket_backend(&fixture.api)
|
||||
.unwrap()
|
||||
.show(ticket_id.clone().into())
|
||||
|
||||
@@ -31,7 +31,6 @@ fn request(
|
||||
}];
|
||||
changes.extend(extra);
|
||||
ConfigCommitRequest {
|
||||
base_revision: state.snapshot.revision,
|
||||
base_digest: state.snapshot.digest,
|
||||
entrypoints: state.contract.entrypoints,
|
||||
changes,
|
||||
@@ -75,10 +74,7 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
saved["snapshot"]["revision"].as_u64().unwrap(),
|
||||
initial["snapshot"]["revision"].as_u64().unwrap() + 1
|
||||
);
|
||||
assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]);
|
||||
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
|
||||
assert_eq!(listed, saved);
|
||||
let entries = listed["snapshot"]["entries"].as_object().unwrap();
|
||||
@@ -108,7 +104,6 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
|
||||
.unwrap();
|
||||
let main_digest = entries["main.dcdl"]["content_digest"].as_str().unwrap();
|
||||
let base = json!({
|
||||
"base_revision": listed["snapshot"]["revision"],
|
||||
"base_digest": listed["snapshot"]["digest"],
|
||||
"entrypoints": listed["contract"]["entrypoints"],
|
||||
});
|
||||
@@ -326,7 +321,7 @@ async fn value_profiles_builtin_companion_composition_seals_observed_sources() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
|
||||
async fn value_profiles_save_project_and_runtime_consume_the_same_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let forms = [
|
||||
@@ -390,8 +385,8 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
|
||||
Some("project:alpha")
|
||||
);
|
||||
assert_eq!(
|
||||
projection.settings.config_revision,
|
||||
Some(state.snapshot.revision)
|
||||
projection.settings.tree_digest.as_deref(),
|
||||
Some(state.snapshot.digest.as_str())
|
||||
);
|
||||
let selected = projection
|
||||
.settings
|
||||
@@ -413,10 +408,7 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
|
||||
assert_eq!(
|
||||
bundle.metadata.revision,
|
||||
state.snapshot.revision.to_string()
|
||||
);
|
||||
|
||||
let archive = bundle.profile_source_archive.as_ref().unwrap();
|
||||
let verified = archive.verify().unwrap();
|
||||
// This is the Runtime's archive consumer. It needs neither the Workspace tree nor imports.
|
||||
@@ -454,7 +446,7 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
|
||||
}
|
||||
if index == 2 {
|
||||
let mut other_state = state.clone();
|
||||
other_state.snapshot.revision += 1;
|
||||
other_state.snapshot.digest = "sha256:different-tree".to_string();
|
||||
assert!(
|
||||
build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
|
||||
@@ -393,8 +393,8 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant
|
||||
.unwrap(),
|
||||
);
|
||||
assert_ne!(
|
||||
updated["entry"]["metadata"]["revision"],
|
||||
created["entry"]["metadata"]["revision"]
|
||||
updated["entry"]["metadata"]["last_mutation_id"],
|
||||
created["entry"]["metadata"]["last_mutation_id"]
|
||||
);
|
||||
assert_eq!(
|
||||
value(
|
||||
@@ -416,7 +416,8 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simultaneous_worker_edits_publish_exactly_one_revision_and_preserve_the_loser_conflict() {
|
||||
async fn simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict()
|
||||
{
|
||||
let f = Fixture::new(["read_write", "read_write"]).await;
|
||||
let id = entry(&f.create("simultaneous.md", "original").await);
|
||||
for feature in &f.features {
|
||||
@@ -456,7 +457,7 @@ async fn simultaneous_worker_edits_publish_exactly_one_revision_and_preserve_the
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_revision() {
|
||||
async fn stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_last_mutation_id() {
|
||||
let f = Fixture::new(["read_write", "read_write"]).await;
|
||||
let id = entry(&f.create("cas.md", "old").await);
|
||||
let root = f.root(0).await;
|
||||
@@ -914,8 +915,8 @@ async fn lost_mutation_response_is_resolved_by_receipt_without_resending_effect(
|
||||
let listed = value(tool(&f.features[1], "DriveList", json!({})).await.unwrap());
|
||||
assert_eq!(listed["entries"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
listed["entries"][0]["metadata"]["revision"],
|
||||
created["entry"]["metadata"]["revision"]
|
||||
listed["entries"][0]["metadata"]["last_mutation_id"],
|
||||
created["entry"]["metadata"]["last_mutation_id"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -954,8 +955,8 @@ async fn scoped_workdir_import_publishes_document_and_image_bytes_with_bounded_r
|
||||
.append_pair("entry_workspace_id", TEST_WORKSPACE_ID)
|
||||
.append_pair("id", id["node_id"].as_str().unwrap())
|
||||
.append_pair(
|
||||
"expected_revision",
|
||||
metadata["metadata"]["revision"].as_str().unwrap(),
|
||||
"expected_mutation_id",
|
||||
metadata["metadata"]["last_mutation_id"].as_str().unwrap(),
|
||||
)
|
||||
.append_pair("offset", "0")
|
||||
.append_pair("length", "17")
|
||||
@@ -1271,7 +1272,7 @@ async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_cu
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_create_and_write_use_client_managed_revisions_and_reject_stale_worker() {
|
||||
async fn native_create_and_write_use_client_managed_committed_requests_and_reject_stale_worker() {
|
||||
let f = Fixture::new(["read_write", "read_write"]).await;
|
||||
let first = native_tools(&f.features[0]);
|
||||
let second = native_tools(&f.features[1]);
|
||||
@@ -1294,8 +1295,8 @@ async fn native_create_and_write_use_client_managed_revisions_and_reject_stale_w
|
||||
);
|
||||
let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap());
|
||||
assert_ne!(
|
||||
updated["entry"]["metadata"]["revision"],
|
||||
created["entry"]["metadata"]["revision"]
|
||||
updated["entry"]["metadata"]["last_mutation_id"],
|
||||
created["entry"]["metadata"]["last_mutation_id"]
|
||||
);
|
||||
let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err();
|
||||
assert!(
|
||||
@@ -1333,7 +1334,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
|
||||
request: &str,
|
||||
bytes: &[u8],
|
||||
id: Option<&Value>,
|
||||
revision: Option<&str>| {
|
||||
last_mutation_id: Option<&str>| {
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
query
|
||||
.append_pair("operation", operation)
|
||||
@@ -1345,7 +1346,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
|
||||
if let Some(id) = id {
|
||||
query
|
||||
.append_pair("id", id["node_id"].as_str().unwrap())
|
||||
.append_pair("expected_revision", revision.unwrap());
|
||||
.append_pair("expected_mutation_id", last_mutation_id.unwrap());
|
||||
} else {
|
||||
query
|
||||
.append_pair("parent_id", root["node_id"].as_str().unwrap())
|
||||
@@ -1400,7 +1401,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
|
||||
"image-update",
|
||||
replacement,
|
||||
Some(&id),
|
||||
saved["entry"]["revision"].as_str(),
|
||||
saved["entry"]["last_mutation_id"].as_str(),
|
||||
),
|
||||
replacement,
|
||||
)
|
||||
|
||||
@@ -184,7 +184,6 @@ async fn handle_resolution_rejects_unknown_foreign_and_internal_only_workers() {
|
||||
&source.worker_ref,
|
||||
protocol::Event::InternalWorker {
|
||||
worker: internal.clone(),
|
||||
revision: 1,
|
||||
event: Box::new(protocol::Event::WorkerState {
|
||||
snapshot: protocol::WorkerStateSnapshot::initial(),
|
||||
}),
|
||||
@@ -522,7 +521,6 @@ async fn browser_protocol_sender_rejects_job_input_and_all_live_methods() {
|
||||
let request = BackendJobRequest {
|
||||
job_id: "worker-operation-read-only-job".into(),
|
||||
purpose: "operation_boundary_test".into(),
|
||||
input_revision: "revision-1".into(),
|
||||
input_ref: "test://worker-operation/input".into(),
|
||||
input: serde_json::json!({"immutable": true}),
|
||||
instruction: "Return a structured result.".into(),
|
||||
@@ -755,7 +753,6 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_
|
||||
TEST_WORKSPACE_ID.into(),
|
||||
"offline-runtime".into(),
|
||||
ExecuteRuntimeCleanupRequest {
|
||||
expected_plan_revision: "irrelevant".into(),
|
||||
expected_plan_digest: "irrelevant".into(),
|
||||
worker_target_ids: Vec::new(),
|
||||
workdir_target_ids: Vec::new(),
|
||||
@@ -875,7 +872,6 @@ async fn browser_protocol_connection_and_cleanup_reject_cookie_origin_mismatch()
|
||||
&api,
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
ExecuteRuntimeCleanupRequest {
|
||||
expected_plan_revision: "irrelevant".into(),
|
||||
expected_plan_digest: "irrelevant".into(),
|
||||
worker_target_ids: Vec::new(),
|
||||
workdir_target_ids: Vec::new(),
|
||||
@@ -1112,7 +1108,7 @@ async fn restore_guard_stale_ssh_intent_does_not_issue_workspace_resource() {
|
||||
hostname: "example.invalid".into(),
|
||||
port: 22,
|
||||
host_key: public_key,
|
||||
expected_revision: None,
|
||||
expected_fingerprint: None,
|
||||
},
|
||||
"owner-account",
|
||||
)
|
||||
@@ -1642,7 +1638,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
|
||||
hostname: "example.invalid".into(),
|
||||
port: 22,
|
||||
host_key: public_key,
|
||||
expected_revision: None,
|
||||
expected_fingerprint: None,
|
||||
},
|
||||
"owner-account",
|
||||
)
|
||||
@@ -1678,7 +1674,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
|
||||
);
|
||||
assert_ne!(first.materialization.runtime_id, identity.runtime_id);
|
||||
// Rotate current host trust while the accepted operation is uncertain. Its
|
||||
// original credential/trust revisions, source and binding must remain pinned.
|
||||
// original credential/trust fingerprints, source and binding must remain pinned.
|
||||
let alternate = api
|
||||
.repository_secrets
|
||||
.generate_credential(
|
||||
@@ -1697,12 +1693,13 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key;
|
||||
let original_trust_revision = first
|
||||
let original_trust_fingerprint = first
|
||||
.materialization
|
||||
.ssh
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.host_trust_revision;
|
||||
.host_key_fingerprint
|
||||
.clone();
|
||||
api.repository_secrets
|
||||
.put_host_trust(
|
||||
api.workspace_id(),
|
||||
@@ -1712,7 +1709,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
|
||||
hostname: "example.invalid".into(),
|
||||
port: 22,
|
||||
host_key: alternate_public,
|
||||
expected_revision: Some(original_trust_revision),
|
||||
expected_fingerprint: Some(original_trust_fingerprint),
|
||||
},
|
||||
"owner-account",
|
||||
)
|
||||
|
||||
@@ -598,7 +598,6 @@ async fn runtime_callbacks_during_deletion_drain_still_require_bound_proofs() {
|
||||
TEST_WORKSPACE_ID,
|
||||
WorkdirlessFixtureRuntime::RUNTIME_ID,
|
||||
"fixture-resource",
|
||||
"1",
|
||||
Utc::now().timestamp() + 60,
|
||||
worker_runtime::resource::RepositorySshAccessSecret {
|
||||
credential_candidates: Vec::new(),
|
||||
@@ -846,7 +845,7 @@ async fn create_and_restore_session_callbacks_finish_while_deletion_start_waits(
|
||||
&format!("/api/workspaces/{TEST_WORKSPACE_ID}/deletion"),
|
||||
json!({
|
||||
"operation_id": if restore {"delete-during-restore"} else {"delete-during-create"},
|
||||
"expected_revision": "fixture-revision", "confirmation":"fixture",
|
||||
"expected_workspace_updated_at": "fixture-updated-at", "confirmation":"fixture",
|
||||
}),
|
||||
);
|
||||
let mut deletion = Box::pin(f.app.clone().oneshot(request));
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::*;
|
||||
use futures::StreamExt;
|
||||
use server_api::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use workspace_drive::{Drive, Mutation, NodeId, Revision};
|
||||
use workspace_drive::{Drive, Mutation, NodeId};
|
||||
|
||||
fn mutation_actor(context: &ServerRequestContext) -> DriveResult<String> {
|
||||
if let Some(source) = &context.runtime_source {
|
||||
@@ -65,8 +65,11 @@ async fn blocking<T: Send + 'static>(
|
||||
fn node_id(value: &str) -> DriveResult<NodeId> {
|
||||
value.to_owned().try_into().map_err(|_| failure(400))
|
||||
}
|
||||
fn revision(value: &str) -> DriveResult<Revision> {
|
||||
value.to_owned().try_into().map_err(|_| failure(400))
|
||||
fn last_mutation_id(value: &str) -> DriveResult<String> {
|
||||
if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
|
||||
return Err(failure(400));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
fn entry(workspace: &str, reference: &DriveEntryRef) -> DriveResult<NodeId> {
|
||||
if reference.workspace_id != workspace {
|
||||
@@ -188,7 +191,7 @@ fn public_node(node: workspace_drive::Node) -> DriveEntry {
|
||||
workspace_drive::Kind::File => DriveEntryKind::File,
|
||||
workspace_drive::Kind::Directory => DriveEntryKind::Folder,
|
||||
},
|
||||
revision: node.revision.get().to_string(),
|
||||
last_mutation_id: node.last_mutation_id.clone(),
|
||||
size: (node.kind == workspace_drive::Kind::File).then_some(node.size as u32),
|
||||
content_type: node.content_type,
|
||||
updated_by: node.updated_by,
|
||||
@@ -292,7 +295,7 @@ pub(super) async fn read_text(
|
||||
}
|
||||
let (node, chunk) = blocking(false, move || {
|
||||
let node = drive.metadata(id)?;
|
||||
let chunk = drive.read(id, node.revision, 0, max)?;
|
||||
let chunk = drive.read(id, &node.last_mutation_id, 0, max)?;
|
||||
Ok((node, chunk))
|
||||
})
|
||||
.await?;
|
||||
@@ -317,12 +320,17 @@ pub(super) async fn read_chunk(
|
||||
) -> DriveResult<BinaryBody> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||
let revision = revision(&query.expected_revision)?;
|
||||
let last_mutation_id = last_mutation_id(&query.expected_mutation_id)?;
|
||||
if query.length == 0 || query.length > DRIVE_CHUNK_MAX_BYTES {
|
||||
return Err(failure(413));
|
||||
}
|
||||
let chunk = blocking(false, move || {
|
||||
drive.read(id, revision, query.offset.into(), query.length as usize)
|
||||
drive.read(
|
||||
id,
|
||||
&last_mutation_id,
|
||||
query.offset.into(),
|
||||
query.length as usize,
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
Ok(chunk.bytes.into())
|
||||
@@ -352,32 +360,32 @@ fn mutation(workspace: &str, mutation: DriveMutation) -> DriveResult<Mutation> {
|
||||
},
|
||||
DriveMutation::UpdateText {
|
||||
id,
|
||||
expected_revision,
|
||||
expected_mutation_id,
|
||||
text,
|
||||
content_type,
|
||||
} => Mutation::Update {
|
||||
id: entry(workspace, &id)?,
|
||||
expected_revision: revision(&expected_revision)?,
|
||||
expected_mutation_id: last_mutation_id(&expected_mutation_id)?,
|
||||
content_type: safe_content_type(&content_type)?,
|
||||
bytes: text_bytes(text)?,
|
||||
},
|
||||
DriveMutation::Relocate {
|
||||
id,
|
||||
expected_revision,
|
||||
expected_mutation_id,
|
||||
parent,
|
||||
name,
|
||||
} => Mutation::Relocate {
|
||||
id: entry(workspace, &id)?,
|
||||
expected_revision: revision(&expected_revision)?,
|
||||
expected_mutation_id: last_mutation_id(&expected_mutation_id)?,
|
||||
parent: entry(workspace, &parent)?,
|
||||
name,
|
||||
},
|
||||
DriveMutation::Delete {
|
||||
id,
|
||||
expected_revision,
|
||||
expected_mutation_id,
|
||||
} => Mutation::Delete {
|
||||
id: entry(workspace, &id)?,
|
||||
expected_revision: revision(&expected_revision)?,
|
||||
expected_mutation_id: last_mutation_id(&expected_mutation_id)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -413,7 +421,9 @@ fn upload_mutation(
|
||||
}
|
||||
let content_type = safe_content_type(&query.content_type)?;
|
||||
match query.operation {
|
||||
DriveUploadOperation::Create if query.id.is_none() && query.expected_revision.is_none() => {
|
||||
DriveUploadOperation::Create
|
||||
if query.id.is_none() && query.expected_mutation_id.is_none() =>
|
||||
{
|
||||
Ok(Mutation::CreateFile {
|
||||
parent: node_id(query.parent_id.as_deref().ok_or_else(|| failure(400))?)?,
|
||||
name: query.name.clone().ok_or_else(|| failure(400))?,
|
||||
@@ -424,9 +434,9 @@ fn upload_mutation(
|
||||
DriveUploadOperation::Update if query.parent_id.is_none() && query.name.is_none() => {
|
||||
Ok(Mutation::Update {
|
||||
id: node_id(query.id.as_deref().ok_or_else(|| failure(400))?)?,
|
||||
expected_revision: revision(
|
||||
expected_mutation_id: last_mutation_id(
|
||||
query
|
||||
.expected_revision
|
||||
.expected_mutation_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| failure(400))?,
|
||||
)?,
|
||||
@@ -508,9 +518,9 @@ async fn select_download(
|
||||
return Err(failure(400));
|
||||
}
|
||||
if query
|
||||
.expected_revision
|
||||
.expected_mutation_id
|
||||
.as_deref()
|
||||
.is_some_and(|v| v != node.revision.get().to_string())
|
||||
.is_some_and(|v| v != node.last_mutation_id.clone())
|
||||
{
|
||||
return Err(failure(409));
|
||||
}
|
||||
@@ -528,8 +538,9 @@ pub(super) async fn download(
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let copy = drive.clone();
|
||||
let node = node.clone();
|
||||
let chunk = blocking(false, move || {
|
||||
copy.read(node.id, node.revision, offset, TRANSFER_CHUNK)
|
||||
copy.read(node.id, &node.last_mutation_id, offset, TRANSFER_CHUNK)
|
||||
})
|
||||
.await?;
|
||||
offset += chunk.bytes.len() as u64;
|
||||
@@ -548,12 +559,18 @@ pub(super) async fn download(
|
||||
})
|
||||
}
|
||||
fn validator(node: &workspace_drive::Node) -> String {
|
||||
format!(
|
||||
"\"drive-{}-{}-{}\"",
|
||||
node.workspace_id,
|
||||
node.id.get(),
|
||||
node.revision.get()
|
||||
)
|
||||
// Request IDs are caller-chosen UTF-8, never interpolate them into HTTP headers.
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(node.workspace_id.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(node.id.get().to_be_bytes());
|
||||
digest.update(node.last_mutation_id.as_bytes());
|
||||
let hex: String = digest
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
format!("\"drive-{hex}\"")
|
||||
}
|
||||
|
||||
pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||
@@ -664,16 +681,17 @@ async fn streaming_download(
|
||||
let (drive, node) = select_download(api, &context, &workspace, query).await?;
|
||||
// Validate the referenced blob before HTTP success, including a zero-byte file.
|
||||
let first_drive = drive.clone();
|
||||
let selected = node.clone();
|
||||
let first = blocking(false, move || {
|
||||
first_drive.read(node.id, node.revision, 0, TRANSFER_CHUNK)
|
||||
first_drive.read(selected.id, &selected.last_mutation_id, 0, TRANSFER_CHUNK)
|
||||
})
|
||||
.await?;
|
||||
let id = node.id;
|
||||
let revision = node.revision;
|
||||
let state = (drive, id, revision, Some(first), 0_u64, false);
|
||||
let last_mutation_id = node.last_mutation_id.clone();
|
||||
let state = (drive, id, last_mutation_id, Some(first), 0_u64, false);
|
||||
let stream = futures::stream::try_unfold(
|
||||
state,
|
||||
|(drive, id, revision, first, offset, done)| async move {
|
||||
|(drive, id, last_mutation_id, first, offset, done)| async move {
|
||||
if done {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -681,8 +699,9 @@ async fn streaming_download(
|
||||
Some(chunk) => chunk,
|
||||
None => {
|
||||
let copy = drive.clone();
|
||||
let expected = last_mutation_id.clone();
|
||||
blocking(false, move || {
|
||||
copy.read(id, revision, offset, TRANSFER_CHUNK)
|
||||
copy.read(id, &expected, offset, TRANSFER_CHUNK)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| stream_error())?
|
||||
@@ -692,7 +711,7 @@ async fn streaming_download(
|
||||
let eof = chunk.eof;
|
||||
Ok::<_, std::io::Error>(Some((
|
||||
axum::body::Bytes::from(chunk.bytes),
|
||||
(drive, id, revision, None, next, eof),
|
||||
(drive, id, last_mutation_id, None, next, eof),
|
||||
)))
|
||||
},
|
||||
);
|
||||
@@ -734,3 +753,30 @@ async fn streaming_download(
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod validator_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_identity_is_hashed_before_becoming_an_http_validator() {
|
||||
let mut node = workspace_drive::Node {
|
||||
id: "2".to_string().try_into().unwrap(),
|
||||
workspace_id: "workspace".into(),
|
||||
parent_id: Some("1".to_string().try_into().unwrap()),
|
||||
name: "document".into(),
|
||||
kind: workspace_drive::Kind::File,
|
||||
last_mutation_id: "write/資料 ?\"#&=+".into(),
|
||||
size: 0,
|
||||
content_type: Some("text/plain".into()),
|
||||
updated_by: "actor".into(),
|
||||
updated_at_ms: 0,
|
||||
};
|
||||
let first = validator(&node);
|
||||
assert!(first.parse::<axum::http::HeaderValue>().is_ok());
|
||||
assert!(!first.contains(&node.last_mutation_id));
|
||||
assert_eq!(first, validator(&node));
|
||||
node.last_mutation_id = "another-request".into();
|
||||
assert_ne!(first, validator(&node));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,8 @@ fn tree(api: &WorkspaceApi) -> ConfigResult<crate::config_source::WorkspaceConfi
|
||||
fn validator(
|
||||
grant: &WorkspaceConfigGrantResponse,
|
||||
link: &WorkerWorkdirLinkRecord,
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
state: &crate::config_source::WorkspaceConfigState,
|
||||
applied_schema_fingerprint: &str,
|
||||
path: &str,
|
||||
) -> String {
|
||||
let bound = serde_json::to_vec(&(
|
||||
@@ -280,8 +281,10 @@ fn validator(
|
||||
&link.connection_id,
|
||||
effective_access(grant, link),
|
||||
path,
|
||||
snapshot.revision,
|
||||
&snapshot.digest,
|
||||
&state.snapshot.digest,
|
||||
&state.contract.fingerprint,
|
||||
&state.projection_digest,
|
||||
applied_schema_fingerprint,
|
||||
))
|
||||
.expect("bounded configuration validator tuple");
|
||||
format!(
|
||||
@@ -313,6 +316,11 @@ pub(super) async fn observe(
|
||||
let _guard = lock(api, worker).await?;
|
||||
let (grant, link) = resolve(api, worker, &request.connection_id)?;
|
||||
let state = tree(api)?;
|
||||
let schema_fingerprint = api
|
||||
.config_schema_registry
|
||||
.compose()
|
||||
.map_err(pre_error)?
|
||||
.fingerprint;
|
||||
let snapshot = &state.snapshot;
|
||||
let writable = effective_access(&grant, &link) == Access::ReadWrite;
|
||||
// Build only bounded metadata (the canonical tree itself is bounded by config-source).
|
||||
@@ -386,7 +394,7 @@ pub(super) async fn observe(
|
||||
}
|
||||
}
|
||||
WorkspaceConfigNode {
|
||||
validator: validator(&grant, &link, snapshot, &path),
|
||||
validator: validator(&grant, &link, &state, &schema_fingerprint, &path),
|
||||
path,
|
||||
kind,
|
||||
digest: entry.map(|entry| entry.content_digest.clone()),
|
||||
@@ -402,8 +410,7 @@ pub(super) async fn observe(
|
||||
.collect();
|
||||
Ok(WorkspaceConfigObserveResponse {
|
||||
connection_id: link.connection_id.clone(),
|
||||
validator: validator(&grant, &link, snapshot, ""),
|
||||
revision: snapshot.revision,
|
||||
validator: validator(&grant, &link, &state, &schema_fingerprint, ""),
|
||||
digest: snapshot.digest.clone(),
|
||||
entrypoints: state
|
||||
.contract
|
||||
@@ -423,7 +430,12 @@ pub(super) async fn read(
|
||||
let _guard = lock(api, worker).await?;
|
||||
let (grant, link) = resolve(api, worker, &request.connection_id)?;
|
||||
let state = tree(api)?;
|
||||
let expected = validator(&grant, &link, &state.snapshot, &request.path);
|
||||
let schema_fingerprint = api
|
||||
.config_schema_registry
|
||||
.compose()
|
||||
.map_err(pre_error)?
|
||||
.fingerprint;
|
||||
let expected = validator(&grant, &link, &state, &schema_fingerprint, &request.path);
|
||||
check_validator(&request.validator, &expected)?;
|
||||
let path =
|
||||
config_source::VirtualPath::parse(&request.path).map_err(|_| fail(400, "invalid_path"))?;
|
||||
@@ -476,9 +488,14 @@ pub(super) async fn commit(
|
||||
return Err(fail(403, "access_denied"));
|
||||
}
|
||||
let state = tree(api)?;
|
||||
let schema_fingerprint = api
|
||||
.config_schema_registry
|
||||
.compose()
|
||||
.map_err(pre_error)?
|
||||
.fingerprint;
|
||||
check_validator(
|
||||
&request.validator,
|
||||
&validator(&grant, &link, &state.snapshot, ""),
|
||||
&validator(&grant, &link, &state, &schema_fingerprint, ""),
|
||||
)?;
|
||||
let canonical = config_commit_request_from_api(request.request).map_err(pre_error)?;
|
||||
// Evaluation/semantic validation are pure preparation. Bound this stage
|
||||
@@ -496,6 +513,13 @@ pub(super) async fn commit(
|
||||
.map_err(|_| fail(503, "evaluation_timeout"))?
|
||||
.map_err(|_| fail(500, "evaluation_unavailable"))?
|
||||
.map_err(pre_error)?;
|
||||
if candidate.base_digest != state.snapshot.digest
|
||||
|| candidate.base_toolchain_fingerprint != state.contract.fingerprint
|
||||
|| candidate.base_projection_digest != state.projection_digest
|
||||
|| candidate.contract.schema_bundle.fingerprint != schema_fingerprint
|
||||
{
|
||||
return Err(fail(409, "stale_validator"));
|
||||
}
|
||||
// Recheck durable authority before the first auxiliary/canonical effects.
|
||||
let (current_grant, current_link) = resolve(api, worker, &request.connection_id)?;
|
||||
if current_grant != grant || current_link.capabilities != link.capabilities {
|
||||
@@ -506,8 +530,7 @@ pub(super) async fn commit(
|
||||
let state = persist_workspace_config_candidate(api, &api.config.workspace_id, &candidate)
|
||||
.map_err(|_| unknown())?;
|
||||
Ok(WorkspaceConfigCommitResponse {
|
||||
validator: validator(&grant, &link, &state.snapshot, ""),
|
||||
revision: state.snapshot.revision,
|
||||
validator: validator(&grant, &link, &state, &schema_fingerprint, ""),
|
||||
digest: state.snapshot.digest,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ mod workspace_config_integration {
|
||||
connection_id: connection.into(),
|
||||
validator: snapshot.validator.clone(),
|
||||
request: server_api::ConfigCommitRequest {
|
||||
base_revision: snapshot.revision,
|
||||
base_digest: snapshot.digest.clone(),
|
||||
changes,
|
||||
entrypoints: snapshot.entrypoints.clone(),
|
||||
@@ -127,97 +126,335 @@ mod workspace_config_integration {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_schema_refresh_invalidates_read_and_commit_validators_without_source_change()
|
||||
{
|
||||
struct AddedSchema;
|
||||
impl crate::config_source::WorkspaceConfigSchemaProvider for AddedSchema {
|
||||
fn contribution(&self) -> Result<config_source::ConfigSchemaContribution> {
|
||||
Ok(config_source::ConfigSchemaContribution::new(
|
||||
"builtin:validator-test",
|
||||
"validator_test",
|
||||
"1",
|
||||
"{ validator_test = Bool default true; }",
|
||||
)
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
let mut fixture = manual_worker_assignment_fixture().await;
|
||||
grant(&fixture.api, &fixture.worker, Access::ReadWrite).await;
|
||||
let attached = attach(&fixture.api, &fixture.worker).await;
|
||||
let prior = observe(
|
||||
&fixture.api,
|
||||
&fixture.worker,
|
||||
&attached.connection_id,
|
||||
&[""],
|
||||
1,
|
||||
)
|
||||
.await;
|
||||
let file = prior
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.path == "main.dcdl")
|
||||
.unwrap();
|
||||
// Also bind the schema that will be applied, even before canonical refresh.
|
||||
fixture.api.config_schema_registry = fixture
|
||||
.api
|
||||
.config_schema_registry
|
||||
.clone()
|
||||
.with_provider(Arc::new(AddedSchema));
|
||||
let applied = observe(
|
||||
&fixture.api,
|
||||
&fixture.worker,
|
||||
&attached.connection_id,
|
||||
&[""],
|
||||
1,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(applied.digest, prior.digest);
|
||||
assert_ne!(applied.validator, prior.validator);
|
||||
fixture
|
||||
.api
|
||||
.config_store
|
||||
.ensure_workspace_config_materialized_with_schema(
|
||||
TEST_WORKSPACE_ID,
|
||||
"2026-08-14T00:00:00Z",
|
||||
fixture.api.config_schema_registry.compose().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let refreshed = observe(
|
||||
&fixture.api,
|
||||
&fixture.worker,
|
||||
&attached.connection_id,
|
||||
&[""],
|
||||
1,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(refreshed.digest, prior.digest);
|
||||
assert_ne!(refreshed.validator, applied.validator);
|
||||
let rejected = workspace_config::read(
|
||||
&fixture.api,
|
||||
&fixture.worker,
|
||||
Read {
|
||||
connection_id: attached.connection_id.clone(),
|
||||
path: file.path.clone(),
|
||||
validator: file.validator.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
(rejected.status, rejected.code.as_str()),
|
||||
(409, "stale_validator")
|
||||
);
|
||||
let rejected = workspace_config::commit(
|
||||
&fixture.api,
|
||||
&fixture.worker,
|
||||
commit_request(
|
||||
&attached.connection_id,
|
||||
&prior,
|
||||
vec![create("stale.txt", "must not commit")],
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
(rejected.status, rejected.code.as_str()),
|
||||
(409, "stale_validator")
|
||||
);
|
||||
assert_eq!(rejected.classification, Classification::NotCommitted);
|
||||
assert!(
|
||||
!fixture
|
||||
.api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.snapshot
|
||||
.entries
|
||||
.contains_key(&config_source::VirtualPath::parse("stale.txt").unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_signed_editor_routes_cannot_bypass_wip_authority() {
|
||||
let mut fixture = manual_worker_assignment_fixture().await;
|
||||
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
||||
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||
let api = &fixture.api;
|
||||
let initial = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
|
||||
let initial = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
|
||||
let change = server_api::ConfigCommitRequest {
|
||||
base_revision: initial.snapshot.revision,
|
||||
base_digest: initial.snapshot.digest.clone(),
|
||||
changes: vec![create("notes/editor-only.txt", "must not persist from Worker")],
|
||||
changes: vec![create(
|
||||
"notes/editor-only.txt",
|
||||
"must not persist from Worker",
|
||||
)],
|
||||
entrypoints: vec!["main.dcdl".into()],
|
||||
};
|
||||
let routes = [
|
||||
("GET", tree.clone()),
|
||||
("GET", format!("{tree}/entries/main.dcdl")),
|
||||
("GET", format!("{tree}/revisions/{}", initial.snapshot.revision)),
|
||||
("GET", format!("{tree}/history/{}", initial.snapshot.digest)),
|
||||
("POST", format!("{tree}/commit")),
|
||||
("GET", format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles")),
|
||||
(
|
||||
"GET",
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles"),
|
||||
),
|
||||
];
|
||||
for state in ["ungranted", "read_only", "revoked", "detached", "read_write"] {
|
||||
for state in [
|
||||
"ungranted",
|
||||
"read_only",
|
||||
"revoked",
|
||||
"detached",
|
||||
"read_write",
|
||||
] {
|
||||
match state {
|
||||
"read_only" => {
|
||||
grant(api, &fixture.worker, Access::ReadOnly).await;
|
||||
attach(api, &fixture.worker).await;
|
||||
}
|
||||
"revoked" => {
|
||||
let current = api.store.current_workspace_config_grant(TEST_WORKSPACE_ID, &fixture.worker).unwrap().unwrap();
|
||||
workspace_config::revoke_grant(api, &test_owner_actor(), TEST_WORKSPACE_ID,
|
||||
¤t.grant_id).await.unwrap();
|
||||
let current = api
|
||||
.store
|
||||
.current_workspace_config_grant(TEST_WORKSPACE_ID, &fixture.worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
workspace_config::revoke_grant(
|
||||
api,
|
||||
&test_owner_actor(),
|
||||
TEST_WORKSPACE_ID,
|
||||
¤t.grant_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
"detached" => {
|
||||
grant(api, &fixture.worker, Access::ReadWrite).await;
|
||||
let attached = attach(api, &fixture.worker).await;
|
||||
detach_current_worker_workdir(api, &fixture.worker, "workspace-config",
|
||||
Some(&attached.connection_id)).await.unwrap();
|
||||
detach_current_worker_workdir(
|
||||
api,
|
||||
&fixture.worker,
|
||||
"workspace-config",
|
||||
Some(&attached.connection_id),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
"read_write" => {
|
||||
attach(api, &fixture.worker).await;
|
||||
}
|
||||
"read_write" => { attach(api, &fixture.worker).await; }
|
||||
_ => {}
|
||||
}
|
||||
for (method, path) in &routes {
|
||||
let body = if *method == "POST" { serde_json::to_vec(&change).unwrap() } else { vec![] };
|
||||
let body = if *method == "POST" {
|
||||
serde_json::to_vec(&change).unwrap()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
for scoped in [false, true] {
|
||||
let app = if scoped {
|
||||
workspace_server_router(WorkspaceServerApi::new(api.config.clone(), api.store.clone()))
|
||||
workspace_server_router(WorkspaceServerApi::new(
|
||||
api.config.clone(),
|
||||
api.store.clone(),
|
||||
))
|
||||
} else {
|
||||
build_router(api.clone())
|
||||
};
|
||||
let response = app.oneshot(runtime_source_request(&identity,
|
||||
Some(&fixture.worker.worker_id), method, path, body.clone())).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN,
|
||||
"{state}: {method} {path}, scoped={scoped}");
|
||||
let response = app
|
||||
.oneshot(runtime_source_request(
|
||||
&identity,
|
||||
Some(&fixture.worker.worker_id),
|
||||
method,
|
||||
path,
|
||||
body.clone(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"{state}: {method} {path}, scoped={scoped}"
|
||||
);
|
||||
let bytes = to_bytes(response.into_body(), 4096).await.unwrap();
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains("must not persist"));
|
||||
}
|
||||
}
|
||||
let after = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
|
||||
assert_eq!(after.snapshot, initial.snapshot, "{state}: editor rejection must have no effect");
|
||||
let after = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after.snapshot, initial.snapshot,
|
||||
"{state}: editor rejection must have no effect"
|
||||
);
|
||||
}
|
||||
// A Runtime-only proof is not a user editor identity either.
|
||||
assert_eq!(signed_call(api, &identity, None, "GET", &tree, Value::Null).await.0,
|
||||
StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
signed_call(api, &identity, None, "GET", &tree, Value::Null)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let attached = attach(api, &fixture.worker).await;
|
||||
let observed = observe(api, &fixture.worker, &attached.connection_id, &["main.dcdl"], 0).await;
|
||||
let node = observed.nodes.iter().find(|n| n.path == "main.dcdl").unwrap();
|
||||
assert!(workspace_config::read(api, &fixture.worker, Read {
|
||||
connection_id: attached.connection_id,
|
||||
path: "main.dcdl".into(), validator: node.validator.clone(),
|
||||
}).await.is_ok(), "the granted WIP path remains available");
|
||||
let observed = observe(
|
||||
api,
|
||||
&fixture.worker,
|
||||
&attached.connection_id,
|
||||
&["main.dcdl"],
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
let node = observed
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.path == "main.dcdl")
|
||||
.unwrap();
|
||||
assert!(
|
||||
workspace_config::read(
|
||||
api,
|
||||
&fixture.worker,
|
||||
Read {
|
||||
connection_id: attached.connection_id,
|
||||
path: "main.dcdl".into(),
|
||||
validator: node.validator.clone(),
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_ok(),
|
||||
"the granted WIP path remains available"
|
||||
);
|
||||
|
||||
// Real API-token authentication still permits the normal UI editor.
|
||||
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
for (method, path) in &routes {
|
||||
if *method == "GET" {
|
||||
request_json_authenticated(build_router(api.clone()), method, path, None,
|
||||
&token, StatusCode::OK).await;
|
||||
request_json_authenticated(
|
||||
build_router(api.clone()),
|
||||
method,
|
||||
path,
|
||||
None,
|
||||
&token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
request_json_authenticated(build_router(api.clone()), "POST", &format!("{tree}/commit"),
|
||||
Some(serde_json::to_value(change).unwrap()), &token, StatusCode::CREATED).await;
|
||||
let saved = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
|
||||
assert_eq!(saved.snapshot.revision, initial.snapshot.revision + 1);
|
||||
assert!(saved.snapshot.entries.keys().any(|p| p.as_str() == "notes/editor-only.txt"));
|
||||
request_json_authenticated(
|
||||
build_router(api.clone()),
|
||||
"POST",
|
||||
&format!("{tree}/commit"),
|
||||
Some(serde_json::to_value(change).unwrap()),
|
||||
&token,
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
let saved = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(saved.snapshot.digest, initial.snapshot.digest);
|
||||
assert!(
|
||||
saved
|
||||
.snapshot
|
||||
.entries
|
||||
.keys()
|
||||
.any(|p| p.as_str() == "notes/editor-only.txt")
|
||||
);
|
||||
// Prompt and Skill runtime consumption are separate read-only projections,
|
||||
// not authored-tree editor APIs; bootstrap must remain usable.
|
||||
assert_eq!(signed_call(api, &identity, Some(&fixture.worker.worker_id), "GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/config/projections/prompts"), Value::Null).await.0,
|
||||
StatusCode::OK);
|
||||
assert_eq!(signed_call(api, &identity, Some(&fixture.worker.worker_id), "GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/skills"), Value::Null).await.0, StatusCode::OK);
|
||||
assert_eq!(
|
||||
signed_call(
|
||||
api,
|
||||
&identity,
|
||||
Some(&fixture.worker.worker_id),
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/config/projections/prompts"),
|
||||
Value::Null
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
signed_call(
|
||||
api,
|
||||
&identity,
|
||||
Some(&fixture.worker.worker_id),
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/skills"),
|
||||
Value::Null
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -836,7 +1073,6 @@ mod workspace_config_integration {
|
||||
let b = attach(api, &second).await;
|
||||
let first = observe(api, &fixture.worker, &a.connection_id, &[""], 0).await;
|
||||
let other = observe(api, &second, &b.connection_id, &[""], 0).await;
|
||||
assert_eq!(first.revision, other.revision);
|
||||
assert_eq!(first.digest, other.digest);
|
||||
assert_ne!(first.validator, other.validator);
|
||||
workspace_config::commit(
|
||||
@@ -871,7 +1107,6 @@ mod workspace_config_integration {
|
||||
validator: file.validator.clone(),
|
||||
};
|
||||
let canonical = config_commit_request_from_api(server_api::ConfigCommitRequest {
|
||||
base_revision: fresh.revision,
|
||||
base_digest: fresh.digest.clone(),
|
||||
entrypoints: fresh.entrypoints.clone(),
|
||||
changes: vec![server_api::ConfigTreeChange::Update {
|
||||
@@ -881,34 +1116,12 @@ mod workspace_config_integration {
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
// Same bytes, new virtual identity/revision via the ORIGINAL UI commit boundary.
|
||||
// Identical bytes retain content identity through the original UI commit boundary.
|
||||
commit_workspace_config_tree(api, TEST_WORKSPACE_ID, &canonical).unwrap();
|
||||
let after = observe(api, &second, &b.connection_id, &["notes/concurrent.md"], 0).await;
|
||||
assert_eq!(after.digest, fresh.digest);
|
||||
assert!(after.revision > fresh.revision);
|
||||
assert_ne!(after.validator, fresh.validator);
|
||||
assert_eq!(
|
||||
workspace_config::read(api, &second, old_read)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"stale_validator"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_config::commit(
|
||||
api,
|
||||
&second,
|
||||
commit_request(
|
||||
&b.connection_id,
|
||||
&fresh,
|
||||
vec![create("notes/stale.md", "stale")]
|
||||
)
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"stale_validator"
|
||||
);
|
||||
assert_eq!(after.validator, fresh.validator);
|
||||
assert!(workspace_config::read(api, &second, old_read).await.is_ok());
|
||||
let delete = vec![server_api::ConfigTreeChange::Delete {
|
||||
path: file.path.clone(),
|
||||
expected_digest: file.digest.clone().unwrap(),
|
||||
|
||||
@@ -475,7 +475,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(after.snapshot.revision, before.snapshot.revision + 1);
|
||||
assert_ne!(after.snapshot.digest, before.snapshot.digest);
|
||||
assert!(
|
||||
after
|
||||
.snapshot
|
||||
|
||||
@@ -71,7 +71,7 @@ pub enum SkillError {
|
||||
NotFound(String),
|
||||
#[error("Skill `{0}` has blocking diagnostics")]
|
||||
InvalidSkill(String),
|
||||
#[error("failed to evaluate the active Workspace config revision: {0}")]
|
||||
#[error("failed to evaluate the active Workspace config content: {0}")]
|
||||
Evaluation(String),
|
||||
#[error("the active Workspace config projection is missing its root value")]
|
||||
MissingProjection,
|
||||
@@ -120,7 +120,6 @@ pub fn lint(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, SkillE
|
||||
|
||||
fn projection_identity(state: &WorkspaceConfigState) -> SkillProjectionIdentity {
|
||||
SkillProjectionIdentity {
|
||||
config_revision: state.snapshot.revision,
|
||||
tree_digest: state.snapshot.digest.clone(),
|
||||
}
|
||||
}
|
||||
@@ -183,7 +182,7 @@ fn merged_skills(
|
||||
kind: SkillSourceKind::Builtin,
|
||||
id: format!("builtin:{BUILTIN_SKILL_ID}"),
|
||||
virtual_path: Some(BUILTIN_SKILL_VIRTUAL_PATH.to_string()),
|
||||
revision: None,
|
||||
|
||||
source_digest: Some(config_source::digest_bytes(BUILTIN_SKILL_SOURCE.as_bytes())),
|
||||
tree_digest: None,
|
||||
},
|
||||
@@ -249,7 +248,7 @@ fn merged_skills(
|
||||
kind: SkillSourceKind::Workspace,
|
||||
id: format!("workspace:{name}"),
|
||||
virtual_path: Some(canonical_path),
|
||||
revision: Some(state.snapshot.revision),
|
||||
|
||||
source_digest: Some(source_digest),
|
||||
tree_digest: Some(state.snapshot.digest.clone()),
|
||||
},
|
||||
@@ -479,29 +478,26 @@ mod tests {
|
||||
fn state(main: &str, markdown: &str, name: &str) -> WorkspaceConfigState {
|
||||
let skill_path = format!("skills/{name}/SKILL.md");
|
||||
let reference_path = format!("skills/{name}/references/checklist.md");
|
||||
let tree = ConfigTreeSnapshot::from_entries(
|
||||
9,
|
||||
[
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
main,
|
||||
)
|
||||
.unwrap(),
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse(&skill_path).unwrap(),
|
||||
ConfigContentType::Text,
|
||||
markdown,
|
||||
)
|
||||
.unwrap(),
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse(&reference_path).unwrap(),
|
||||
ConfigContentType::Text,
|
||||
"checklist",
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
)
|
||||
let tree = ConfigTreeSnapshot::from_entries([
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
main,
|
||||
)
|
||||
.unwrap(),
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse(&skill_path).unwrap(),
|
||||
ConfigContentType::Text,
|
||||
markdown,
|
||||
)
|
||||
.unwrap(),
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse(&reference_path).unwrap(),
|
||||
ConfigContentType::Text,
|
||||
"checklist",
|
||||
)
|
||||
.unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
let bundle = WorkspaceConfigSchemaBundle::compose([SkillConfigSchemaProvider
|
||||
.contribution()
|
||||
@@ -553,8 +549,10 @@ mod tests {
|
||||
.find(|item| item.name == "debug-rust")
|
||||
.unwrap();
|
||||
assert_eq!(item.provenance.kind, SkillSourceKind::Workspace);
|
||||
assert_eq!(item.provenance.revision, Some(9));
|
||||
assert_eq!(catalog.projection.config_revision, 9);
|
||||
assert_eq!(
|
||||
item.provenance.tree_digest.as_deref(),
|
||||
Some(state.snapshot.digest.as_str())
|
||||
);
|
||||
assert_eq!(catalog.projection.tree_digest, state.snapshot.digest);
|
||||
assert_eq!(item.activation_status, SkillActivationStatus::Active);
|
||||
assert_eq!(item.projection_status, SkillProjectionStatus::Valid);
|
||||
@@ -565,7 +563,7 @@ mod tests {
|
||||
);
|
||||
let detail = detail(&state, "debug-rust").unwrap();
|
||||
assert_eq!(detail.authority, SKILL_CATALOG_AUTHORITY);
|
||||
assert_eq!(detail.projection.config_revision, 9);
|
||||
assert_eq!(detail.projection.tree_digest, state.snapshot.digest);
|
||||
assert_eq!(detail.activation_status, SkillActivationStatus::Active);
|
||||
assert_eq!(detail.projection_status, SkillProjectionStatus::Valid);
|
||||
assert_eq!(detail.body, "# Debug Rust\n");
|
||||
|
||||
+960
-3370
File diff suppressed because it is too large
Load Diff
@@ -21,11 +21,11 @@ const MAX_SUGGESTION_BYTES: usize = 512;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketItemCheckInput {
|
||||
/// Canonical internal identity used for an authoritative revision reread.
|
||||
/// Canonical internal identity used for an authoritative content_digest reread.
|
||||
pub ticket_id: String,
|
||||
/// User-facing identity used in checker output and advisory notifications.
|
||||
pub ticket_resource_key: String,
|
||||
pub revision: String,
|
||||
pub content_digest: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub edit: TicketItemCheckEdit,
|
||||
@@ -88,8 +88,8 @@ impl TicketItemCheckFindingCategory {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_revision(ticket: &ticket::Ticket) -> String {
|
||||
ticket::ticket_item_revision(ticket)
|
||||
pub fn content_digest(ticket: &ticket::Ticket) -> String {
|
||||
ticket::ticket_content_digest(ticket)
|
||||
}
|
||||
|
||||
pub fn request(
|
||||
@@ -98,7 +98,7 @@ pub fn request(
|
||||
source_worker: RuntimeWorkerRef,
|
||||
instruction: String,
|
||||
) -> Result<BackendJobRequest> {
|
||||
let revision = item_revision(ticket);
|
||||
let content_digest = content_digest(ticket);
|
||||
let resource_key = ticket
|
||||
.meta
|
||||
.resource_key
|
||||
@@ -107,16 +107,15 @@ pub fn request(
|
||||
let input = TicketItemCheckInput {
|
||||
ticket_id: ticket.meta.id.clone(),
|
||||
ticket_resource_key: resource_key.clone(),
|
||||
revision: revision.clone(),
|
||||
content_digest: content_digest.clone(),
|
||||
title: ticket.meta.title.clone(),
|
||||
body: ticket.document.body.as_str().to_string(),
|
||||
edit,
|
||||
};
|
||||
let request = BackendJobRequest {
|
||||
job_id: format!("ticket-item-check:{}:{revision}", ticket.meta.id),
|
||||
let mut request = BackendJobRequest {
|
||||
job_id: format!("ticket-item-check:{}", ticket.meta.id),
|
||||
purpose: PURPOSE.to_string(),
|
||||
input_revision: revision.clone(),
|
||||
input_ref: format!("ticket://{resource_key}/revisions/{revision}"),
|
||||
input_ref: format!("ticket://{resource_key}/contents/{content_digest}"),
|
||||
input: serde_json::to_value(input).map_err(|error| {
|
||||
Error::InvalidInput(format!("serialize Ticket item checker input: {error}"))
|
||||
})?,
|
||||
@@ -133,6 +132,11 @@ pub fn request(
|
||||
max_attempts: 1,
|
||||
},
|
||||
};
|
||||
// The same item content can result from distinct edits or different source
|
||||
// Workers. Content identity fences advisory freshness, but does not identify
|
||||
// the complete Job intent. Hash the request with its stable Ticket prefix
|
||||
// before adding the digest so exact retries still reuse the same Job.
|
||||
request.job_id = format!("{}:{}", request.job_id, request.fingerprint()?);
|
||||
request.validate()?;
|
||||
Ok(request)
|
||||
}
|
||||
@@ -143,7 +147,7 @@ pub fn parse_input(value: &Value) -> Result<TicketItemCheckInput> {
|
||||
})?;
|
||||
if input.ticket_id.trim().is_empty()
|
||||
|| input.ticket_resource_key.trim().is_empty()
|
||||
|| input.revision.trim().is_empty()
|
||||
|| input.content_digest.trim().is_empty()
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"Ticket item checker input identity is incomplete".to_string(),
|
||||
@@ -217,8 +221,7 @@ pub fn notification(
|
||||
Error::InvalidInput("completed Ticket item checker Job has no result".to_string())
|
||||
})?,
|
||||
)?;
|
||||
if input.revision != acceptance.job.request.input_revision
|
||||
|| input.revision != item_revision(current_ticket)
|
||||
if input.content_digest != content_digest(current_ticket)
|
||||
|| input.ticket_id != current_ticket.meta.id
|
||||
{
|
||||
return Ok(TicketItemCheckNotification::Stale);
|
||||
@@ -228,8 +231,8 @@ pub fn notification(
|
||||
}
|
||||
|
||||
let mut content = format!(
|
||||
"[Ticket item checker advisory]\nTicket: {}\nRevision: {}\n\nThis is a post-save advisory based only on the inspected Ticket text. It is not a new user request, approval gate, workflow blocker, or implementation instruction.\n",
|
||||
input.ticket_resource_key, input.revision
|
||||
"[Ticket item checker advisory]\nTicket: {}\nContent digest: {}\n\nThis is a post-save advisory based only on the inspected Ticket text. It is not a new user request, approval gate, workflow blocker, or implementation instruction.\n",
|
||||
input.ticket_resource_key, input.content_digest
|
||||
);
|
||||
for (index, finding) in result.findings.iter().enumerate() {
|
||||
let quoted = serde_json::to_string(&finding.quote).map_err(|error| {
|
||||
@@ -255,13 +258,79 @@ mod tests {
|
||||
serde_json::json!({
|
||||
"ticket_id": "ticket-internal",
|
||||
"ticket_resource_key": "T-42",
|
||||
"revision": "revision-7",
|
||||
"content_digest": "content_digest-7",
|
||||
"title": "Deploy safely",
|
||||
"body": "This turn only creates the Ticket; I will not implement it. Production deployment requires approval.",
|
||||
"edit": { "kind": "create" }
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checker_job_identity_reuses_exact_intent_but_separates_edits_and_sources() {
|
||||
use ticket::TicketBackend;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(dir.path().join("tickets.db"), "space").unwrap();
|
||||
let (_, ticket) = backend
|
||||
.create_with_snapshot(ticket::NewTicket::new("Check this content"))
|
||||
.unwrap();
|
||||
let source = RuntimeWorkerRef {
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "source".into(),
|
||||
};
|
||||
let build = |edit, source, instruction: &str| {
|
||||
request(&ticket, edit, source, instruction.to_string()).unwrap()
|
||||
};
|
||||
let created = build(
|
||||
TicketItemCheckEdit::Create,
|
||||
source.clone(),
|
||||
"Check the item.",
|
||||
);
|
||||
let replay = build(
|
||||
TicketItemCheckEdit::Create,
|
||||
source.clone(),
|
||||
"Check the item.",
|
||||
);
|
||||
assert_eq!(created.job_id, replay.job_id);
|
||||
assert_eq!(
|
||||
created.fingerprint().unwrap(),
|
||||
replay.fingerprint().unwrap()
|
||||
);
|
||||
|
||||
let edited = build(
|
||||
TicketItemCheckEdit::Edit {
|
||||
title_changed: true,
|
||||
body_changed: false,
|
||||
previous_title: Some("Different prior title".into()),
|
||||
previous_body: None,
|
||||
body_replacement: None,
|
||||
},
|
||||
source.clone(),
|
||||
"Check the item.",
|
||||
);
|
||||
let other_source = build(
|
||||
TicketItemCheckEdit::Create,
|
||||
RuntimeWorkerRef {
|
||||
worker_id: "other-source".into(),
|
||||
..source.clone()
|
||||
},
|
||||
"Check the item.",
|
||||
);
|
||||
let other_instruction = build(
|
||||
TicketItemCheckEdit::Create,
|
||||
source,
|
||||
"Check with new policy.",
|
||||
);
|
||||
for different_intent in [edited, other_source, other_instruction] {
|
||||
assert_eq!(
|
||||
created.input["content_digest"],
|
||||
different_intent.input["content_digest"]
|
||||
);
|
||||
assert_ne!(created.job_id, different_intent.job_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_requires_bounded_findings_grounded_in_the_saved_item() {
|
||||
let valid = serde_json::json!({"findings": [{
|
||||
|
||||
@@ -27,8 +27,23 @@ pub fn request_fingerprint(
|
||||
requested_runtime_id: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
repository_source_fingerprint: &str,
|
||||
repository_source_revision: u64,
|
||||
) -> String {
|
||||
encode_request_digest(request_hasher(
|
||||
repository_id,
|
||||
selector,
|
||||
requested_runtime_id,
|
||||
display_name,
|
||||
repository_source_fingerprint,
|
||||
))
|
||||
}
|
||||
|
||||
fn request_hasher(
|
||||
repository_id: &str,
|
||||
selector: Option<&str>,
|
||||
requested_runtime_id: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
repository_source_fingerprint: &str,
|
||||
) -> Sha256 {
|
||||
let mut hasher = Sha256::new();
|
||||
for value in [
|
||||
Some(repository_id),
|
||||
@@ -46,7 +61,10 @@ pub fn request_fingerprint(
|
||||
None => hasher.update([0]),
|
||||
}
|
||||
}
|
||||
hasher.update(repository_source_revision.to_be_bytes());
|
||||
hasher
|
||||
}
|
||||
|
||||
fn encode_request_digest(hasher: Sha256) -> String {
|
||||
let digest = hasher.finalize();
|
||||
let mut encoded = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
@@ -56,6 +74,52 @@ pub fn request_fingerprint(
|
||||
format!("sha256:{encoded}")
|
||||
}
|
||||
|
||||
/// Resolve replay identity without rewriting the original durable request. Schema 86
|
||||
/// did not persist display_name, so its digest cannot be converted at migration time.
|
||||
/// The frozen ordinal is hash evidence only, never a live Repository precondition.
|
||||
pub fn request_fingerprint_for_replay(
|
||||
repository_id: &str,
|
||||
selector: Option<&str>,
|
||||
requested_runtime_id: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
repository_source_fingerprint: &str,
|
||||
persisted_fingerprint: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let current = request_fingerprint(
|
||||
repository_id,
|
||||
selector,
|
||||
requested_runtime_id,
|
||||
display_name,
|
||||
repository_source_fingerprint,
|
||||
);
|
||||
let Some(persisted) = persisted_fingerprint else {
|
||||
return Ok(current);
|
||||
};
|
||||
if persisted == current {
|
||||
return Ok(current);
|
||||
}
|
||||
if let Some(legacy) = persisted.strip_prefix("workdir-create-v86:") {
|
||||
if let Some((revision, digest)) = legacy.split_once(':') {
|
||||
if let Ok(revision) = revision.parse::<u64>() {
|
||||
let mut hasher = request_hasher(
|
||||
repository_id,
|
||||
selector,
|
||||
requested_runtime_id,
|
||||
display_name,
|
||||
repository_source_fingerprint,
|
||||
);
|
||||
hasher.update(revision.to_be_bytes());
|
||||
if encode_request_digest(hasher) == digest {
|
||||
return Ok(persisted.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::InvalidInput(
|
||||
"Workdir create operation was reused with different input or lacks frozen request identity evidence".into(),
|
||||
))
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn reserve_workdir_create_operation(
|
||||
&self,
|
||||
@@ -66,11 +130,11 @@ impl SqliteWorkspaceStore {
|
||||
tx.execute(
|
||||
r#"INSERT OR IGNORE INTO workdir_create_operations (
|
||||
workspace_id, operation_id, request_fingerprint, repository_id, selector,
|
||||
requested_runtime_id, resolved_runtime_id, config_revision,
|
||||
config_projection_digest, source_kind, source_uri, source_revision,
|
||||
requested_runtime_id, resolved_runtime_id,
|
||||
config_projection_digest, source_kind, source_uri,
|
||||
source_fingerprint, working_directory_id, state, failure,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"#,
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.operation_id,
|
||||
@@ -79,11 +143,9 @@ impl SqliteWorkspaceStore {
|
||||
record.selector,
|
||||
record.requested_runtime_id,
|
||||
record.resolved_runtime_id,
|
||||
record.config_revision as i64,
|
||||
record.config_projection_digest,
|
||||
record.source_kind,
|
||||
record.source_uri,
|
||||
record.source_revision.map(|revision| revision as i64),
|
||||
record.source_fingerprint,
|
||||
record.working_directory_id,
|
||||
record.state,
|
||||
@@ -131,7 +193,7 @@ impl SqliteWorkspaceStore {
|
||||
"Workdir create operation `{operation_id}` was reused with different input"
|
||||
)));
|
||||
}
|
||||
if operation.state != "failed" {
|
||||
if operation.state != "failed" || workdir_create_has_archived_ssh(&tx, workspace_id, operation_id)? {
|
||||
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||
"Workdir create operation `{operation_id}` is not a failed retry"
|
||||
)));
|
||||
@@ -176,16 +238,17 @@ impl SqliteWorkspaceStore {
|
||||
operation_id: &str,
|
||||
request_fingerprint: &str,
|
||||
credential_id: &str,
|
||||
credential_revision: u64,
|
||||
credential_fingerprint: &str,
|
||||
host_trust_id: &str,
|
||||
host_trust_revision: u64,
|
||||
host_trust_fingerprint: &str,
|
||||
repository_access_mode: &str,
|
||||
credential_candidates: &[WorkdirCreateCredentialCandidate],
|
||||
now: &str,
|
||||
) -> Result<WorkdirCreateOperationRecord> {
|
||||
validate_content_fingerprint(host_trust_fingerprint)?;
|
||||
validate_workdir_create_credential_candidates(
|
||||
credential_id,
|
||||
credential_revision,
|
||||
credential_fingerprint,
|
||||
credential_candidates,
|
||||
)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
@@ -201,11 +264,14 @@ impl SqliteWorkspaceStore {
|
||||
"Workdir create operation `{operation_id}` was reused with different input"
|
||||
)));
|
||||
}
|
||||
if operation.state != "pending" || workdir_create_has_archived_ssh(&tx, workspace_id, operation_id)? {
|
||||
return Err(Error::WorkdirAttachmentConflict("Only a pending, nonarchived Workdir create can bind Repository access".into()));
|
||||
}
|
||||
if let Some(existing) = operation.credential_id.as_deref() {
|
||||
if existing != credential_id
|
||||
|| operation.credential_revision != Some(credential_revision)
|
||||
|| operation.credential_fingerprint.as_deref() != Some(credential_fingerprint)
|
||||
|| operation.host_trust_id.as_deref() != Some(host_trust_id)
|
||||
|| operation.host_trust_revision != Some(host_trust_revision)
|
||||
|| operation.host_trust_fingerprint.as_deref() != Some(host_trust_fingerprint)
|
||||
|| operation.repository_access_mode.as_deref()
|
||||
!= Some(repository_access_mode)
|
||||
|| operation.credential_candidates != credential_candidates
|
||||
@@ -218,8 +284,8 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
let updated = tx.execute(
|
||||
r#"UPDATE workdir_create_operations
|
||||
SET credential_id = ?4, credential_revision = ?5,
|
||||
host_trust_id = ?6, host_trust_revision = ?7,
|
||||
SET credential_id = ?4, credential_fingerprint = ?5,
|
||||
host_trust_id = ?6, host_trust_fingerprint = ?7,
|
||||
repository_access_mode = ?8, updated_at = ?9
|
||||
WHERE workspace_id = ?1 AND operation_id = ?2
|
||||
AND request_fingerprint = ?3 AND credential_id IS NULL"#,
|
||||
@@ -228,13 +294,9 @@ impl SqliteWorkspaceStore {
|
||||
operation_id,
|
||||
request_fingerprint,
|
||||
credential_id,
|
||||
i64::try_from(credential_revision).map_err(|_| Error::InvalidInput(
|
||||
"credential revision is out of range".to_string()
|
||||
))?,
|
||||
credential_fingerprint,
|
||||
host_trust_id,
|
||||
i64::try_from(host_trust_revision).map_err(|_| Error::InvalidInput(
|
||||
"host-trust revision is out of range".to_string()
|
||||
))?,
|
||||
host_trust_fingerprint,
|
||||
repository_access_mode,
|
||||
now,
|
||||
],
|
||||
@@ -248,7 +310,7 @@ impl SqliteWorkspaceStore {
|
||||
tx.execute(
|
||||
r#"INSERT INTO workdir_create_credential_candidates (
|
||||
workspace_id, operation_id, ordinal, role,
|
||||
credential_id, credential_revision
|
||||
credential_id, credential_fingerprint
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
|
||||
params![
|
||||
workspace_id,
|
||||
@@ -258,18 +320,21 @@ impl SqliteWorkspaceStore {
|
||||
))?,
|
||||
candidate.role.as_str(),
|
||||
candidate.credential_id,
|
||||
i64::try_from(candidate.credential_revision).map_err(|_| {
|
||||
Error::InvalidInput(
|
||||
"credential candidate revision is out of range".to_string(),
|
||||
)
|
||||
})?,
|
||||
candidate.credential_fingerprint,
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO workdir_create_credential_revision_retentions (
|
||||
let retained = tx.execute(
|
||||
r#"INSERT INTO workdir_create_credential_retentions (
|
||||
workspace_id, operation_id, ordinal,
|
||||
credential_id, credential_revision
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)"#,
|
||||
credential_id, credential_fingerprint, credential_operation_id
|
||||
) SELECT ?1, ?2, ?3, ?4, ?5, k.operation_id
|
||||
FROM repository_ssh_credential_keys k
|
||||
JOIN repository_ssh_credentials c
|
||||
ON c.workspace_id=k.workspace_id AND c.credential_id=k.credential_id
|
||||
WHERE k.workspace_id=?1 AND k.credential_id=?4 AND k.public_key_fingerprint=?5
|
||||
AND c.status='active'
|
||||
ORDER BY (k.operation_id=c.current_operation_id) DESC,
|
||||
k.created_at DESC, k.operation_id DESC LIMIT 1"#,
|
||||
params![
|
||||
workspace_id,
|
||||
operation_id,
|
||||
@@ -277,14 +342,11 @@ impl SqliteWorkspaceStore {
|
||||
"credential candidate ordinal is out of range".to_string()
|
||||
))?,
|
||||
candidate.credential_id,
|
||||
i64::try_from(candidate.credential_revision).map_err(|_| {
|
||||
Error::InvalidInput(
|
||||
"credential candidate revision is out of range".to_string(),
|
||||
)
|
||||
})?,
|
||||
candidate.credential_fingerprint,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
if retained != 1 { return Err(Error::RegistryInconsistency("Workdir create credential fingerprint has no stored key".into())); } }
|
||||
let bound = read_workdir_create_operation(&tx, workspace_id, operation_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
@@ -307,11 +369,31 @@ impl SqliteWorkspaceStore {
|
||||
) -> Result<WorkdirCreateOperationRecord> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(
|
||||
"Workdir create disappeared before finalization".into(),
|
||||
)
|
||||
})?;
|
||||
let target_state = if succeeded { "succeeded" } else { "failed" };
|
||||
if operation.request_fingerprint != request_fingerprint {
|
||||
return Err(Error::InvalidInput(
|
||||
"Workdir create operation was reused with different input".into(),
|
||||
));
|
||||
}
|
||||
if operation.state != "pending" {
|
||||
if operation.state == target_state && operation.failure.as_deref() == failure {
|
||||
return Ok(operation);
|
||||
}
|
||||
return Err(Error::WorkdirAttachmentConflict(
|
||||
"A completed Workdir create cannot be finalized into another state".into(),
|
||||
));
|
||||
}
|
||||
let changed = tx.execute(
|
||||
r#"UPDATE workdir_create_operations
|
||||
SET state = ?1, failure = ?2, updated_at = ?3
|
||||
WHERE workspace_id = ?4 AND operation_id = ?5
|
||||
AND request_fingerprint = ?6"#,
|
||||
AND request_fingerprint = ?6 AND state='pending'"#,
|
||||
params![
|
||||
if succeeded { "succeeded" } else { "failed" },
|
||||
failure,
|
||||
@@ -328,7 +410,7 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
if succeeded {
|
||||
tx.execute(
|
||||
r#"DELETE FROM workdir_create_credential_revision_retentions
|
||||
r#"DELETE FROM workdir_create_credential_retentions
|
||||
WHERE workspace_id = ?1 AND operation_id = ?2"#,
|
||||
params![workspace_id, operation_id],
|
||||
)?;
|
||||
@@ -344,6 +426,24 @@ impl SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Frozen, nonauthorizing historical data. Never use this payload for a retry or lease.
|
||||
pub fn load_workdir_create_legacy_ssh_archive(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
) -> Result<Option<(serde_json::Value, serde_json::Value)>> {
|
||||
self.with_conn(|conn| {
|
||||
let archive: Option<(String, String)> = conn.query_row(
|
||||
"SELECT operation_json,candidates_json FROM workdir_create_legacy_ssh_archives WHERE workspace_id=?1 AND operation_id=?2",
|
||||
params![workspace_id, operation_id], |r| Ok((r.get(0)?, r.get(1)?)),
|
||||
).optional()?;
|
||||
archive.map(|(operation, candidates)| Ok((
|
||||
serde_json::from_str(&operation).map_err(|e| Error::Store(e.to_string()))?,
|
||||
serde_json::from_str(&candidates).map_err(|e| Error::Store(e.to_string()))?,
|
||||
))).transpose()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_workdir_create_operation(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -353,6 +453,14 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn workdir_create_has_archived_ssh(
|
||||
conn: &rusqlite::Connection,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
) -> Result<bool> {
|
||||
Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM workdir_create_legacy_ssh_archives WHERE workspace_id=?1 AND operation_id=?2)", params![workspace_id,operation_id], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
fn read_workdir_create_operation(
|
||||
conn: &rusqlite::Connection,
|
||||
workspace_id: &str,
|
||||
@@ -361,10 +469,10 @@ fn read_workdir_create_operation(
|
||||
let mut operation = conn
|
||||
.query_row(
|
||||
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
|
||||
requested_runtime_id, resolved_runtime_id, config_revision,
|
||||
config_projection_digest, source_kind, source_uri, source_revision,
|
||||
source_fingerprint, credential_id, credential_revision,
|
||||
host_trust_id, host_trust_revision, repository_access_mode,
|
||||
requested_runtime_id, resolved_runtime_id,
|
||||
config_projection_digest, source_kind, source_uri,
|
||||
source_fingerprint, credential_id, credential_fingerprint,
|
||||
host_trust_id, host_trust_fingerprint, repository_access_mode,
|
||||
working_directory_id, state, failure,
|
||||
created_at, updated_at
|
||||
FROM workdir_create_operations
|
||||
@@ -379,23 +487,21 @@ fn read_workdir_create_operation(
|
||||
selector: row.get(4)?,
|
||||
requested_runtime_id: row.get(5)?,
|
||||
resolved_runtime_id: row.get(6)?,
|
||||
config_revision: row.get::<_, i64>(7)? as u64,
|
||||
config_projection_digest: row.get(8)?,
|
||||
source_kind: row.get(9)?,
|
||||
source_uri: row.get(10)?,
|
||||
source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
|
||||
source_fingerprint: row.get(12)?,
|
||||
credential_id: row.get(13)?,
|
||||
credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64),
|
||||
host_trust_id: row.get(15)?,
|
||||
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
|
||||
repository_access_mode: row.get(17)?,
|
||||
config_projection_digest: row.get(7)?,
|
||||
source_kind: row.get(8)?,
|
||||
source_uri: row.get(9)?,
|
||||
source_fingerprint: row.get(10)?,
|
||||
credential_id: row.get(11)?,
|
||||
credential_fingerprint: row.get(12)?,
|
||||
host_trust_id: row.get(13)?,
|
||||
host_trust_fingerprint: row.get(14)?,
|
||||
repository_access_mode: row.get(15)?,
|
||||
credential_candidates: Vec::new(),
|
||||
working_directory_id: row.get(18)?,
|
||||
state: row.get(19)?,
|
||||
failure: row.get(20)?,
|
||||
created_at: row.get(21)?,
|
||||
updated_at: row.get(22)?,
|
||||
working_directory_id: row.get(16)?,
|
||||
state: row.get(17)?,
|
||||
failure: row.get(18)?,
|
||||
created_at: row.get(19)?,
|
||||
updated_at: row.get(20)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -403,7 +509,7 @@ fn read_workdir_create_operation(
|
||||
|
||||
if let Some(operation) = operation.as_mut() {
|
||||
let mut statement = conn.prepare(
|
||||
r#"SELECT ordinal, role, credential_id, credential_revision
|
||||
r#"SELECT ordinal, role, credential_id, credential_fingerprint
|
||||
FROM workdir_create_credential_candidates
|
||||
WHERE workspace_id = ?1 AND operation_id = ?2
|
||||
ORDER BY ordinal ASC"#,
|
||||
@@ -413,11 +519,11 @@ fn read_workdir_create_operation(
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})?;
|
||||
for (expected_ordinal, row) in rows.enumerate() {
|
||||
let (ordinal, role, credential_id, credential_revision) = row?;
|
||||
let (ordinal, role, credential_id, credential_fingerprint) = row?;
|
||||
if ordinal
|
||||
!= i64::try_from(expected_ordinal).map_err(|_| {
|
||||
Error::Store(
|
||||
@@ -434,17 +540,16 @@ fn read_workdir_create_operation(
|
||||
.push(WorkdirCreateCredentialCandidate {
|
||||
role: WorkdirCreateCredentialCandidateRole::parse(&role)?,
|
||||
credential_id,
|
||||
credential_revision: u64::try_from(credential_revision).map_err(|_| {
|
||||
Error::Store(format!(
|
||||
"invalid Workdir create credential candidate revision `{credential_revision}`"
|
||||
))
|
||||
})?,
|
||||
credential_fingerprint,
|
||||
});
|
||||
}
|
||||
if !operation.credential_candidates.is_empty() {
|
||||
validate_workdir_create_credential_candidates(
|
||||
operation.credential_id.as_deref().unwrap_or_default(),
|
||||
operation.credential_revision.unwrap_or_default(),
|
||||
operation
|
||||
.credential_fingerprint
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
&operation.credential_candidates,
|
||||
)
|
||||
.map_err(|error| {
|
||||
@@ -457,9 +562,21 @@ fn read_workdir_create_operation(
|
||||
Ok(operation)
|
||||
}
|
||||
|
||||
fn validate_content_fingerprint(fingerprint: &str) -> Result<()> {
|
||||
if fingerprint.trim().is_empty()
|
||||
|| fingerprint.len() > 256
|
||||
|| fingerprint.chars().any(char::is_control)
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"Workdir create content fingerprint is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_workdir_create_credential_candidates(
|
||||
credential_id: &str,
|
||||
credential_revision: u64,
|
||||
credential_fingerprint: &str,
|
||||
candidates: &[WorkdirCreateCredentialCandidate],
|
||||
) -> Result<()> {
|
||||
if candidates.is_empty() || candidates.len() > MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES {
|
||||
@@ -472,15 +589,11 @@ pub(crate) fn validate_workdir_create_credential_candidates(
|
||||
"Workdir create credential id is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
if credential_revision == 0 {
|
||||
return Err(Error::InvalidInput(
|
||||
"Workdir create credential revision must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
validate_content_fingerprint(credential_fingerprint)?;
|
||||
let primary = &candidates[0];
|
||||
if primary.role != WorkdirCreateCredentialCandidateRole::Primary
|
||||
|| primary.credential_id != credential_id
|
||||
|| primary.credential_revision != credential_revision
|
||||
|| primary.credential_fingerprint != credential_fingerprint
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"Workdir create primary credential evidence does not match the ordered candidate snapshot"
|
||||
@@ -494,10 +607,13 @@ pub(crate) fn validate_workdir_create_credential_candidates(
|
||||
"Workdir create fallback credential role is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
for candidate in candidates {
|
||||
validate_content_fingerprint(&candidate.credential_fingerprint)?;
|
||||
}
|
||||
if candidates.iter().any(|candidate| {
|
||||
candidate.credential_id.is_empty()
|
||||
|| candidate.credential_id.len() > MAX_CREDENTIAL_ID_BYTES
|
||||
|| candidate.credential_revision == 0
|
||||
|| candidate.credential_fingerprint.is_empty()
|
||||
}) {
|
||||
return Err(Error::InvalidInput(
|
||||
"Workdir create credential candidate identity is invalid".to_string(),
|
||||
@@ -516,6 +632,106 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord};
|
||||
|
||||
#[test]
|
||||
fn request_identity_depends_on_content_and_distinguishes_optional_values() {
|
||||
let original = request_fingerprint("main", None, None, None, "sha256:source-a");
|
||||
assert_eq!(
|
||||
original,
|
||||
request_fingerprint("main", None, None, None, "sha256:source-a")
|
||||
);
|
||||
assert_ne!(
|
||||
original,
|
||||
request_fingerprint("main", None, None, None, "sha256:source-b")
|
||||
);
|
||||
assert_ne!(
|
||||
original,
|
||||
request_fingerprint("main", Some(""), None, None, "sha256:source-a")
|
||||
);
|
||||
assert_ne!(
|
||||
request_fingerprint("a", Some("bc"), None, None, "source"),
|
||||
request_fingerprint("ab", Some("c"), None, None, "source")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_request_replay_checks_every_input_and_keeps_original_identity() {
|
||||
// Digest emitted by the schema-86 algorithm, including source ordinal 9.
|
||||
let legacy = "workdir-create-v86:9:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b";
|
||||
assert_eq!(
|
||||
request_fingerprint_for_replay(
|
||||
"repo",
|
||||
Some("main"),
|
||||
None,
|
||||
Some("Name"),
|
||||
"source",
|
||||
Some(legacy)
|
||||
)
|
||||
.unwrap(),
|
||||
legacy,
|
||||
);
|
||||
for (repo, selector, runtime, name, source) in [
|
||||
("other", Some("main"), None, Some("Name"), "source"),
|
||||
("repo", Some("develop"), None, Some("Name"), "source"),
|
||||
(
|
||||
"repo",
|
||||
Some("main"),
|
||||
Some("runtime"),
|
||||
Some("Name"),
|
||||
"source",
|
||||
),
|
||||
("repo", Some("main"), None, None, "source"),
|
||||
("repo", Some("main"), None, Some("Other name"), "source"),
|
||||
("repo", Some("main"), None, Some("Name"), "changed-source"),
|
||||
] {
|
||||
assert!(
|
||||
request_fingerprint_for_replay(repo, selector, runtime, name, source, Some(legacy))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for invalid in [
|
||||
"workdir-create-v86:unknown:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b",
|
||||
"workdir-create-v86:8:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b",
|
||||
"workdir-create-v86:9:sha256:bad",
|
||||
] {
|
||||
assert!(
|
||||
request_fingerprint_for_replay(
|
||||
"repo",
|
||||
Some("main"),
|
||||
None,
|
||||
Some("Name"),
|
||||
"source",
|
||||
Some(invalid)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let current = request_fingerprint("repo", Some("main"), None, Some("Name"), "source");
|
||||
assert_eq!(
|
||||
request_fingerprint_for_replay(
|
||||
"repo",
|
||||
Some("main"),
|
||||
None,
|
||||
Some("Name"),
|
||||
"source",
|
||||
Some(¤t)
|
||||
)
|
||||
.unwrap(),
|
||||
current
|
||||
);
|
||||
assert_eq!(
|
||||
request_fingerprint_for_replay(
|
||||
"repo",
|
||||
Some("main"),
|
||||
None,
|
||||
Some("Name"),
|
||||
"source",
|
||||
None
|
||||
)
|
||||
.unwrap(),
|
||||
current
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_selector_keeps_persisted_default_but_honors_explicit_input() {
|
||||
assert_eq!(
|
||||
@@ -556,7 +772,6 @@ mod tests {
|
||||
uri: "/tmp/main".to_string(),
|
||||
},
|
||||
default_ref: Some("develop".to_string()),
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: server_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
@@ -573,22 +788,19 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
"sha256:test",
|
||||
1,
|
||||
),
|
||||
repository_id: "main".to_string(),
|
||||
selector: Some("develop".to_string()),
|
||||
requested_runtime_id: None,
|
||||
resolved_runtime_id: "arcadia".to_string(),
|
||||
config_revision: 7,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
source_kind: Some("local_path".to_string()),
|
||||
source_uri: Some("/tmp/repo".to_string()),
|
||||
source_revision: Some(1),
|
||||
source_fingerprint: Some("sha256:source".to_string()),
|
||||
credential_id: None,
|
||||
credential_revision: None,
|
||||
credential_fingerprint: None,
|
||||
host_trust_id: None,
|
||||
host_trust_revision: None,
|
||||
host_trust_fingerprint: None,
|
||||
repository_access_mode: None,
|
||||
credential_candidates: Vec::new(),
|
||||
working_directory_id: "wd-1".to_string(),
|
||||
@@ -603,25 +815,26 @@ mod tests {
|
||||
);
|
||||
store
|
||||
.with_conn_mut(|conn| {
|
||||
for (credential_id, revision) in
|
||||
[("credential-1", 3_i64), ("workspace-default-ssh", 7_i64)]
|
||||
{
|
||||
for (credential_id, fingerprint) in [
|
||||
("credential-1", "key-a"),
|
||||
("workspace-default-ssh", "key-fallback"),
|
||||
] {
|
||||
conn.execute(
|
||||
r#"INSERT INTO repository_ssh_credentials (
|
||||
workspace_id, credential_id, name,
|
||||
public_key_algorithm, public_key_fingerprint,
|
||||
current_revision, status, created_at
|
||||
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?1, ?2,
|
||||
'active', '2026-08-24T00:00:00Z')"#,
|
||||
params![credential_id, revision],
|
||||
current_operation_id, status, created_at
|
||||
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?2,
|
||||
?1, 'active', '2026-08-24T00:00:00Z')"#,
|
||||
params![credential_id, fingerprint],
|
||||
)?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO repository_ssh_credential_revisions (
|
||||
workspace_id, credential_id, revision,
|
||||
r#"INSERT INTO repository_ssh_credential_keys (
|
||||
workspace_id, credential_id, operation_id,
|
||||
public_key_algorithm, public_key_fingerprint, created_at
|
||||
) VALUES ('workspace', ?1, ?2, 'ssh-ed25519', ?1,
|
||||
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?2,
|
||||
'2026-08-24T00:00:00Z')"#,
|
||||
params![credential_id, revision],
|
||||
params![credential_id, fingerprint],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -631,12 +844,12 @@ mod tests {
|
||||
WorkdirCreateCredentialCandidate {
|
||||
role: WorkdirCreateCredentialCandidateRole::Primary,
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 3,
|
||||
credential_fingerprint: "key-a".to_string(),
|
||||
},
|
||||
WorkdirCreateCredentialCandidate {
|
||||
role: WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback,
|
||||
credential_id: "workspace-default-ssh".to_string(),
|
||||
credential_revision: 7,
|
||||
credential_fingerprint: "key-fallback".to_string(),
|
||||
},
|
||||
];
|
||||
let bound = store
|
||||
@@ -645,26 +858,26 @@ mod tests {
|
||||
"call-1",
|
||||
&record.request_fingerprint,
|
||||
"credential-1",
|
||||
3,
|
||||
"key-a",
|
||||
"trust-1",
|
||||
5,
|
||||
"trust-a",
|
||||
"read_only",
|
||||
&candidates,
|
||||
"2026-08-24T00:00:01Z",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
|
||||
assert_eq!(bound.credential_revision, Some(3));
|
||||
assert_eq!(bound.host_trust_revision, Some(5));
|
||||
assert_eq!(bound.credential_fingerprint.as_deref(), Some("key-a"));
|
||||
assert_eq!(bound.host_trust_fingerprint.as_deref(), Some("trust-a"));
|
||||
assert_eq!(bound.credential_candidates, candidates);
|
||||
let serialized = serde_json::to_string(&bound).unwrap();
|
||||
assert!(serialized.contains("workspace_default_fallback"));
|
||||
assert!(!serialized.contains("private_key"));
|
||||
assert!(!serialized.contains("known_hosts"));
|
||||
// A concurrent Workspace-default rotation must not replace the fallback
|
||||
// revision already bound to this operation.
|
||||
// fingerprint already bound to this operation.
|
||||
let mut changed_candidates = candidates.clone();
|
||||
changed_candidates[1].credential_revision = 8;
|
||||
changed_candidates[1].credential_fingerprint = "key-rotated".to_string();
|
||||
assert!(
|
||||
store
|
||||
.bind_workdir_create_repository_access(
|
||||
@@ -672,9 +885,9 @@ mod tests {
|
||||
"call-1",
|
||||
&record.request_fingerprint,
|
||||
"credential-1",
|
||||
3,
|
||||
"key-a",
|
||||
"trust-1",
|
||||
5,
|
||||
"trust-a",
|
||||
"read_only",
|
||||
&changed_candidates,
|
||||
"2026-08-24T00:00:02Z",
|
||||
@@ -683,9 +896,9 @@ mod tests {
|
||||
);
|
||||
let mut changed_resolution = record.clone();
|
||||
changed_resolution.resolved_runtime_id = "other".to_string();
|
||||
changed_resolution.config_revision = 8;
|
||||
changed_resolution.config_projection_digest = "sha256:changed".to_string();
|
||||
changed_resolution.source_uri = Some("ssh://git@other.test/repo.git".to_string());
|
||||
changed_resolution.source_revision = Some(9);
|
||||
changed_resolution.source_fingerprint = Some("sha256:changed-source".to_string());
|
||||
let replayed = store
|
||||
.reserve_workdir_create_operation(&changed_resolution)
|
||||
.unwrap();
|
||||
@@ -721,7 +934,7 @@ mod tests {
|
||||
);
|
||||
let mut changed_input = record.clone();
|
||||
changed_input.request_fingerprint =
|
||||
request_fingerprint("main", Some("main"), None, None, "sha256:test", 1);
|
||||
request_fingerprint("main", Some("main"), None, None, "sha256:test");
|
||||
assert!(
|
||||
store
|
||||
.reserve_workdir_create_operation(&changed_input)
|
||||
|
||||
@@ -75,7 +75,7 @@ pub struct WorkdirRemovalOperation {
|
||||
pub source_actor: String,
|
||||
pub reason: String,
|
||||
pub state: WorkdirRemovalOperationState,
|
||||
pub attempt_count: u64,
|
||||
pub attempt_id: Option<String>,
|
||||
pub retryable: bool,
|
||||
pub disposition: Option<WorkdirRemovalDisposition>,
|
||||
pub failure_category: Option<String>,
|
||||
@@ -202,9 +202,9 @@ impl SqliteWorkspaceStore {
|
||||
r#"INSERT INTO workdir_removal_operations (
|
||||
workspace_id, operation_id, request_fingerprint, workdir_id, runtime_id,
|
||||
repository_id, materialization_fingerprint, source_actor, reason, state,
|
||||
attempt_count, retryable, disposition, failure_category,
|
||||
attempt_id, retryable, disposition, failure_category,
|
||||
created_at, updated_at, completed_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', 0, 1, NULL, NULL, ?10, ?10, NULL)"#,
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', NULL, 1, NULL, NULL, ?10, ?10, NULL)"#,
|
||||
params![
|
||||
intent.workspace_id,
|
||||
intent.operation_id,
|
||||
@@ -248,14 +248,14 @@ impl SqliteWorkspaceStore {
|
||||
request_fingerprint: &str,
|
||||
owner: WorkdirRemovalAttemptOwner,
|
||||
expected_prior_owner: Option<WorkdirRemovalAttemptOwner>,
|
||||
expected_attempt_count: u64,
|
||||
expected_attempt_id: Option<&str>,
|
||||
) -> Result<WorkdirRemovalOperation> {
|
||||
self.begin_workdir_removal_attempt_inner(
|
||||
workspace_id,
|
||||
operation_id,
|
||||
request_fingerprint,
|
||||
owner,
|
||||
Some((expected_prior_owner, expected_attempt_count)),
|
||||
Some((expected_prior_owner, expected_attempt_id)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ impl SqliteWorkspaceStore {
|
||||
operation_id: &str,
|
||||
request_fingerprint: &str,
|
||||
owner: WorkdirRemovalAttemptOwner,
|
||||
recovery_expected: Option<(Option<WorkdirRemovalAttemptOwner>, u64)>,
|
||||
recovery_expected: Option<(Option<WorkdirRemovalAttemptOwner>, Option<&str>)>,
|
||||
) -> Result<WorkdirRemovalOperation> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
@@ -282,17 +282,17 @@ impl SqliteWorkspaceStore {
|
||||
"Workdir removal operation `{operation_id}` is not retryable"
|
||||
)));
|
||||
}
|
||||
if let Some((expected_owner, expected_attempt_count)) = recovery_expected {
|
||||
if let Some((expected_owner, expected_attempt_id)) = recovery_expected {
|
||||
if operation.state != WorkdirRemovalOperationState::Pending
|
||||
|| operation.attempt_owner != expected_owner
|
||||
|| operation.attempt_count != expected_attempt_count
|
||||
|| operation.attempt_id.as_deref() != expected_attempt_id
|
||||
{
|
||||
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||
"Workdir removal operation `{operation_id}` changed after orphan proof"
|
||||
)));
|
||||
}
|
||||
} else if operation.state == WorkdirRemovalOperationState::Pending
|
||||
&& operation.attempt_count > 0
|
||||
&& operation.attempt_id.is_some()
|
||||
{
|
||||
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||
"Workdir removal operation `{operation_id}` already has an active attempt"
|
||||
@@ -300,7 +300,7 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
tx.execute(
|
||||
"UPDATE workdir_removal_operations SET state='pending', attempt_count=attempt_count+1, retryable=1, failure_category=NULL, disposition=NULL, attempt_owner_pid=?1, attempt_owner_start_marker=?2, updated_at=?3, completed_at=NULL WHERE workspace_id=?4 AND operation_id=?5 AND request_fingerprint=?6",
|
||||
"UPDATE workdir_removal_operations SET state='pending', attempt_id=?7, retryable=1, failure_category=NULL, disposition=NULL, attempt_owner_pid=?1, attempt_owner_start_marker=?2, updated_at=?3, completed_at=NULL WHERE workspace_id=?4 AND operation_id=?5 AND request_fingerprint=?6",
|
||||
params![
|
||||
owner.process_id,
|
||||
i64::try_from(owner.process_start_marker).map_err(|_| {
|
||||
@@ -312,6 +312,7 @@ impl SqliteWorkspaceStore {
|
||||
workspace_id,
|
||||
operation_id,
|
||||
request_fingerprint,
|
||||
uuid::Uuid::now_v7().to_string(),
|
||||
],
|
||||
)?;
|
||||
let operation =
|
||||
@@ -462,6 +463,7 @@ impl SqliteWorkspaceStore {
|
||||
tx.commit()?;
|
||||
return Ok(current);
|
||||
}
|
||||
require_matching_attempt(operation, ¤t)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
tx.execute(
|
||||
"UPDATE workdir_removal_operations SET state='failed', retryable=?1, disposition=?2, failure_category=?3, attempt_owner_pid=NULL, attempt_owner_start_marker=NULL, updated_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7",
|
||||
@@ -519,6 +521,7 @@ impl SqliteWorkspaceStore {
|
||||
tx.commit()?;
|
||||
return Ok(current);
|
||||
}
|
||||
require_matching_attempt(operation, ¤t)?;
|
||||
if delete_registry {
|
||||
let registry = load_workdir_record(
|
||||
&tx,
|
||||
@@ -616,6 +619,22 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn require_matching_attempt(
|
||||
expected: &WorkdirRemovalOperation,
|
||||
current: &WorkdirRemovalOperation,
|
||||
) -> Result<()> {
|
||||
if current.state != WorkdirRemovalOperationState::Pending
|
||||
|| expected.attempt_id != current.attempt_id
|
||||
|| expected.attempt_owner != current.attempt_owner
|
||||
{
|
||||
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||
"Workdir removal operation `{}` execution attempt changed",
|
||||
expected.operation_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_no_removal_blockers(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
@@ -804,7 +823,7 @@ fn load_operation(
|
||||
fn operation_select_sql() -> &'static str {
|
||||
r#"SELECT operation_id, request_fingerprint, workspace_id, workdir_id, runtime_id,
|
||||
repository_id, materialization_fingerprint, source_actor, reason, state,
|
||||
attempt_count, retryable, disposition, failure_category,
|
||||
attempt_id, retryable, disposition, failure_category,
|
||||
attempt_owner_pid, attempt_owner_start_marker,
|
||||
created_at, updated_at, completed_at
|
||||
FROM workdir_removal_operations"#
|
||||
@@ -816,7 +835,7 @@ fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkdirRemovalOpe
|
||||
.get::<_, Option<String>>(12)?
|
||||
.map(|value| parse_disposition(&value))
|
||||
.transpose()?;
|
||||
let attempt_count = row.get::<_, i64>(10)?;
|
||||
let attempt_id = row.get::<_, Option<String>>(10)?;
|
||||
let attempt_owner_pid = row.get::<_, Option<i64>>(14)?;
|
||||
let attempt_owner_start_marker = row.get::<_, Option<i64>>(15)?;
|
||||
let attempt_owner = match (attempt_owner_pid, attempt_owner_start_marker) {
|
||||
@@ -848,9 +867,7 @@ fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkdirRemovalOpe
|
||||
source_actor: row.get(7)?,
|
||||
reason: row.get(8)?,
|
||||
state,
|
||||
attempt_count: attempt_count
|
||||
.try_into()
|
||||
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(10, attempt_count))?,
|
||||
attempt_id,
|
||||
retryable: row.get(11)?,
|
||||
disposition,
|
||||
failure_category: row.get(13)?,
|
||||
@@ -942,7 +959,6 @@ mod tests {
|
||||
uri: "/repository-a".to_string(),
|
||||
},
|
||||
default_ref: Some("develop".to_string()),
|
||||
source_revision: 1,
|
||||
source_fingerprint: "source-a".to_string(),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
@@ -1006,7 +1022,7 @@ mod tests {
|
||||
attempt_owner(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first.attempt_count, 1);
|
||||
assert!(first.attempt_id.is_some());
|
||||
let failed = store
|
||||
.fail_workdir_removal_operation(&first, "runtime_unavailable", true)
|
||||
.unwrap();
|
||||
@@ -1019,7 +1035,7 @@ mod tests {
|
||||
attempt_owner(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(retry.attempt_count, 2);
|
||||
assert_ne!(retry.attempt_id, first.attempt_id);
|
||||
let completed = store.commit_workdir_removal_removed(&retry).unwrap();
|
||||
assert_eq!(
|
||||
completed.disposition,
|
||||
@@ -1042,6 +1058,58 @@ mod tests {
|
||||
assert_eq!(replay, completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_and_completion_are_fenced_by_attempt_identity() {
|
||||
let (store, workdir) = seeded_store().await;
|
||||
let intent = workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap();
|
||||
let reserved = store.reserve_workdir_removal_operation(&intent).unwrap();
|
||||
let first = store
|
||||
.begin_workdir_removal_attempt(
|
||||
&reserved.workspace_id,
|
||||
&reserved.operation_id,
|
||||
&reserved.request_fingerprint,
|
||||
attempt_owner(),
|
||||
)
|
||||
.unwrap();
|
||||
let recovered = store
|
||||
.reclaim_workdir_removal_attempt_for_recovery(
|
||||
&first.workspace_id,
|
||||
&first.operation_id,
|
||||
&first.request_fingerprint,
|
||||
attempt_owner(),
|
||||
first.attempt_owner,
|
||||
first.attempt_id.as_deref(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(first.attempt_id, recovered.attempt_id);
|
||||
assert!(matches!(
|
||||
store.reclaim_workdir_removal_attempt_for_recovery(
|
||||
&first.workspace_id,
|
||||
&first.operation_id,
|
||||
&first.request_fingerprint,
|
||||
attempt_owner(),
|
||||
first.attempt_owner,
|
||||
first.attempt_id.as_deref(),
|
||||
),
|
||||
Err(Error::WorkdirAttachmentConflict(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.fail_workdir_removal_operation(&first, "late_failure", true),
|
||||
Err(Error::WorkdirAttachmentConflict(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.commit_workdir_removal_removed(&first),
|
||||
Err(Error::WorkdirAttachmentConflict(_))
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.get_workdir_registry("workspace-a", "workdir-a")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
store.commit_workdir_removal_removed(&recovered).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_claims_invoke_the_simulated_provider_once() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -1109,16 +1177,14 @@ mod tests {
|
||||
selector: Some("develop".to_string()),
|
||||
requested_runtime_id: Some("runtime-a".to_string()),
|
||||
resolved_runtime_id: "runtime-a".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "projection-a".to_string(),
|
||||
source_kind: Some("local_path".to_string()),
|
||||
source_uri: Some("/repository-a".to_string()),
|
||||
source_revision: Some(1),
|
||||
source_fingerprint: Some("source-a".to_string()),
|
||||
credential_id: None,
|
||||
credential_revision: None,
|
||||
credential_fingerprint: None,
|
||||
host_trust_id: None,
|
||||
host_trust_revision: None,
|
||||
host_trust_fingerprint: None,
|
||||
repository_access_mode: None,
|
||||
credential_candidates: Vec::new(),
|
||||
working_directory_id: "workdir-a".to_string(),
|
||||
@@ -1382,7 +1448,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(retry.state, WorkdirRemovalOperationState::Pending);
|
||||
assert_eq!(retry.attempt_count, 1);
|
||||
assert!(retry.attempt_id.is_some());
|
||||
assert_eq!(retry.disposition, None);
|
||||
assert_eq!(retry.failure_category, None);
|
||||
assert!(retry.retryable);
|
||||
|
||||
@@ -23,7 +23,6 @@ const MAX_OPERATION_KEY_BYTES: usize = 200;
|
||||
pub struct WorkspaceCreateResult {
|
||||
pub workspace: WorkspaceRecord,
|
||||
pub repository: Option<RepositoryRecord>,
|
||||
pub config_revision: u64,
|
||||
pub request_fingerprint: String,
|
||||
pub replayed: bool,
|
||||
}
|
||||
@@ -159,7 +158,6 @@ impl WorkspaceCatalogService {
|
||||
provider: Some("git".to_string()),
|
||||
source: repository_source.clone(),
|
||||
default_ref: Some(default_ref),
|
||||
source_revision: 1,
|
||||
source_fingerprint: repository_source_fingerprint(&repository_source),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
@@ -174,7 +172,6 @@ impl WorkspaceCatalogService {
|
||||
Ok(WorkspaceCreateResult {
|
||||
workspace: result.workspace,
|
||||
repository: result.repository,
|
||||
config_revision: result.config_revision,
|
||||
request_fingerprint: fingerprint,
|
||||
replayed: result.replayed,
|
||||
})
|
||||
@@ -632,11 +629,10 @@ mod tests {
|
||||
let created = service.create(request.clone(), owner.clone()).unwrap();
|
||||
let mut repository = created.repository.unwrap();
|
||||
repository.source = validate_repository_source("https://example.test/changed.git").unwrap();
|
||||
repository.source_revision += 1;
|
||||
repository.source_fingerprint = repository_source_fingerprint(&repository.source);
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_revision = ?3, source_fingerprint = ?4 WHERE workspace_id = ?5 AND repository_key = 'main'",
|
||||
rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_revision, repository.source_fingerprint, repository.workspace_id])?;
|
||||
conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_fingerprint = ?3 WHERE workspace_id = ?4 AND repository_key = 'main'",
|
||||
rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_fingerprint, repository.workspace_id])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
@@ -880,7 +876,6 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(persisted.source.kind, RepositorySourceKind::Ssh);
|
||||
assert_eq!(persisted.source_revision, 1);
|
||||
assert!(persisted.source_fingerprint.starts_with("sha256:"));
|
||||
assert_eq!(
|
||||
persisted.observed_status,
|
||||
|
||||
@@ -3,7 +3,7 @@ use rusqlite::{OptionalExtension, params};
|
||||
use server_api::{
|
||||
WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES, WORKSPACE_DELETION_MAX_BLOCKERS,
|
||||
WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS, WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES,
|
||||
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES, WORKSPACE_DELETION_MAX_REVISION_BYTES,
|
||||
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES, WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES,
|
||||
WorkspaceDeletionBlocker, WorkspaceDeletionBlockerKind, WorkspaceDeletionOperationResponse,
|
||||
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionResourceCounts,
|
||||
WorkspaceDeletionState,
|
||||
@@ -22,7 +22,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"backend_job_attempts",
|
||||
"backend_jobs",
|
||||
"external_workdir_grants",
|
||||
"flow_source_revisions",
|
||||
"flow_source_contents",
|
||||
"flow_sources",
|
||||
"memory_staging_records",
|
||||
"memory_staging_resolutions",
|
||||
@@ -39,11 +39,12 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"runtime_removal_operations",
|
||||
"repository_secret_audit_events",
|
||||
"repository_secret_operations",
|
||||
"repository_ssh_credential_revisions",
|
||||
"repository_secret_legacy_receipts",
|
||||
"repository_ssh_credential_keys",
|
||||
"repository_ssh_credentials",
|
||||
"repository_ssh_host_trust_revisions",
|
||||
"repository_ssh_host_trust_keys",
|
||||
"repository_ssh_host_trusts",
|
||||
"server_secret_versions",
|
||||
"server_secret_objects",
|
||||
"ticket_assignment_operations",
|
||||
"ticket_assignment_work_releases",
|
||||
"ticket_assignment_ticket_tombstones",
|
||||
@@ -63,7 +64,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"typed_ticket_targets",
|
||||
"typed_tickets",
|
||||
"workdir_create_credential_candidates",
|
||||
"workdir_create_credential_revision_retentions",
|
||||
"workdir_create_credential_retentions",
|
||||
"workdir_create_legacy_ssh_archives",
|
||||
"workdir_create_operations",
|
||||
"workdir_registry",
|
||||
"workdir_removal_operations",
|
||||
@@ -88,7 +90,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"workspace_config_entries",
|
||||
"workspace_config_grants",
|
||||
"workspace_drive_grants",
|
||||
"workspace_config_tree_revisions",
|
||||
"workspace_config_tree_history",
|
||||
"workspace_config_trees",
|
||||
"workspace_create_operations",
|
||||
"workspace_memory_documents",
|
||||
@@ -102,7 +104,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"workspace_signing_identity_audit",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
"workspace_worker_retention_policies",
|
||||
"workspace_worker_retention_policy_revisions",
|
||||
"workspace_worker_retention_policy_snapshots",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -198,7 +200,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
Ok(WorkspaceDeletionPreflightResponse {
|
||||
workspace_id: workspace.workspace_id,
|
||||
display_name: workspace.display_name,
|
||||
expected_revision: workspace.updated_at,
|
||||
expected_workspace_updated_at: workspace.updated_at,
|
||||
can_delete: blockers.is_empty(),
|
||||
resources,
|
||||
blockers,
|
||||
@@ -213,7 +215,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
request: &WorkspaceDeletionRequest,
|
||||
) -> Result<WorkspaceDeletionReservation> {
|
||||
validate_operation_id(&request.operation_id)?;
|
||||
if request.expected_revision.len() > WORKSPACE_DELETION_MAX_REVISION_BYTES
|
||||
if request.expected_workspace_updated_at.len() > WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES
|
||||
|| request.confirmation.len() > server_api::WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
@@ -245,7 +247,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
"confirmation must exactly match the displayed Workspace name".to_string(),
|
||||
));
|
||||
}
|
||||
if workspace.updated_at != request.expected_revision {
|
||||
if workspace.updated_at != request.expected_workspace_updated_at {
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
"Workspace metadata changed; reload deletion impact before confirming".to_string(),
|
||||
));
|
||||
@@ -273,7 +275,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
tx.execute(
|
||||
"INSERT INTO workspace_deletion_operations (
|
||||
operation_id, request_fingerprint, workspace_id, workspace_display_name,
|
||||
workspace_revision, owner_account_id, actor_account_id,
|
||||
workspace_updated_at, owner_account_id, actor_account_id,
|
||||
state, resource_counts_json,
|
||||
child_operation_ids_json, blockers_json, failure_category,
|
||||
created_at, updated_at, completed_at
|
||||
@@ -283,7 +285,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
fingerprint,
|
||||
workspace_id,
|
||||
workspace.display_name,
|
||||
request.expected_revision,
|
||||
request.expected_workspace_updated_at,
|
||||
workspace.owner_account_id,
|
||||
actor_account_id,
|
||||
serde_json::to_string(&resources).map_err(|error| Error::Store(error.to_string()))?,
|
||||
@@ -293,7 +295,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
let changed = tx.execute(
|
||||
"UPDATE workspaces SET state = 'deleting', updated_at = ?2
|
||||
WHERE workspace_id = ?1 AND updated_at = ?3 AND state = 'active'",
|
||||
params![workspace_id, now, request.expected_revision],
|
||||
params![workspace_id, now, request.expected_workspace_updated_at],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
@@ -766,7 +768,7 @@ fn resource_counts(
|
||||
workdirs: table_count(conn, "workdir_registry", workspace_id)?,
|
||||
repositories: table_count(conn, "repositories", workspace_id)?,
|
||||
runtime_bindings: table_count(conn, "workspace_runtime_bindings", workspace_id)?,
|
||||
secrets: table_count(conn, "server_secret_versions", workspace_id)?,
|
||||
secrets: table_count(conn, "server_secret_objects", workspace_id)?,
|
||||
artifacts: table_count(conn, "artifacts", workspace_id)?,
|
||||
})
|
||||
}
|
||||
@@ -826,7 +828,7 @@ fn request_fingerprint(
|
||||
) -> String {
|
||||
let canonical = format!(
|
||||
"workspace-delete-v1\0{actor_account_id}\0{workspace_id}\0{}\0{}\0{}",
|
||||
request.operation_id, request.expected_revision, request.confirmation
|
||||
request.operation_id, request.expected_workspace_updated_at, request.confirmation
|
||||
);
|
||||
encode_hex(&Sha256::digest(canonical.as_bytes()))
|
||||
}
|
||||
@@ -959,7 +961,7 @@ mod tests {
|
||||
.expect("preflight");
|
||||
let request = WorkspaceDeletionRequest {
|
||||
operation_id: "delete-workspace-a".to_string(),
|
||||
expected_revision: preflight.expected_revision,
|
||||
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
|
||||
confirmation: "Alpha".to_string(),
|
||||
};
|
||||
let first = store
|
||||
@@ -1073,7 +1075,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_confirmation_and_revision_are_required_before_reservation() {
|
||||
fn owner_confirmation_and_updated_at_are_required_before_reservation() {
|
||||
let (store, owner, workspace_id) = setup();
|
||||
store.with_conn(|conn| {
|
||||
conn.execute(
|
||||
@@ -1093,7 +1095,7 @@ mod tests {
|
||||
.expect("preflight");
|
||||
let mut request = WorkspaceDeletionRequest {
|
||||
operation_id: "delete-alpha-guarded".to_string(),
|
||||
expected_revision: "stale".to_string(),
|
||||
expected_workspace_updated_at: "stale".to_string(),
|
||||
confirmation: "Alpha".to_string(),
|
||||
};
|
||||
assert!(matches!(
|
||||
@@ -1106,7 +1108,7 @@ mod tests {
|
||||
request_fingerprint(&owner, &workspace_id, &request),
|
||||
request_fingerprint(&owner, &workspace_id, &other_operation)
|
||||
);
|
||||
request.expected_revision = preflight.expected_revision;
|
||||
request.expected_workspace_updated_at = preflight.expected_workspace_updated_at;
|
||||
request.confirmation = "delete Alpha".to_string();
|
||||
assert!(matches!(
|
||||
store.reserve_workspace_deletion(&owner, &workspace_id, &request),
|
||||
@@ -1130,11 +1132,11 @@ mod tests {
|
||||
conn.execute(
|
||||
"INSERT INTO workdir_create_operations (
|
||||
workspace_id, operation_id, request_fingerprint, repository_id,
|
||||
selector, requested_runtime_id, resolved_runtime_id, config_revision,
|
||||
selector, requested_runtime_id, resolved_runtime_id,
|
||||
config_projection_digest, working_directory_id, state, created_at, updated_at
|
||||
) VALUES (
|
||||
?1, 'workdir-create', 'fingerprint', 'repository-pending',
|
||||
'develop', 'runtime-a', 'runtime-a', 1,
|
||||
'develop', 'runtime-a', 'runtime-a',
|
||||
'projection', 'workdir-pending', 'pending', '1', '1'
|
||||
)",
|
||||
params![workspace_id],
|
||||
@@ -1160,7 +1162,7 @@ mod tests {
|
||||
);
|
||||
let request = WorkspaceDeletionRequest {
|
||||
operation_id: "delete-with-pending-creates".to_string(),
|
||||
expected_revision: preflight.expected_revision,
|
||||
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
|
||||
confirmation: "Alpha".to_string(),
|
||||
};
|
||||
assert!(matches!(
|
||||
@@ -1295,7 +1297,7 @@ mod tests {
|
||||
}));
|
||||
let request = WorkspaceDeletionRequest {
|
||||
operation_id: "delete-pinned".to_string(),
|
||||
expected_revision: preflight.expected_revision,
|
||||
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
|
||||
confirmation: "Alpha".to_string(),
|
||||
};
|
||||
assert!(matches!(
|
||||
@@ -1338,7 +1340,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_accessible_workspace_and_revision_conflicts_fail_closed() {
|
||||
fn last_accessible_workspace_and_updated_at_conflicts_fail_closed() {
|
||||
let (store, owner, workspace_id) = setup();
|
||||
let other = "workspace-b";
|
||||
let preflight = store
|
||||
@@ -1352,7 +1354,7 @@ mod tests {
|
||||
other,
|
||||
&WorkspaceDeletionRequest {
|
||||
operation_id: "delete-beta".to_string(),
|
||||
expected_revision: preflight.expected_revision,
|
||||
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
|
||||
confirmation: "Beta".to_string(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -22,8 +22,7 @@ use crate::store::{
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
|
||||
pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1;
|
||||
const MATERIAL_SCHEMA_VERSION: u32 = 1;
|
||||
const MATERIAL_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -31,7 +30,6 @@ pub struct WorkspaceSigningPrivateMaterial {
|
||||
version: u32,
|
||||
workspace_id: String,
|
||||
key_id: String,
|
||||
revision: u64,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
@@ -42,7 +40,6 @@ impl fmt::Debug for WorkspaceSigningPrivateMaterial {
|
||||
.field("version", &self.version)
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("key_id", &self.key_id)
|
||||
.field("revision", &self.revision)
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
@@ -66,7 +63,6 @@ impl WorkspaceSigningPrivateMaterial {
|
||||
version: MATERIAL_SCHEMA_VERSION,
|
||||
workspace_id: workspace_id.to_string(),
|
||||
key_id: key_id.to_string(),
|
||||
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
|
||||
private_key: material.private_key,
|
||||
})
|
||||
}
|
||||
@@ -75,12 +71,10 @@ impl WorkspaceSigningPrivateMaterial {
|
||||
&self,
|
||||
expected_workspace_id: &str,
|
||||
expected_key_id: &str,
|
||||
expected_revision: u64,
|
||||
) -> Result<ring::signature::Ed25519KeyPair> {
|
||||
if self.version != MATERIAL_SCHEMA_VERSION
|
||||
|| self.workspace_id != expected_workspace_id
|
||||
|| self.key_id != expected_key_id
|
||||
|| self.revision != expected_revision
|
||||
{
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
@@ -105,10 +99,8 @@ impl WorkspaceSigningPrivateMaterial {
|
||||
&self,
|
||||
expected_workspace_id: &str,
|
||||
expected_key_id: &str,
|
||||
expected_revision: u64,
|
||||
) -> Result<String> {
|
||||
let signing_key =
|
||||
self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?;
|
||||
let signing_key = self.signing_key(expected_workspace_id, expected_key_id)?;
|
||||
Ok(encode_public_key(signing_key.public_key().as_ref()))
|
||||
}
|
||||
}
|
||||
@@ -146,7 +138,8 @@ impl WorkspaceSigningIdentityService {
|
||||
) -> Result<(WorkspaceSigningIdentityActivation, String)> {
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple());
|
||||
let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1");
|
||||
let proposed_material_ref =
|
||||
format!("workspace-signing/{proposed_workspace_id}/{proposed_key_id}");
|
||||
let operation_key = format!("workspace-create:{workspace_create_operation_key}");
|
||||
let operation = self.store.reserve_workspace_signing_identity_provisioning(
|
||||
&WorkspaceSigningIdentityProvisioningOperation {
|
||||
@@ -156,7 +149,6 @@ impl WorkspaceSigningIdentityService {
|
||||
workspace_id: proposed_workspace_id.to_string(),
|
||||
key_id: proposed_key_id,
|
||||
private_material_ref: proposed_material_ref,
|
||||
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
|
||||
actor_account_id: actor_account_id.to_string(),
|
||||
state: "pending".to_string(),
|
||||
created_at: now,
|
||||
@@ -191,12 +183,12 @@ impl WorkspaceSigningIdentityService {
|
||||
"Workspace signing identity state is invalid",
|
||||
));
|
||||
}
|
||||
let operation_key = format!(
|
||||
"existing-workspace:{workspace_id}:revision-{}",
|
||||
identity.revision
|
||||
let operation_key = format!("existing-workspace:{workspace_id}:key-{}", identity.key_id);
|
||||
let request_fingerprint = provisioning_fingerprint(
|
||||
workspace_id,
|
||||
&identity.key_id,
|
||||
&identity.private_material_ref,
|
||||
);
|
||||
let request_fingerprint =
|
||||
provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision);
|
||||
let operation = self.store.reserve_workspace_signing_identity_provisioning(
|
||||
&WorkspaceSigningIdentityProvisioningOperation {
|
||||
operation_key: operation_key.clone(),
|
||||
@@ -205,7 +197,6 @@ impl WorkspaceSigningIdentityService {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
key_id: identity.key_id.clone(),
|
||||
private_material_ref: identity.private_material_ref.clone(),
|
||||
revision: identity.revision,
|
||||
actor_account_id: actor_account_id.to_string(),
|
||||
state: "pending".to_string(),
|
||||
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
@@ -253,8 +244,20 @@ impl WorkspaceSigningIdentityService {
|
||||
"Workspace signing private material is missing",
|
||||
)
|
||||
})?;
|
||||
let signing_key =
|
||||
material.signing_key(workspace_id, &identity.key_id, identity.revision)?;
|
||||
let fingerprint = identity.public_key_fingerprint.as_deref().ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
"Workspace signing public key fingerprint is missing",
|
||||
)
|
||||
})?;
|
||||
let signing_key = material.signing_key(workspace_id, &identity.key_id)?;
|
||||
let public_key = encode_public_key(signing_key.public_key().as_ref());
|
||||
if public_key_fingerprint(&public_key)? != fingerprint {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
"Workspace signing private material does not match public metadata",
|
||||
));
|
||||
}
|
||||
Ok(signing_key.sign(payload).as_ref().to_vec())
|
||||
}
|
||||
|
||||
@@ -290,11 +293,8 @@ impl WorkspaceSigningIdentityService {
|
||||
.put_if_absent(&operation.private_material_ref, &generated)?
|
||||
}
|
||||
};
|
||||
let public_key = material.validate_and_public_key(
|
||||
&operation.workspace_id,
|
||||
&operation.key_id,
|
||||
operation.revision,
|
||||
)?;
|
||||
let public_key =
|
||||
material.validate_and_public_key(&operation.workspace_id, &operation.key_id)?;
|
||||
let public_key_fingerprint = public_key_fingerprint(&public_key)?;
|
||||
Ok(WorkspaceSigningIdentityActivation {
|
||||
workspace_id: operation.workspace_id.clone(),
|
||||
@@ -302,7 +302,6 @@ impl WorkspaceSigningIdentityService {
|
||||
public_key,
|
||||
public_key_fingerprint,
|
||||
private_material_ref: operation.private_material_ref.clone(),
|
||||
revision: operation.revision,
|
||||
provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
})
|
||||
}
|
||||
@@ -317,11 +316,8 @@ impl WorkspaceSigningIdentityService {
|
||||
"Workspace signing private material is missing",
|
||||
)
|
||||
})?;
|
||||
let public_key = material.validate_and_public_key(
|
||||
&identity.workspace_id,
|
||||
&identity.key_id,
|
||||
identity.revision,
|
||||
)?;
|
||||
let public_key =
|
||||
material.validate_and_public_key(&identity.workspace_id, &identity.key_id)?;
|
||||
let fingerprint = public_key_fingerprint(&public_key)?;
|
||||
if identity.public_key.as_deref() != Some(public_key.as_str())
|
||||
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
|
||||
@@ -335,17 +331,116 @@ impl WorkspaceSigningIdentityService {
|
||||
}
|
||||
}
|
||||
|
||||
fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String {
|
||||
pub(crate) fn provisioning_fingerprint(
|
||||
workspace_id: &str,
|
||||
key_id: &str,
|
||||
material_ref: &str,
|
||||
) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"workspace-signing-provisioning\0");
|
||||
hasher.update(workspace_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(key_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(revision.to_be_bytes());
|
||||
hasher.update(material_ref.as_bytes());
|
||||
format!("sha256:{}", hex_lower(&hasher.finalize()))
|
||||
}
|
||||
|
||||
// Frozen v1 private-material decoder. New writes never include the old counter.
|
||||
mod frozen_material_v1 {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Material {
|
||||
version: u32,
|
||||
workspace_id: String,
|
||||
key_id: String,
|
||||
revision: u64,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
impl Drop for Material {
|
||||
fn drop(&mut self) {
|
||||
self.private_key.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn decode(bytes: &[u8]) -> Option<WorkspaceSigningPrivateMaterial> {
|
||||
let old: Material = serde_json::from_slice(bytes).ok()?;
|
||||
if old.version != 1 || old.revision != 1 {
|
||||
return None;
|
||||
}
|
||||
Some(WorkspaceSigningPrivateMaterial {
|
||||
version: MATERIAL_SCHEMA_VERSION,
|
||||
workspace_id: old.workspace_id.clone(),
|
||||
key_id: old.key_id.clone(),
|
||||
private_key: old.private_key.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frozen_file_load_preserves_key_and_new_writes_use_current_shape() {
|
||||
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
|
||||
let public_key = material.validate_and_public_key("ws-1", "WK-1").unwrap();
|
||||
let mut old = serde_json::to_value(&material).unwrap();
|
||||
old["version"] = 1.into();
|
||||
old["revision"] = 1.into();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
|
||||
store.put_if_absent("ws-1/ed25519-v1", &material).unwrap();
|
||||
fs::write(
|
||||
store.material_path("ws-1/ed25519-v1").unwrap(),
|
||||
serde_json::to_vec(&old).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let migrated = store.load("ws-1/ed25519-v1").unwrap().unwrap();
|
||||
assert_eq!(
|
||||
migrated.validate_and_public_key("ws-1", "WK-1").unwrap(),
|
||||
public_key
|
||||
);
|
||||
let current = serde_json::to_value(&migrated).unwrap();
|
||||
assert_eq!(current["version"], MATERIAL_SCHEMA_VERSION);
|
||||
assert!(current.get("revision").is_none());
|
||||
assert_eq!(current, serde_json::to_value(material).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_decoder_rejects_unsupported_or_mixed_metadata() {
|
||||
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
|
||||
let mut old = serde_json::to_value(material).unwrap();
|
||||
old["version"] = 1.into();
|
||||
for revision in [0, 2, u64::MAX] {
|
||||
old["revision"] = revision.into();
|
||||
assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
}
|
||||
old["revision"] = 1.into();
|
||||
old["version"] = MATERIAL_SCHEMA_VERSION.into();
|
||||
assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
old["version"] = 1.into();
|
||||
old["unexpected"] = true.into();
|
||||
assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_private_material(
|
||||
bytes: &[u8],
|
||||
) -> std::result::Result<WorkspaceSigningPrivateMaterial, ()> {
|
||||
if let Ok(material) = serde_json::from_slice::<WorkspaceSigningPrivateMaterial>(bytes) {
|
||||
if material.version == MATERIAL_SCHEMA_VERSION {
|
||||
return Ok(material);
|
||||
}
|
||||
return Err(());
|
||||
}
|
||||
frozen_material_v1::decode(bytes).ok_or(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InMemoryWorkspaceSigningMaterialStore {
|
||||
materials: std::sync::Mutex<std::collections::HashMap<String, WorkspaceSigningPrivateMaterial>>,
|
||||
@@ -418,7 +513,7 @@ impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(material_io_error("read", error)),
|
||||
};
|
||||
serde_json::from_slice(&bytes).map(Some).map_err(|_| {
|
||||
decode_private_material(&bytes).map(Some).map_err(|_| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_corrupt",
|
||||
"Workspace signing private material is corrupt",
|
||||
@@ -606,6 +701,34 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
struct ReplaceMaterialAfterValidation {
|
||||
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
|
||||
replacement: WorkspaceSigningPrivateMaterial,
|
||||
loads: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl WorkspaceSigningMaterialStore for ReplaceMaterialAfterValidation {
|
||||
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
|
||||
if self.loads.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
self.inner.load(material_ref)
|
||||
} else {
|
||||
Ok(Some(self.replacement.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn put_if_absent(
|
||||
&self,
|
||||
material_ref: &str,
|
||||
material: &WorkspaceSigningPrivateMaterial,
|
||||
) -> Result<WorkspaceSigningPrivateMaterial> {
|
||||
self.inner.put_if_absent(material_ref, material)
|
||||
}
|
||||
|
||||
fn delete(&self, material_ref: &str) -> Result<()> {
|
||||
self.inner.delete(material_ref)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailFirstMaterialWrite {
|
||||
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
|
||||
fail: AtomicBool,
|
||||
@@ -709,6 +832,21 @@ mod tests {
|
||||
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
|
||||
.verify(payload, &signature)
|
||||
.unwrap();
|
||||
let replacement =
|
||||
WorkspaceSigningPrivateMaterial::generate("workspace-1", &provisioned.key_id).unwrap();
|
||||
let swapping_service = WorkspaceSigningIdentityService::new(
|
||||
store.clone(),
|
||||
Arc::new(ReplaceMaterialAfterValidation {
|
||||
inner: materials.clone(),
|
||||
replacement,
|
||||
loads: std::sync::atomic::AtomicUsize::new(0),
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
swapping_service.sign("workspace-1", payload),
|
||||
Err(Error::WorkspaceSigningIdentity { ref code, .. })
|
||||
if code == "workspace_signing_identity_material_mismatch"
|
||||
));
|
||||
assert_eq!(
|
||||
service
|
||||
.provision_existing("workspace-1", "account-1")
|
||||
@@ -747,17 +885,16 @@ mod tests {
|
||||
let operation = store
|
||||
.reserve_workspace_signing_identity_provisioning(
|
||||
&WorkspaceSigningIdentityProvisioningOperation {
|
||||
operation_key: "existing-workspace:workspace-2:revision-1".to_string(),
|
||||
operation_key: format!("existing-workspace:workspace-2:key-{}", pending.key_id),
|
||||
request_fingerprint: provisioning_fingerprint(
|
||||
"workspace-2",
|
||||
&pending.key_id,
|
||||
pending.revision,
|
||||
&pending.private_material_ref,
|
||||
),
|
||||
operation_kind: "existing_workspace".to_string(),
|
||||
workspace_id: "workspace-2".to_string(),
|
||||
key_id: pending.key_id.clone(),
|
||||
private_material_ref: pending.private_material_ref.clone(),
|
||||
revision: pending.revision,
|
||||
actor_account_id: "account-1".to_string(),
|
||||
state: "pending".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
@@ -826,7 +963,7 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
|
||||
let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
|
||||
let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap();
|
||||
let first_public = first.validate_and_public_key("ws-1", "WK-1").unwrap();
|
||||
let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -849,18 +986,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
persisted
|
||||
.validate_and_public_key("ws-1", "WK-1", 1)
|
||||
.unwrap(),
|
||||
persisted.validate_and_public_key("ws-1", "WK-1").unwrap(),
|
||||
first_public
|
||||
);
|
||||
|
||||
let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
|
||||
let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap();
|
||||
assert_eq!(
|
||||
persisted
|
||||
.validate_and_public_key("ws-1", "WK-1", 1)
|
||||
.unwrap(),
|
||||
persisted.validate_and_public_key("ws-1", "WK-1").unwrap(),
|
||||
first_public
|
||||
);
|
||||
}
|
||||
@@ -872,7 +1005,7 @@ mod tests {
|
||||
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
|
||||
store.put_if_absent("ws-1/ed25519-v1", &material).unwrap();
|
||||
let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap();
|
||||
assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err());
|
||||
assert!(loaded.validate_and_public_key("ws-2", "WK-1").is_err());
|
||||
|
||||
fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap();
|
||||
assert!(store.load("ws-1/ed25519-v1").is_err());
|
||||
|
||||
Reference in New Issue
Block a user