12 Commits
Author SHA1 Message Date
Hare c78f7f7d80 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.
2026-10-10 15:55:20 +09:00
Hare 72ce817de4 fix: integrate structured Workdir refusal diagnostics
Merge independently approved T-731 source 8af492ff into current develop after T-732. No conflicts or source edits. Integration root locked/offline check, eight affected crates, Workdir default/http-client, Server 742+11 (one existing ignored), API schema freshness, fmt/diff passed. Historical Workdir403 remains unidentified; HTTP-only feature-gating limitation remains separate.
2026-10-10 14:40:05 +09:00
Hare ac01f64f4d fix: integrate concurrent Workspace request admission
Merge independently approved T-732 source defaf8f3 into develop. Integration root locked/offline cargo check, full Server 737 library and 11 binary tests (one existing ignored), fmt/diff passed. No source changes or conflict resolution.
2026-10-10 14:25:37 +09:00
Hare 8af492ff84 Merge develop into T-731 source and align scoped authorization logging fixture 2026-10-10 14:20:32 +09:00
Hare defaf8f369 fix: allow concurrent Workspace requests with deletion admission fencing 2026-10-10 13:19:52 +09:00
Hare fc57716344 docs: record workdir tooling and runtime feature blockers 2026-10-10 12:04:56 +09:00
Hare dc8e586a0b fix: keep runtime responsive during workdir cleanup 2026-10-10 12:04:56 +09:00
Hare 7321d6c7c3 fix: forward scoped Workdir delegation checks
Forward provider authorization and overlap checks through ScopedWorkdirSession while preserving capability ceilings, path rebasing, write leases, and revocation. Exercise stacked wrappers, External providers, and remote HTTP sessions without weakening resolved symlink policy.
2026-10-10 02:12:29 +09:00
Hare fd759545c1 fix(workdir): preserve mutation and command safety through diagnostics 2026-10-10 00:00:43 +09:00
Hare 6ac87c885e fix(workdir): keep diagnostic context transparent to Write creation 2026-10-09 23:21:50 +09:00
Hare 10c55874e3 fix: preserve tool classification and accurate Workdir failure stages 2026-10-09 22:55:53 +09:00
Hare f9d70e2ae2 fix: retain structured Workdir refusal diagnostics across provider boundaries 2026-10-09 22:23:22 +09:00
399 changed files with 33018 additions and 12513 deletions
Generated
+2
View File
@@ -2754,6 +2754,7 @@ dependencies = [
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket",
"uuid", "uuid",
] ]
@@ -4788,6 +4789,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"server-api", "server-api",
"sha2 0.11.0",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"uuid", "uuid",
+1 -1
View File
@@ -172,5 +172,5 @@ webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation
[workspace.metadata.wip-upstream] [workspace.metadata.wip-upstream]
repository = "https://gitea.hareworks.net/Hare/wip-rs" repository = "https://gitea.hareworks.net/Hare/wip-rs"
version = "0.2.0" version = "0.2.0"
reference-revision = "6086f3c5ef10aa1464ce9c824750d7f667217bfd" reference-commit = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
source = "crates.io" source = "crates.io"
+19 -19
View File
@@ -3,11 +3,11 @@ use server_api::{
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery, RuntimeTrustKeyRevealResponse, SubjektivMemoryChangesQuery, SubjektivMemoryDetailQuery,
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse, SubjektivMemoryListChangesResponse, SubjektivMemoryListQuery, SubjektivMemoryQueryResponse,
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse, SubjektivMemoryReadResponse, SubjektivResidentSurfaceResponse, SubjektivSubjectListQuery,
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse, SubjektivSubjectListResponse, SubjektivSubjectResponse, WorkerLaunchOptionsResponse,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
}; };
use ticket::{ use ticket::{
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
@@ -168,7 +168,7 @@ impl BackendWorkspaceProductClient {
}; };
Ok(ticket::TicketStateUpdate { Ok(ticket::TicketStateUpdate {
operation_key: uuid::Uuid::now_v7().to_string(), operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&current), expected_content_digest: ticket::ticket_content_digest(&current),
expected_state: parse(&change.from)?, expected_state: parse(&change.from)?,
state: parse(&change.to)?, state: parse(&change.to)?,
reason: if change.body.as_str().is_empty() { reason: if change.body.as_str().is_empty() {
@@ -208,7 +208,7 @@ impl BackendWorkspaceProductClient {
let current = self.show_ticket(id)?; let current = self.show_ticket(id)?;
let request = ticket::TicketCompletion { let request = ticket::TicketCompletion {
operation_key: uuid::Uuid::now_v7().to_string(), operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&current), expected_content_digest: ticket::ticket_content_digest(&current),
expected_state: current.meta.workflow_state, expected_state: current.meta.workflow_state,
reason: resolution.as_str().to_string(), reason: resolution.as_str().to_string(),
references: Vec::new(), references: Vec::new(),
@@ -521,19 +521,19 @@ impl BackendWorkspaceProductClient {
}) })
} }
pub fn list_subjektiv_memory_revisions( pub fn list_subjektiv_memory_changes(
&self, &self,
subject_id: &str, subject_id: &str,
memory_id: &str, memory_id: &str,
query: &SubjektivMemoryRevisionsQuery, query: &SubjektivMemoryChangesQuery,
) -> Result<SubjektivMemoryListRevisionsResponse, BackendWorkspaceClientError> { ) -> Result<SubjektivMemoryListChangesResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone(); let workspace_id = self.workspace_id.clone();
let subject_id = subject_id.to_string(); let subject_id = subject_id.to_string();
let memory_id = memory_id.to_string(); let memory_id = memory_id.to_string();
let query = query.clone(); let query = query.clone();
self.generated(move |client| async move { self.generated(move |client| async move {
client client
.subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query) .subjektiv_memory_changes(workspace_id, subject_id, memory_id, query)
.await .await
}) })
} }
@@ -997,7 +997,7 @@ mod tests {
} }
#[test] #[test]
fn legacy_state_adapter_uses_snapshot_revision_without_overriding_stale_expected_state() { fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let backend = let backend =
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
@@ -1021,8 +1021,8 @@ mod tests {
let body: serde_json::Value = let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!( assert_eq!(
body["expected_item_revision"], body["expected_content_digest"],
ticket::ticket_item_revision(&snapshot) ticket::ticket_content_digest(&snapshot)
); );
assert_eq!(body["expected_state"], "ready"); assert_eq!(body["expected_state"], "ready");
assert_eq!(body["state"], "done"); assert_eq!(body["state"], "done");
@@ -1057,8 +1057,8 @@ mod tests {
let body: serde_json::Value = let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!( assert_eq!(
body["expected_item_revision"], body["expected_content_digest"],
ticket::ticket_item_revision(&snapshot) ticket::ticket_content_digest(&snapshot)
); );
assert_eq!(body["expected_state"], "planning"); assert_eq!(body["expected_state"], "planning");
assert_eq!(body["reason"], "The request was withdrawn"); assert_eq!(body["reason"], "The request was withdrawn");
@@ -1070,7 +1070,7 @@ mod tests {
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() { fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
let (base_url, request, handle) = one_response_server( let (base_url, request, handle) = one_response_server(
"409 Conflict", "409 Conflict",
r#"{"error":"ticket_conflict","message":"stale revision","diagnostics":[]}"#, r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
); );
let client = BackendWorkspaceProductClient::new_with_access_token( let client = BackendWorkspaceProductClient::new_with_access_token(
base_url, base_url,
@@ -1083,7 +1083,7 @@ mod tests {
"T-718", "T-718",
ticket::TicketCompletion { ticket::TicketCompletion {
operation_key: "research-done".into(), operation_key: "research-done".into(),
expected_item_revision: "ticket:1".into(), expected_content_digest: "ticket:1".into(),
expected_state: ticket::TicketWorkflowState::Planning, expected_state: ticket::TicketWorkflowState::Planning,
reason: "Research findings recorded".into(), reason: "Research findings recorded".into(),
references: Vec::new(), references: Vec::new(),
@@ -1100,7 +1100,7 @@ mod tests {
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body["state"], "done"); assert_eq!(body["state"], "done");
assert_eq!(body["operation_key"], "research-done"); assert_eq!(body["operation_key"], "research-done");
assert_eq!(body["expected_item_revision"], "ticket:1"); assert_eq!(body["expected_content_digest"], "ticket:1");
assert_eq!(body["expected_state"], "planning"); assert_eq!(body["expected_state"], "planning");
assert_eq!(body["reason"], "Research findings recorded"); assert_eq!(body["reason"], "Research findings recorded");
assert!(body.get("merge_request_ids").is_none()); assert!(body.get("merge_request_ids").is_none());
+29 -6
View File
@@ -230,9 +230,7 @@ mod tests {
use config_source::{ConfigContentType, ConfigEntry}; use config_source::{ConfigContentType, ConfigEntry};
fn snapshot() -> ConfigTreeSnapshot { fn snapshot() -> ConfigTreeSnapshot {
ConfigTreeSnapshot::from_entries( ConfigTreeSnapshot::from_entries([
7,
[
ConfigEntry::new( ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(), VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal, ConfigContentType::Decodal,
@@ -246,11 +244,37 @@ mod tests {
"{}", "{}",
) )
.unwrap(), .unwrap(),
], ])
)
.unwrap() .unwrap()
} }
#[test]
fn wasm_analysis_uses_applied_tree_content_identity() {
let base = snapshot();
let path = VirtualPath::parse("main.dcdl").unwrap();
let change = ConfigTreeChange::Update {
path: path.clone(),
expected_digest: base.entries[&path].content_digest.clone(),
content: "{ broken = ; }".into(),
};
let changed = base.apply(&[change]).unwrap();
let diagnostics = session_environment(changed.clone()).analyze(&path, None);
assert!(!diagnostics.is_empty());
assert!(
diagnostics
.iter()
.all(|diagnostic| diagnostic.tree_digest == changed.digest)
);
assert_ne!(changed.digest, base.digest);
let restored = changed.apply(&changed.changes_to(&base)).unwrap();
assert_eq!(restored.digest, base.digest);
assert!(
session_environment(restored)
.analyze(&path, None)
.is_empty()
);
}
#[test] #[test]
fn wasm_completion_boundary_preserves_read_only_builtin_detail() { fn wasm_completion_boundary_preserves_read_only_builtin_detail() {
let snapshot = snapshot(); let snapshot = snapshot();
@@ -301,7 +325,6 @@ mod tests {
}) })
.unwrap(); .unwrap();
assert_eq!(diagnostic.path, path); assert_eq!(diagnostic.path, path);
assert_eq!(diagnostic.revision, 7);
assert_eq!(diagnostic.tree_digest, snapshot.digest); assert_eq!(diagnostic.tree_digest, snapshot.digest);
assert!( assert!(
diagnostic diagnostic
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ConfigProjectionValidator = { "kind": "static_template_catalog", namespace: string, key_aliases?: { [key in string]: string }, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigProjectionValidator } from "./ConfigProjectionValidator";
export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, authoring_source?: string | null, projection_validator?: ConfigProjectionValidator | null, source_digest: string, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { VirtualPath } from "./VirtualPath";
export type EvaluatedProjection = { entrypoint: VirtualPath, data_json: unknown, projection_digest: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type VirtualPath = string;
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigSchemaContribution } from "./ConfigSchemaContribution";
export type WorkspaceConfigSchemaBundle = { contributions: Array<ConfigSchemaContribution>, source: string, fingerprint: string, };
+313 -138
View File
@@ -27,9 +27,16 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_PATH_BYTES: usize = 512; pub const MAX_PATH_BYTES: usize = 512;
pub const MAX_IMPORT_DEPTH: usize = 32; pub const MAX_IMPORT_DEPTH: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, ts_rs::TS)]
pub struct VirtualPath(String); pub struct VirtualPath(String);
impl<'de> Deserialize<'de> for VirtualPath {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::parse(value).map_err(serde::de::Error::custom)
}
}
impl VirtualPath { impl VirtualPath {
pub fn parse(value: impl AsRef<str>) -> Result<Self, ConfigTreeError> { pub fn parse(value: impl AsRef<str>) -> Result<Self, ConfigTreeError> {
let value = value.as_ref(); let value = value.as_ref();
@@ -267,27 +274,46 @@ impl ConfigEntry {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, ts_rs::TS)]
pub struct ConfigTreeSnapshot { pub struct ConfigTreeSnapshot {
#[ts(type = "number")] /// SHA-256 of ordered virtual paths, content types and complete source bytes.
pub revision: u64, /// Identifies tree content, not an edit sequence or freshness counter.
pub digest: String, pub digest: String,
pub entries: BTreeMap<VirtualPath, ConfigEntry>, pub entries: BTreeMap<VirtualPath, ConfigEntry>,
} }
impl<'de> Deserialize<'de> for ConfigTreeSnapshot {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct SnapshotContent {
digest: String,
entries: BTreeMap<VirtualPath, ConfigEntry>,
}
let content = SnapshotContent::deserialize(deserializer)?;
let snapshot = Self {
digest: content.digest,
entries: content.entries,
};
snapshot.validate().map_err(serde::de::Error::custom)?;
Ok(snapshot)
}
}
impl ConfigTreeSnapshot { impl ConfigTreeSnapshot {
pub fn empty() -> Self { pub fn empty() -> Self {
Self::from_entries(0, Vec::new()).expect("empty config snapshot is valid") Self::from_entries(Vec::new()).expect("empty config snapshot is valid")
} }
pub fn from_entries( pub fn from_entries(
revision: u64,
entries: impl IntoIterator<Item = ConfigEntry>, entries: impl IntoIterator<Item = ConfigEntry>,
) -> Result<Self, ConfigTreeError> { ) -> Result<Self, ConfigTreeError> {
let mut ordered = BTreeMap::new(); let mut ordered = BTreeMap::new();
let mut total = 0usize; let mut total = 0usize;
for entry in entries { for entry in entries {
validate_workspace_path(&entry.path)?; validate_workspace_path(&entry.path)?;
if digest_bytes(entry.content.as_bytes()) != entry.content_digest {
return Err(ConfigTreeError::EntryDigestMismatch(entry.path));
}
if entry.content.len() > MAX_ENTRY_BYTES { if entry.content.len() > MAX_ENTRY_BYTES {
return Err(ConfigTreeError::LimitExceeded("entry bytes")); return Err(ConfigTreeError::LimitExceeded("entry bytes"));
} }
@@ -306,12 +332,25 @@ impl ConfigTreeSnapshot {
} }
let digest = snapshot_digest(&ordered); let digest = snapshot_digest(&ordered);
Ok(Self { Ok(Self {
revision,
digest, digest,
entries: ordered, entries: ordered,
}) })
} }
/// Verify external snapshot metadata against the complete source content.
pub fn validate(&self) -> Result<(), ConfigTreeError> {
for (path, entry) in &self.entries {
if path != &entry.path {
return Err(ConfigTreeError::EntryPathMismatch(path.clone()));
}
}
let rebuilt = Self::from_entries(self.entries.values().cloned())?;
if self.digest != rebuilt.digest {
return Err(ConfigTreeError::TreeDigestMismatch);
}
Ok(())
}
pub fn list_prefix(&self, prefix: Option<&VirtualPath>) -> Vec<&ConfigEntry> { pub fn list_prefix(&self, prefix: Option<&VirtualPath>) -> Vec<&ConfigEntry> {
self.entries self.entries
.values() .values()
@@ -364,7 +403,11 @@ impl ConfigTreeSnapshot {
changes changes
} }
/// Apply an atomic batch. Each expected_digest compares the target's complete
/// source bytes only; it is not a tree precondition. Callers needing whole-tree
/// CAS must also compare the authoritative tree digest under their write lock.
pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result<Self, ConfigTreeError> { pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result<Self, ConfigTreeError> {
self.validate()?;
if changes.len() > MAX_CHANGE_COUNT { if changes.len() > MAX_CHANGE_COUNT {
return Err(ConfigTreeError::LimitExceeded("change count")); return Err(ConfigTreeError::LimitExceeded("change count"));
} }
@@ -440,7 +483,7 @@ impl ConfigTreeSnapshot {
} }
} }
} }
Self::from_entries(self.revision, entries.into_values()) Self::from_entries(entries.into_values())
} }
} }
@@ -788,8 +831,6 @@ pub struct ConfigDiagnosticLabel {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ConfigDiagnostic { pub struct ConfigDiagnostic {
pub path: VirtualPath, pub path: VirtualPath,
#[ts(type = "number")]
pub revision: u64,
pub tree_digest: String, pub tree_digest: String,
pub kind: String, pub kind: String,
pub span: ConfigSpan, pub span: ConfigSpan,
@@ -1436,7 +1477,6 @@ impl SnapshotEnvironment {
) -> ConfigDiagnostic { ) -> ConfigDiagnostic {
ConfigDiagnostic { ConfigDiagnostic {
path, path,
revision: self.snapshot.revision,
tree_digest: self.snapshot.digest.clone(), tree_digest: self.snapshot.digest.clone(),
kind: kind.into(), kind: kind.into(),
span: ConfigSpan { span: ConfigSpan {
@@ -1572,13 +1612,39 @@ impl SnapshotImportLoader {
import_edges: BTreeMap::new(), import_edges: BTreeMap::new(),
} }
} }
/// Translate an Engine cache identity back to its actual virtual source path.
/// Only exact keys produced by this loader are recognized; ordinary source
/// paths (including paths containing '@') are not split heuristically.
fn source_path(&self, key: &str) -> Result<VirtualPath, ConfigTreeError> {
if let Some(entry) = self
.snapshot
.entries
.values()
.find(|entry| snapshot_import_cache_key(entry) == key)
{
return Ok(entry.path.clone());
}
for resource in BUILTIN_PROFILE_RESOURCES {
let path = format!("$builtin/{}", resource.path);
if key
== format!(
"config-source://{path}@{}",
digest_bytes(resource.source.as_bytes())
)
{
return VirtualPath::parse(path);
}
}
VirtualPath::parse(key)
}
pub fn resolve( pub fn resolve(
&self, &self,
current_key: Option<&str>, current_key: Option<&str>,
specifier: &str, specifier: &str,
) -> Result<VirtualPath, ConfigTreeError> { ) -> Result<VirtualPath, ConfigTreeError> {
let current = current_key let current = current_key
.map(VirtualPath::parse) .map(|key| self.source_path(key))
.transpose()? .transpose()?
.ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?; .ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?;
resolve_import(&current, specifier) resolve_import(&current, specifier)
@@ -1594,11 +1660,10 @@ impl ImportLoader for SnapshotImportLoader {
let path = self.resolve(current_key, specifier).map_err(import_error)?; let path = self.resolve(current_key, specifier).map_err(import_error)?;
// Guard the import graph before returning a module to Decodal. In particular, // Guard the import graph before returning a module to Decodal. In particular,
// cycles hidden inside lazy object fields must not recurse during materialization. // cycles hidden inside lazy object fields must not recurse during materialization.
let current = current_key let current = self
.expect("resolve requires a source context") .source_path(current_key.expect("resolve requires a source context"))
.split("@sha256:") .map_err(import_error)?
.next() .to_string();
.unwrap();
if self if self
.import_edges .import_edges
.entry(current.to_string()) .entry(current.to_string())
@@ -1614,7 +1679,7 @@ impl ImportLoader for SnapshotImportLoader {
// A language service can keep using its loader after a diagnostic. // A language service can keep using its loader after a diagnostic.
// Only successful edges may be cached as already validated. // Only successful edges may be cached as already validated.
self.import_edges self.import_edges
.get_mut(current) .get_mut(&current)
.unwrap() .unwrap()
.remove(path.as_str()); .remove(path.as_str());
return Err(diagnostic); return Err(diagnostic);
@@ -1651,7 +1716,10 @@ impl ImportLoader for SnapshotImportLoader {
return Err(import_error(ConfigTreeError::LimitExceeded("total bytes"))); return Err(import_error(ConfigTreeError::LimitExceeded("total bytes")));
} }
return Ok(LoadedImport::source( return Ok(LoadedImport::source(
format!("{path}@{}", digest_bytes(resource.source.as_bytes())), format!(
"config-source://{path}@{}",
digest_bytes(resource.source.as_bytes())
),
path.as_str(), path.as_str(),
resource.source, resource.source,
)); ));
@@ -1747,9 +1815,16 @@ fn validate_import_graph(
fn snapshot_import_cache_key(entry: &ConfigEntry) -> String { fn snapshot_import_cache_key(entry: &ConfigEntry) -> String {
// The source id remains the virtual path for diagnostics and relative-import // The source id remains the virtual path for diagnostics and relative-import
// resolution. The cache identity also includes immutable source content so // resolution. Cache identity binds path, import interpretation and source bytes,
// equal paths from different revisions cannot alias in an Engine cache. // so changed content or content type cannot alias in an Engine cache.
format!("{}@{}", entry.path, entry.content_digest) // The URI-like prefix is rejected by VirtualPath, keeping engine identities
// disjoint from literal paths even when a path spells another entry's digest.
format!(
"config-source://{}@{}@{}",
entry.path,
entry.content_type.media_type(),
entry.content_digest
)
} }
fn is_builtin_path(path: &VirtualPath) -> bool { fn is_builtin_path(path: &VirtualPath) -> bool {
@@ -1881,7 +1956,6 @@ fn project_engine_diagnostic(
.unwrap_or(fallback_path); .unwrap_or(fallback_path);
ConfigDiagnostic { ConfigDiagnostic {
path, path,
revision: snapshot.revision,
tree_digest: snapshot.digest.clone(), tree_digest: snapshot.digest.clone(),
span: ConfigSpan { span: ConfigSpan {
start_byte: diagnostic.span.start, start_byte: diagnostic.span.start,
@@ -1911,7 +1985,6 @@ fn project_diagnostic(
) -> ConfigDiagnostic { ) -> ConfigDiagnostic {
ConfigDiagnostic { ConfigDiagnostic {
path: fallback_path, path: fallback_path,
revision: snapshot.revision,
tree_digest: snapshot.digest.clone(), tree_digest: snapshot.digest.clone(),
kind: diagnostic_kind(diagnostic.kind).to_string(), kind: diagnostic_kind(diagnostic.kind).to_string(),
span: ConfigSpan { span: ConfigSpan {
@@ -2174,6 +2247,12 @@ pub enum ConfigTreeError {
NotFound(VirtualPath), NotFound(VirtualPath),
#[error("virtual config entry changed: {0}")] #[error("virtual config entry changed: {0}")]
EntryConflict(VirtualPath), EntryConflict(VirtualPath),
#[error("virtual config entry content digest mismatch: {0}")]
EntryDigestMismatch(VirtualPath),
#[error("virtual config entry path does not match its tree key: {0}")]
EntryPathMismatch(VirtualPath),
#[error("virtual config tree content digest mismatch")]
TreeDigestMismatch,
#[error("virtual config path changed more than once in one candidate: {0}")] #[error("virtual config path changed more than once in one candidate: {0}")]
PathChangedMoreThanOnce(VirtualPath), PathChangedMoreThanOnce(VirtualPath),
#[error("duplicate virtual config path")] #[error("duplicate virtual config path")]
@@ -2254,13 +2333,10 @@ mod tests {
#[test] #[test]
fn candidate_changes_are_atomic_ordered_and_conflict_checked() { fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
let base = ConfigTreeSnapshot::from_entries( let base = ConfigTreeSnapshot::from_entries([
7,
[
entry("profiles/a.dcdl", "{ a = 1; }"), entry("profiles/a.dcdl", "{ a = 1; }"),
entry("shared.dcdl", "{}"), entry("shared.dcdl", "{}"),
], ])
)
.unwrap(); .unwrap();
let updated = base let updated = base
.apply(&[ .apply(&[
@@ -2296,30 +2372,96 @@ mod tests {
)); ));
} }
#[test]
fn source_edits_use_content_preconditions_and_preserve_equal_tree_identity() {
let base = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ x = 1; }")]).unwrap();
let expected_digest = base.entries[&path("main.dcdl")].content_digest.clone();
let update = |source: &str| ConfigTreeChange::Update {
path: path("main.dcdl"),
expected_digest: expected_digest.clone(),
content: source.into(),
};
assert_eq!(base.apply(&[]).unwrap(), base);
assert_eq!(base.apply(&[update("{ x = 1; }")]).unwrap(), base);
let changed = base.apply(&[update("{ x = 2; }")]).unwrap();
assert!(matches!(
changed.apply(&[update("{ x = 3; }")]),
Err(ConfigTreeError::EntryConflict(_))
));
let restored = changed.apply(&changed.changes_to(&base)).unwrap();
assert_eq!(restored, base);
assert!(restored.apply(&[update("{ x = 3; }")]).is_ok());
// Equal evaluated values are not equal source trees.
let whitespace_edit = base.apply(&[update("{ x = 1; }\n")]).unwrap();
assert_ne!(whitespace_edit.digest, base.digest);
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
assert_eq!(
SnapshotEnvironment::new(base)
.evaluate_contract(&contract)
.unwrap(),
SnapshotEnvironment::new(whitespace_edit)
.evaluate_contract(&contract)
.unwrap()
);
}
#[test]
fn snapshot_json_rejects_forged_content_identity() {
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let original = serde_json::to_value(&snapshot).unwrap();
assert_eq!(
original
.as_object()
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>(),
["digest", "entries"]
);
assert_eq!(
serde_json::from_value::<ConfigTreeSnapshot>(original.clone()).unwrap(),
snapshot
);
for mutation in ["body", "entry_digest", "tree_digest", "entry_path"] {
let mut wire = original.clone();
match mutation {
"body" => wire["entries"]["main.dcdl"]["content"] = "{ changed = true; }".into(),
"entry_digest" => {
wire["entries"]["main.dcdl"]["content_digest"] = "sha256:forged".into()
}
"tree_digest" => wire["digest"] = "sha256:forged".into(),
"entry_path" => wire["entries"]["main.dcdl"]["path"] = "other.dcdl".into(),
_ => unreachable!(),
}
assert!(
serde_json::from_value::<ConfigTreeSnapshot>(wire).is_err(),
"{mutation}"
);
}
}
#[test] #[test]
fn snapshot_digest_is_deterministic() { fn snapshot_digest_is_deterministic() {
let left = ConfigTreeSnapshot::from_entries( let left = ConfigTreeSnapshot::from_entries([
1, entry("z.dcdl", "{}"),
[entry("z.dcdl", "{}"), entry("a.dcdl", "{ x = 1; }")], entry("a.dcdl", "{ x = 1; }"),
) ])
.unwrap(); .unwrap();
let right = ConfigTreeSnapshot::from_entries( let right = ConfigTreeSnapshot::from_entries([
99, entry("a.dcdl", "{ x = 1; }"),
[entry("a.dcdl", "{ x = 1; }"), entry("z.dcdl", "{}")], entry("z.dcdl", "{}"),
) ])
.unwrap(); .unwrap();
assert_eq!(left.digest, right.digest); assert_eq!(left.digest, right.digest);
} }
#[test] #[test]
fn entrypoint_can_assert_empty_workspace_schema_global() { fn entrypoint_can_assert_empty_workspace_schema_global() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([entry(
1,
[entry(
"main.dcdl", "main.dcdl",
"{ answer = 42; } as WorkspaceConfigSchema", "{ answer = 42; } as WorkspaceConfigSchema",
)], )])
)
.unwrap(); .unwrap();
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
SnapshotEnvironment::new(snapshot) SnapshotEnvironment::new(snapshot)
@@ -2373,7 +2515,7 @@ mod tests {
#[test] #[test]
fn completion_projects_workspace_schema_fields_into_config_objects() { fn completion_projects_workspace_schema_fields_into_config_objects() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile", "builtin:profile",
"profile", "profile",
@@ -2458,7 +2600,7 @@ mod tests {
#[test] #[test]
fn completion_projects_array_element_and_nested_profile_fields() { fn completion_projects_array_element_and_nested_profile_fields() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot) let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema()); .with_schema_bundle(profile_entries_completion_schema());
for (marked_source, expected, prefix_len) in [ for (marked_source, expected, prefix_len) in [
@@ -2544,7 +2686,7 @@ mod tests {
#[test] #[test]
fn completion_tracks_fixed_array_positions_and_nested_array_ranges() { fn completion_tracks_fixed_array_positions_and_nested_array_ranges() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile", "builtin:profile",
"profile", "profile",
@@ -2617,7 +2759,7 @@ mod tests {
#[test] #[test]
fn completion_preserves_general_schema_member_behavior() { fn completion_preserves_general_schema_member_behavior() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot) let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema()); .with_schema_bundle(profile_entries_completion_schema());
for source in [ for source in [
@@ -2642,7 +2784,7 @@ mod tests {
#[test] #[test]
fn completion_array_schema_retains_nested_profile_diagnostics() { fn completion_array_schema_retains_nested_profile_diagnostics() {
let valid = "{ profile = { default_profile = \"default\"; entries = [{ selector = \"default\"; profile = { slug = \"default\"; worker = { mode = \"interactive\"; }; }; }]; }; } as WorkspaceConfigSchema"; let valid = "{ profile = { default_profile = \"default\"; entries = [{ selector = \"default\"; profile = { slug = \"default\"; worker = { mode = \"interactive\"; }; }; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries(7, [entry("main.dcdl", valid)]).unwrap(); let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", valid)]).unwrap();
let environment = SnapshotEnvironment::new(snapshot) let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema()); .with_schema_bundle(profile_entries_completion_schema());
assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
@@ -2663,7 +2805,7 @@ mod tests {
diagnostics.iter().any(|diagnostic| { diagnostics.iter().any(|diagnostic| {
diagnostic.kind == "constraint_violation" diagnostic.kind == "constraint_violation"
&& diagnostic.path == path("main.dcdl") && diagnostic.path == path("main.dcdl")
&& diagnostic.revision == 7 && diagnostic.tree_digest == environment.snapshot().digest
&& diagnostic.span.end_byte > diagnostic.span.start_byte && diagnostic.span.end_byte > diagnostic.span.start_byte
&& (diagnostic.message.contains(field) && (diagnostic.message.contains(field)
|| diagnostic || diagnostic
@@ -2690,7 +2832,7 @@ mod tests {
)]) )])
.unwrap(); .unwrap();
let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema"; let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
@@ -2760,13 +2902,10 @@ mod tests {
fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() { fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() {
let schema = actual_profile_schema(); let schema = actual_profile_schema();
let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema"; let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
8,
[
entry("main.dcdl", source), entry("main.dcdl", source),
entry("recipe.dcdl", "{ worker = { mode = 42; }; }"), entry("recipe.dcdl", "{ worker = { mode = 42; }; }"),
], ])
)
.unwrap(); .unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
@@ -2814,7 +2953,7 @@ mod tests {
"{diagnostic:?}" "{diagnostic:?}"
); );
assert_eq!(diagnostic.path, path(expected_path), "{diagnostic:?}"); assert_eq!(diagnostic.path, path(expected_path), "{diagnostic:?}");
assert_eq!(diagnostic.revision, 8); assert_eq!(diagnostic.tree_digest, environment.snapshot().digest);
let diagnostic_source = if expected_path == "main.dcdl" { let diagnostic_source = if expected_path == "main.dcdl" {
&source &source
} else { } else {
@@ -2874,10 +3013,10 @@ mod tests {
let source = format!( let source = format!(
r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
9, entry("main.dcdl", &source),
[entry("main.dcdl", &source), entry("scoped.dcdl", recipe)], entry("scoped.dcdl", recipe),
) ])
.unwrap(); .unwrap();
let schema = actual_profile_schema(); let schema = actual_profile_schema();
let environment = let environment =
@@ -2967,10 +3106,10 @@ mod tests {
let source = format!( let source = format!(
r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
10, entry("main.dcdl", &source),
[entry("main.dcdl", &source), entry("recipe.dcdl", &recipe)], entry("recipe.dcdl", &recipe),
) ])
.unwrap(); .unwrap();
let schema = actual_profile_schema(); let schema = actual_profile_schema();
let environment = let environment =
@@ -3001,7 +3140,7 @@ mod tests {
} }
let source = "{} as WorkspaceConfigSchema"; let source = "{} as WorkspaceConfigSchema";
let environment = SnapshotEnvironment::new( let environment = SnapshotEnvironment::new(
ConfigTreeSnapshot::from_entries(10, [entry("main.dcdl", source)]).unwrap(), ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap(),
) )
.with_schema_bundle(actual_profile_schema()); .with_schema_bundle(actual_profile_schema());
for (profile_path, prefix, label) in [ for (profile_path, prefix, label) in [
@@ -3051,7 +3190,7 @@ mod tests {
.unwrap(), .unwrap(),
]) ])
.unwrap(); .unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
for (marked_source, expected) in [ for (marked_source, expected) in [
("{ ed| } as WorkspaceConfigSchema", "editor"), ("{ ed| } as WorkspaceConfigSchema", "editor"),
@@ -3148,14 +3287,11 @@ mod tests {
#[test] #[test]
fn relative_imports_and_completion_share_the_snapshot_namespace() { fn relative_imports_and_completion_share_the_snapshot_namespace() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1,
[
entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#), entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#),
entry("shared/value.dcdl", "{ answer = 42; }"), entry("shared/value.dcdl", "{ answer = 42; }"),
entry("other.dcdl", "{}"), entry("other.dcdl", "{}"),
], ])
)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(), resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(),
@@ -3207,7 +3343,7 @@ mod tests {
#[test] #[test]
fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() { fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() {
let snapshot = let snapshot =
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; }")]).unwrap(); ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = {}; }")]).unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web", "builtin:web",
"web", "web",
@@ -3239,8 +3375,7 @@ mod tests {
("{ web = {}; custom = 42; }", "custom"), ("{ web = {}; custom = 42; }", "custom"),
("{ web = { typo = true; }; }", "typo"), ("{ web = { typo = true; }; }", "typo"),
] { ] {
let snapshot = let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::with_schema_bundle( .evaluate_contract(&ToolchainContract::with_schema_bundle(
1, 1,
@@ -3258,13 +3393,10 @@ mod tests {
#[test] #[test]
fn workspace_schema_supports_typed_associative_collections() { fn workspace_schema_supports_typed_associative_collections() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([entry(
1,
[entry(
"main.dcdl", "main.dcdl",
"{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }", "{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }",
)], )])
)
.unwrap(); .unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:features", "builtin:features",
@@ -3315,8 +3447,7 @@ mod tests {
"constraintviolation", "constraintviolation",
), ),
] { ] {
let snapshot = let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::with_schema_bundle( .evaluate_contract(&ToolchainContract::with_schema_bundle(
1, 1,
@@ -3335,7 +3466,7 @@ mod tests {
fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() { fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() {
let source = let source =
"{} as { features = {...{ enabled = Bool; }}; web = { enabled = Bool; ...Unknown }; }"; "{} as { features = {...{ enabled = Bool; }}; web = { enabled = Bool; ...Unknown }; }";
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("schema.dcdl", source)]).unwrap(); let snapshot = ConfigTreeSnapshot::from_entries([entry("schema.dcdl", source)]).unwrap();
let environment = SnapshotEnvironment::new(snapshot); let environment = SnapshotEnvironment::new(snapshot);
let diagnostics = environment.analyze(&path("schema.dcdl"), None); let diagnostics = environment.analyze(&path("schema.dcdl"), None);
assert!( assert!(
@@ -3370,13 +3501,10 @@ mod tests {
#[test] #[test]
fn workspace_schema_preserves_fields_only_where_rest_is_explicit() { fn workspace_schema_preserves_fields_only_where_rest_is_explicit() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([entry(
1,
[entry(
"main.dcdl", "main.dcdl",
"{ web = { enabled = true; extension_value = 42; }; }", "{ web = { enabled = true; extension_value = 42; }; }",
)], )])
)
.unwrap(); .unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web", "builtin:web",
@@ -3402,8 +3530,7 @@ mod tests {
#[test] #[test]
fn unresolved_unknown_cannot_be_materialized() { fn unresolved_unknown_cannot_be_materialized() {
let snapshot = let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "Unknown")]).unwrap();
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
.unwrap_err(); .unwrap_err();
@@ -3413,10 +3540,8 @@ mod tests {
#[test] #[test]
fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() { fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot =
1, ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = { enabled = 1; }; }")])
[entry("main.dcdl", "{ web = { enabled = 1; }; }")],
)
.unwrap(); .unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web", "builtin:web",
@@ -3459,9 +3584,82 @@ mod tests {
let first = text_entry("skills/debug-rust/SKILL.md", "first"); let first = text_entry("skills/debug-rust/SKILL.md", "first");
let second = text_entry("skills/debug-rust/SKILL.md", "second"); let second = text_entry("skills/debug-rust/SKILL.md", "second");
let first_key = snapshot_import_cache_key(&first); let first_key = snapshot_import_cache_key(&first);
assert!(first_key.starts_with("skills/debug-rust/SKILL.md@sha256:")); assert!(
first_key.starts_with("config-source://skills/debug-rust/SKILL.md@text/plain@sha256:")
);
assert!(first_key.ends_with(&first.content_digest)); assert!(first_key.ends_with(&first.content_digest));
assert_ne!(first_key, snapshot_import_cache_key(&second)); assert_ne!(first_key, snapshot_import_cache_key(&second));
let different_type = entry("skills/debug-rust/SKILL.md", "first");
assert_ne!(first_key, snapshot_import_cache_key(&different_type));
}
#[test]
fn cached_import_identity_resolves_relative_paths_and_keeps_literal_at_paths() {
let source = entry("profiles/name@sha256:literal.dcdl", "{}");
let loader =
SnapshotImportLoader::new(ConfigTreeSnapshot::from_entries([source.clone()]).unwrap());
for key in [
source.path.as_str().to_owned(),
snapshot_import_cache_key(&source),
] {
assert_eq!(
loader.resolve(Some(&key), "./shared.dcdl").unwrap(),
path("profiles/shared.dcdl")
);
assert_eq!(loader.source_path(&key).unwrap(), source.path);
}
}
#[test]
fn snapshot_json_rejects_cache_identity_paths_even_with_valid_content_digests() {
let source = entry("a.dcdl", "{}");
// Build adversarial JSON with a correct tree digest, bypassing the private
// constructor only to describe the untrusted input at this boundary.
let forged = ConfigEntry::new(
VirtualPath(snapshot_import_cache_key(&source)),
ConfigContentType::Decodal,
r#"import "./shared.dcdl""#,
)
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([source, forged]).unwrap();
let json = serde_json::to_string(&snapshot).unwrap();
let error = serde_json::from_str::<ConfigTreeSnapshot>(&json).unwrap_err();
assert!(error.to_string().contains("config-source://"), "{error}");
for invalid in [
"../escape",
"/absolute",
"a//b",
"a\\b",
"config-source://entry",
] {
assert!(serde_json::from_value::<VirtualPath>(serde_json::json!(invalid)).is_err());
}
let path: VirtualPath = serde_json::from_str(r#""profiles/name@digest.dcdl""#).unwrap();
assert_eq!(path.as_str(), "profiles/name@digest.dcdl");
}
#[test]
fn literal_path_matching_another_entries_digest_keeps_its_relative_import_base() {
let source = entry("a.dcdl", "{}");
let literal_path = format!("a.dcdl@text/x-decodal@{}", source.content_digest);
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", "{}"),
source.clone(),
entry(&literal_path, r#"import "./shared.dcdl""#),
entry("a.dcdl@text/shared.dcdl", "{ answer = 42; }"),
])
.unwrap();
let loader = SnapshotImportLoader::new(snapshot.clone());
assert_eq!(
loader
.resolve(Some(&literal_path), "./shared.dcdl")
.unwrap(),
path("a.dcdl@text/shared.dcdl")
);
assert!(VirtualPath::parse(snapshot_import_cache_key(&source)).is_err());
SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
.expect("literal paths must not alias engine cache identities");
} }
#[test] #[test]
@@ -3476,9 +3674,7 @@ mod tests {
"---\n", "---\n",
"# Debug Rust\n", "# Debug Rust\n",
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
3,
[
entry( entry(
"main.dcdl", "main.dcdl",
r#"{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }"#, r#"{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }"#,
@@ -3512,13 +3708,10 @@ mod tests {
content: source.to_string(), content: source.to_string(),
} }
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1,
[
entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#), entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#),
text_entry("skills/plain/SKILL.md", source), text_entry("skills/plain/SKILL.md", source),
], ])
)
.unwrap(); .unwrap();
let result = SnapshotEnvironment::new(snapshot) let result = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
@@ -3536,16 +3729,13 @@ mod tests {
project_markdown_document("---\nname: missing-close\nbody\n").unwrap_err(), project_markdown_document("---\nname: missing-close\nbody\n").unwrap_err(),
"opening YAML frontmatter delimiter has no closing delimiter" "opening YAML frontmatter delimiter has no closing delimiter"
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1,
[
entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#), entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#),
text_entry( text_entry(
"skills/broken/SKILL.md", "skills/broken/SKILL.md",
"---\nname: [unterminated\n---\nbody\n", "---\nname: [unterminated\n---\nbody\n",
), ),
], ])
)
.unwrap(); .unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
@@ -3557,7 +3747,7 @@ mod tests {
#[test] #[test]
fn builtin_imports_are_ordinary_values_with_confined_transitive_sources() { fn builtin_imports_are_ordinary_values_with_confined_transitive_sources() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [ let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", r#"import "$builtin/profiles/companion.dcdl" // { worker = { mode = "wip"; }; feature = { workspace_config = { enabled = true; }; }; }"#), entry("main.dcdl", r#"import "$builtin/profiles/companion.dcdl" // { worker = { mode = "wip"; }; feature = { workspace_config = { enabled = true; }; }; }"#),
entry("profiles/base.dcdl", "{ slug = \"workspace-shadow\"; }"), entry("profiles/base.dcdl", "{ slug = \"workspace-shadow\"; }"),
]).unwrap(); ]).unwrap();
@@ -3583,13 +3773,10 @@ mod tests {
); );
// Intersections conflict on explicit builtin values, exactly like any other import. // Intersections conflict on explicit builtin values, exactly like any other import.
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([entry(
1,
[entry(
"main.dcdl", "main.dcdl",
r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#, r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#,
)], )])
)
.unwrap(); .unwrap();
assert!( assert!(
SnapshotEnvironment::new(snapshot) SnapshotEnvironment::new(snapshot)
@@ -3612,13 +3799,10 @@ mod tests {
"builtin:companion", "builtin:companion",
] { ] {
let source = format!("import {specifier:?}"); let source = format!("import {specifier:?}");
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1,
[
entry("main.dcdl", &source), entry("main.dcdl", &source),
entry("profiles/base.dcdl", "{ spoof = true; }"), entry("profiles/base.dcdl", "{ spoof = true; }"),
], ])
)
.unwrap(); .unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&contract) .evaluate_contract(&contract)
@@ -3684,7 +3868,7 @@ mod tests {
#[test] #[test]
fn builtin_values_do_not_bypass_cycle_or_path_limits() { fn builtin_values_do_not_bypass_cycle_or_path_limits() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [ let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", r#"{ builtin = import "$builtin/profiles/companion.dcdl"; cycle = import "./cycle.dcdl"; }"#), entry("main.dcdl", r#"{ builtin = import "$builtin/profiles/companion.dcdl"; cycle = import "./cycle.dcdl"; }"#),
entry("cycle.dcdl", r#"import "./main.dcdl""#), entry("cycle.dcdl", r#"import "./main.dcdl""#),
]).unwrap(); ]).unwrap();
@@ -3718,7 +3902,7 @@ mod tests {
#[test] #[test]
fn rejected_import_graph_edges_stay_rejected_after_diagnostics() { fn rejected_import_graph_edges_stay_rejected_after_diagnostics() {
let mut loader = SnapshotImportLoader::new( let mut loader = SnapshotImportLoader::new(
ConfigTreeSnapshot::from_entries(1, [entry("a.dcdl", "{}"), entry("b.dcdl", "{}")]) ConfigTreeSnapshot::from_entries([entry("a.dcdl", "{}"), entry("b.dcdl", "{}")])
.unwrap(), .unwrap(),
); );
loader.load(Some("a.dcdl"), "./b.dcdl").unwrap(); loader.load(Some("a.dcdl"), "./b.dcdl").unwrap();
@@ -3760,7 +3944,7 @@ mod tests {
entries.push(entry(&format!("{index}.dcdl"), &source)); entries.push(entry(&format!("{index}.dcdl"), &source));
} }
let diagnostics = let diagnostics =
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()) SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap())
.evaluate_contract(&ToolchainContract::new(1, vec![path("0.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("0.dcdl")], 1))
.unwrap_err(); .unwrap_err();
assert!( assert!(
@@ -3781,7 +3965,7 @@ mod tests {
entries.push(text_entry(&format!("unused/{i}.txt"), "")); entries.push(text_entry(&format!("unused/{i}.txt"), ""));
} }
let environment = let environment =
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()); SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap());
let diagnostics = environment.evaluate_contract(&contract).unwrap_err(); let diagnostics = environment.evaluate_contract(&contract).unwrap_err();
assert!(diagnostics[0].message.contains("entry count")); assert!(diagnostics[0].message.contains("entry count"));
let mut entries = vec![entry( let mut entries = vec![entry(
@@ -3798,20 +3982,17 @@ mod tests {
entries.push(text_entry(&format!("unused/{i}.txt"), &"a".repeat(bytes))); entries.push(text_entry(&format!("unused/{i}.txt"), &"a".repeat(bytes)));
} }
let environment = let environment =
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()); SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap());
let diagnostics = environment.evaluate_contract(&contract).unwrap_err(); let diagnostics = environment.evaluate_contract(&contract).unwrap_err();
assert!(diagnostics[0].message.contains("total bytes")); assert!(diagnostics[0].message.contains("total bytes"));
} }
#[test] #[test]
fn host_environment_evaluation_uses_only_snapshot_imports() { fn host_environment_evaluation_uses_only_snapshot_imports() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
3,
[
entry("profiles/main.dcdl", r#"import "./shared.dcdl""#), entry("profiles/main.dcdl", r#"import "./shared.dcdl""#),
entry("profiles/shared.dcdl", "{ answer = 42; }"), entry("profiles/shared.dcdl", "{ answer = 42; }"),
], ])
)
.unwrap(); .unwrap();
let contract = ToolchainContract::new( let contract = ToolchainContract::new(
DEFAULT_SCHEMA_VERSION, DEFAULT_SCHEMA_VERSION,
@@ -3826,13 +4007,10 @@ mod tests {
#[test] #[test]
fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() { fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1,
[
entry("workspace.dcdl", "{ answer = 42; }"), entry("workspace.dcdl", "{ answer = 42; }"),
entry("unused.dcdl", "{ broken = ; }"), entry("unused.dcdl", "{ broken = ; }"),
], ])
)
.unwrap(); .unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1))
@@ -3844,7 +4022,7 @@ mod tests {
#[test] #[test]
fn missing_import_and_cycles_are_structured_failures() { fn missing_import_and_cycles_are_structured_failures() {
let missing = let missing =
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", r#"import "./missing.dcdl""#)]) ConfigTreeSnapshot::from_entries([entry("main.dcdl", r#"import "./missing.dcdl""#)])
.unwrap(); .unwrap();
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
let diagnostics = SnapshotEnvironment::new(missing) let diagnostics = SnapshotEnvironment::new(missing)
@@ -3852,13 +4030,10 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert_eq!(diagnostics[0].kind, "import"); assert_eq!(diagnostics[0].kind, "import");
let cycle = ConfigTreeSnapshot::from_entries( let cycle = ConfigTreeSnapshot::from_entries([
1,
[
entry("a.dcdl", r#"import "./b.dcdl""#), entry("a.dcdl", r#"import "./b.dcdl""#),
entry("b.dcdl", r#"import "./a.dcdl""#), entry("b.dcdl", r#"import "./a.dcdl""#),
], ])
)
.unwrap(); .unwrap();
let diagnostics = SnapshotEnvironment::new(cycle) let diagnostics = SnapshotEnvironment::new(cycle)
.evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1))
+2 -11
View File
@@ -7,9 +7,6 @@ const CODER_REVIEW_FLOW_SOURCE: &str = include_str!("../../../resources/flows/co
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinFlowSource { pub struct BuiltinFlowSource {
pub slug: &'static str, pub slug: &'static str,
/// Monotonic resource revision. Increment when built-in semantics change;
/// Runtime also pins the compiled content digest.
pub revision: u64,
pub path: &'static str, pub path: &'static str,
pub content: &'static str, pub content: &'static str,
} }
@@ -24,7 +21,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
match slug { match slug {
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource { CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG, slug: CODER_REVIEW_FLOW_SLUG,
revision: 5,
path: "builtin/flows/coder-review.dcdl", path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE, content: CODER_REVIEW_FLOW_SOURCE,
}), }),
@@ -35,7 +32,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] { pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource { const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG, slug: CODER_REVIEW_FLOW_SLUG,
revision: 5,
path: "builtin/flows/coder-review.dcdl", path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE, content: CODER_REVIEW_FLOW_SOURCE,
}]; }];
@@ -64,7 +61,6 @@ mod tests {
"MergeRequestShow", "MergeRequestShow",
"MergeRequestReview", "MergeRequestReview",
"MergeRequestComplete", "MergeRequestComplete",
"new immutable revision",
] { ] {
assert!(!source.content.contains(stale), "stale contract {stale}"); assert!(!source.content.contains(stale), "stale contract {stale}");
} }
@@ -74,10 +70,6 @@ mod tests {
fn every_builtin_flow_compiles_and_matches_catalog_identity() { fn every_builtin_flow_compiles_and_matches_catalog_identity() {
assert!(!builtin_flow_sources().is_empty()); assert!(!builtin_flow_sources().is_empty());
for source in builtin_flow_sources() { for source in builtin_flow_sources() {
assert!(
source.revision > 0,
"built-in Flow revision must be positive"
);
let definition = source.compile().unwrap_or_else(|error| { let definition = source.compile().unwrap_or_else(|error| {
panic!( panic!(
"builtin Flow {} failed to compile: {:?}", "builtin Flow {} failed to compile: {:?}",
@@ -88,7 +80,6 @@ mod tests {
let selected = let selected =
builtin_flow_source(source.slug).expect("builtin Flow must be selectable"); builtin_flow_source(source.slug).expect("builtin Flow must be selectable");
assert_eq!(selected.content, source.content); assert_eq!(selected.content, source.content);
assert_eq!(selected.revision, source.revision);
} }
} }
} }
+126 -56
View File
@@ -21,10 +21,8 @@ pub enum FlowInstanceStatus {
pub struct FlowInstance { pub struct FlowInstance {
pub instance_id: String, pub instance_id: String,
pub definition_id: String, pub definition_id: String,
pub definition_revision: u64,
pub definition_digest: String, pub definition_digest: String,
pub current_state: StateId, pub current_state: StateId,
pub state_revision: u64,
pub status: FlowInstanceStatus, pub status: FlowInstanceStatus,
pub active_attempt_id: Option<String>, pub active_attempt_id: Option<String>,
} }
@@ -33,7 +31,6 @@ impl FlowInstance {
pub fn start( pub fn start(
instance_id: impl Into<String>, instance_id: impl Into<String>,
definition_id: impl Into<String>, definition_id: impl Into<String>,
definition_revision: u64,
definition: &CompiledFlowDefinition, definition: &CompiledFlowDefinition,
) -> Result<Self, FlowTransitionError> { ) -> Result<Self, FlowTransitionError> {
let instance_id = instance_id.into(); let instance_id = instance_id.into();
@@ -41,11 +38,6 @@ impl FlowInstance {
ensure_non_empty("instance_id", &instance_id) ensure_non_empty("instance_id", &instance_id)
.and_then(|_| ensure_non_empty("definition_id", &definition_id)) .and_then(|_| ensure_non_empty("definition_id", &definition_id))
.map_err(FlowTransitionError::InvalidRequest)?; .map_err(FlowTransitionError::InvalidRequest)?;
if definition_revision == 0 {
return Err(FlowTransitionError::InvalidRequest(
"definition_revision must be positive".to_string(),
));
}
let initial_state = definition.state(&definition.initial).ok_or_else(|| { let initial_state = definition.state(&definition.initial).ok_or_else(|| {
FlowTransitionError::Invariant("compiled initial state is missing".to_string()) FlowTransitionError::Invariant("compiled initial state is missing".to_string())
})?; })?;
@@ -57,10 +49,8 @@ impl FlowInstance {
Ok(Self { Ok(Self {
instance_id, instance_id,
definition_id, definition_id,
definition_revision,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status, status,
active_attempt_id: None, active_attempt_id: None,
}) })
@@ -77,9 +67,7 @@ pub struct FlowTransitionRequest {
pub struct FlowTransitionAttempt { pub struct FlowTransitionAttempt {
pub attempt_id: String, pub attempt_id: String,
pub instance_id: String, pub instance_id: String,
pub definition_revision: u64,
pub definition_digest: String, pub definition_digest: String,
pub checked_state_revision: u64,
pub from_state: StateId, pub from_state: StateId,
pub reason: String, pub reason: String,
pub transitions: Vec<TransitionCheckSnapshot>, pub transitions: Vec<TransitionCheckSnapshot>,
@@ -174,7 +162,6 @@ pub enum FlowEventKind {
TransitionRequested { TransitionRequested {
attempt_id: String, attempt_id: String,
state_id: StateId, state_id: StateId,
state_revision: u64,
reason: String, reason: String,
transitions: Vec<TransitionCheckSnapshot>, transitions: Vec<TransitionCheckSnapshot>,
}, },
@@ -201,7 +188,6 @@ pub enum FlowEventKind {
StateEntered { StateEntered {
attempt_id: String, attempt_id: String,
state_id: StateId, state_id: StateId,
state_revision: u64,
status: FlowInstanceStatus, status: FlowInstanceStatus,
}, },
} }
@@ -214,7 +200,7 @@ pub struct FlowRuntimeEvent {
/// Durable Flow authority owned by one Runtime Worker. /// Durable Flow authority owned by one Runtime Worker.
/// ///
/// Workspace authority resolves and revisions the source, but never mutates /// Workspace authority resolves the exact source content, but never mutates
/// this value. Runtime persists the complete snapshot with the Worker session /// this value. Runtime persists the complete snapshot with the Worker session
/// and replaces it only after the corresponding session-log write succeeds. /// and replaces it only after the corresponding session-log write succeeds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -240,12 +226,8 @@ impl FlowRuntimeState {
"resolved Flow selector does not match compiled definition name".to_string(), "resolved Flow selector does not match compiled definition name".to_string(),
)); ));
} }
let instance = FlowInstance::start( let instance =
instance_id, FlowInstance::start(instance_id, source.flow_id.clone(), &source.definition)?;
source.flow_id.clone(),
source.revision,
&source.definition,
)?;
let initial = source let initial = source
.definition .definition
.state(&source.definition.initial) .state(&source.definition.initial)
@@ -257,7 +239,6 @@ impl FlowRuntimeState {
event: FlowEventKind::StateEntered { event: FlowEventKind::StateEntered {
attempt_id: String::new(), attempt_id: String::new(),
state_id: instance.current_state.clone(), state_id: instance.current_state.clone(),
state_revision: instance.state_revision,
status: instance.status, status: instance.status,
}, },
}; };
@@ -282,8 +263,22 @@ impl FlowRuntimeState {
if let Some(attempt) = &self.active_attempt { if let Some(attempt) = &self.active_attempt {
return Ok(attempt.clone()); return Ok(attempt.clone());
} }
let attempt_id = attempt_id.into();
// The event log is the identity authority: an attempt ID is used once,
// including failed/cancelled attempts. This prevents a delayed result
// from an earlier visit to the same state from matching a later attempt.
if self.events.iter().any(|event| {
matches!(
&event.event,
FlowEventKind::TransitionRequested { attempt_id: used, .. } if used == &attempt_id
)
}) {
return Err(FlowTransitionError::InvalidRequest(
"transition attempt ID has already been used".to_string(),
));
}
let request = FlowTransitionRequest { let request = FlowTransitionRequest {
attempt_id: attempt_id.into(), attempt_id,
reason: reason.into(), reason: reason.into(),
}; };
let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?; let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?;
@@ -329,7 +324,7 @@ pub enum FlowTransitionError {
NotActive, NotActive,
#[error("another transition attempt is already active")] #[error("another transition attempt is already active")]
AttemptInProgress, AttemptInProgress,
#[error("Flow definition does not match the instance's pinned revision")] #[error("Flow definition content does not match the instance's pinned digest")]
DefinitionMismatch, DefinitionMismatch,
#[error("invalid transition request: {0}")] #[error("invalid transition request: {0}")]
InvalidRequest(String), InvalidRequest(String),
@@ -337,7 +332,7 @@ pub enum FlowTransitionError {
Invariant(String), Invariant(String),
} }
pub fn begin_transition( fn begin_transition(
instance: &mut FlowInstance, instance: &mut FlowInstance,
definition: &CompiledFlowDefinition, definition: &CompiledFlowDefinition,
request: FlowTransitionRequest, request: FlowTransitionRequest,
@@ -374,9 +369,7 @@ pub fn begin_transition(
let attempt = FlowTransitionAttempt { let attempt = FlowTransitionAttempt {
attempt_id: request.attempt_id, attempt_id: request.attempt_id,
instance_id: instance.instance_id.clone(), instance_id: instance.instance_id.clone(),
definition_revision: instance.definition_revision,
definition_digest: instance.definition_digest.clone(), definition_digest: instance.definition_digest.clone(),
checked_state_revision: instance.state_revision,
from_state: instance.current_state.clone(), from_state: instance.current_state.clone(),
reason: request.reason, reason: request.reason,
transitions, transitions,
@@ -387,7 +380,6 @@ pub fn begin_transition(
FlowEventKind::TransitionRequested { FlowEventKind::TransitionRequested {
attempt_id: attempt.attempt_id.clone(), attempt_id: attempt.attempt_id.clone(),
state_id: attempt.from_state.clone(), state_id: attempt.from_state.clone(),
state_revision: attempt.checked_state_revision,
reason: attempt.reason.clone(), reason: attempt.reason.clone(),
transitions: attempt.transitions.clone(), transitions: attempt.transitions.clone(),
}, },
@@ -398,20 +390,23 @@ pub fn begin_transition(
Ok((attempt, events)) Ok((attempt, events))
} }
pub fn resolve_transition( fn resolve_transition(
instance: &mut FlowInstance, instance: &mut FlowInstance,
definition: &CompiledFlowDefinition, definition: &CompiledFlowDefinition,
mut attempt: FlowTransitionAttempt, mut attempt: FlowTransitionAttempt,
outcome: FlowVerifierOutcome, outcome: FlowVerifierOutcome,
) -> Result<FlowTransitionResolution, FlowTransitionError> { ) -> Result<FlowTransitionResolution, FlowTransitionError> {
if instance.active_attempt_id.as_deref() != Some(attempt.attempt_id.as_str()) { // Accept a result only for the currently reserved operation on this instance.
// A completed/cancelled attempt has already released that reservation.
if instance.instance_id != attempt.instance_id
|| instance.active_attempt_id.as_deref() != Some(attempt.attempt_id.as_str())
{
return Err(FlowTransitionError::InvalidRequest( return Err(FlowTransitionError::InvalidRequest(
"attempt is not the active/latest attempt for this Flow instance".to_string(), "attempt is not the active/latest attempt for this Flow instance".to_string(),
)); ));
} }
if definition.content_digest != instance.definition_digest if definition.content_digest != instance.definition_digest
|| attempt.definition_digest != instance.definition_digest || attempt.definition_digest != instance.definition_digest
|| attempt.definition_revision != instance.definition_revision
{ {
return Err(FlowTransitionError::DefinitionMismatch); return Err(FlowTransitionError::DefinitionMismatch);
} }
@@ -447,7 +442,7 @@ pub fn resolve_transition(
) )
} }
FlowVerifierOutcome::Completed { results } => { FlowVerifierOutcome::Completed { results } => {
if instance.state_revision != attempt.checked_state_revision if instance.status != FlowInstanceStatus::Active
|| instance.current_state != attempt.from_state || instance.current_state != attempt.from_state
{ {
attempt.status = FlowAttemptStatus::Rejected; attempt.status = FlowAttemptStatus::Rejected;
@@ -604,9 +599,6 @@ fn resolve_completed_results(
results, results,
}); });
instance.current_state = transition.target.clone(); instance.current_state = transition.target.clone();
instance.state_revision = instance.state_revision.checked_add(1).ok_or_else(|| {
FlowTransitionError::Invariant("Flow state revision overflowed".to_string())
})?;
instance.status = if transition.target.as_str() == CANCELLED_STATE_ID { instance.status = if transition.target.as_str() == CANCELLED_STATE_ID {
FlowInstanceStatus::Cancelled FlowInstanceStatus::Cancelled
} else if target_state.terminal { } else if target_state.terminal {
@@ -617,7 +609,6 @@ fn resolve_completed_results(
events.push(FlowEventKind::StateEntered { events.push(FlowEventKind::StateEntered {
attempt_id: attempt.attempt_id.clone(), attempt_id: attempt.attempt_id.clone(),
state_id: instance.current_state.clone(), state_id: instance.current_state.clone(),
state_revision: instance.state_revision,
status: instance.status, status: instance.status,
}); });
Ok(FlowTransitionResolution { Ok(FlowTransitionResolution {
@@ -735,16 +726,109 @@ mod tests {
attempt attempt
} }
fn runtime_state() -> FlowRuntimeState {
let definition = definition();
FlowRuntimeState::start(
&crate::ResolvedFlowSource {
selector: "workspace:simple".parse().unwrap(),
workspace_id: "workspace-1".into(),
flow_id: "flow-1".into(),
content_digest: definition.content_digest.clone(),
definition,
},
"instance-1",
)
.unwrap()
.0
}
#[test]
fn cancelled_attempt_id_cannot_be_reused_even_after_restore() {
let mut state = runtime_state();
state.begin_or_recover_transition("first", "ready").unwrap();
state
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
.unwrap();
let mut restored: FlowRuntimeState =
serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap();
let before = restored.clone();
assert!(matches!(
restored.begin_or_recover_transition("first", "try again"),
Err(FlowTransitionError::InvalidRequest(_))
));
assert_eq!(restored, before);
restored
.begin_or_recover_transition("second", "try again")
.unwrap();
assert_eq!(
restored.instance.active_attempt_id.as_deref(),
Some("second")
);
}
#[test]
fn late_result_cannot_consume_a_new_attempt_in_the_same_state() {
let mut state = runtime_state();
state.begin_or_recover_transition("first", "ready").unwrap();
state
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
.unwrap();
state
.begin_or_recover_transition("second", "ready")
.unwrap();
let before = state.clone();
assert!(matches!(
state.resolve_active_transition(
"first",
FlowVerifierOutcome::Completed { results: vec![] }
),
Err(FlowTransitionError::InvalidRequest(_))
));
assert_eq!(state, before);
state
.resolve_active_transition("second", FlowVerifierOutcome::Cancelled)
.unwrap();
assert!(state.active_attempt.is_none());
}
#[test]
fn source_content_is_checked_before_start_and_before_accepting_a_result() {
let mut state = runtime_state();
let mut changed = state.definition.clone();
changed.content_digest = "different-content".into();
assert_eq!(
begin_transition(
&mut state.instance,
&changed,
FlowTransitionRequest {
attempt_id: "first".into(),
reason: "ready".into(),
}
)
.unwrap_err(),
FlowTransitionError::DefinitionMismatch
);
assert!(state.instance.active_attempt_id.is_none());
state.begin_or_recover_transition("first", "ready").unwrap();
state.definition = changed;
let before = state.clone();
assert_eq!(
state
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
.unwrap_err(),
FlowTransitionError::DefinitionMismatch
);
assert_eq!(state, before);
}
#[test] #[test]
fn exactly_one_met_transition_enters_terminal_state() { fn exactly_one_met_transition_enters_terminal_state() {
let definition = definition(); let definition = definition();
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -770,7 +854,7 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(instance.current_state.as_str(), "done"); assert_eq!(instance.current_state.as_str(), "done");
assert_eq!(instance.state_revision, 1);
assert_eq!(instance.status, FlowInstanceStatus::Completed); assert_eq!(instance.status, FlowInstanceStatus::Completed);
assert!(resolution.rejection.is_none()); assert!(resolution.rejection.is_none());
assert!(matches!( assert!(matches!(
@@ -788,10 +872,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -827,10 +909,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -852,7 +932,7 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(instance.current_state.as_str(), "work"); assert_eq!(instance.current_state.as_str(), "work");
assert_eq!(instance.state_revision, 0);
assert_eq!(instance.active_attempt_id, None); assert_eq!(instance.active_attempt_id, None);
assert_eq!( assert_eq!(
resolution.rejection.unwrap().code, resolution.rejection.unwrap().code,
@@ -866,10 +946,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -901,15 +979,13 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
let attempt = begin(&mut instance); let attempt = begin(&mut instance);
instance.state_revision = 1; instance.current_state = StateId::new("done").unwrap();
let results = attempt let results = attempt
.transitions .transitions
.iter() .iter()
@@ -938,10 +1014,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -967,7 +1041,7 @@ mod tests {
FlowRejectionCode::MultipleConditionsMet FlowRejectionCode::MultipleConditionsMet
); );
assert_eq!(instance.current_state.as_str(), "work"); assert_eq!(instance.current_state.as_str(), "work");
assert_eq!(instance.state_revision, 0);
assert!( assert!(
resolution resolution
.events .events
@@ -982,10 +1056,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -1015,10 +1087,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
-1
View File
@@ -124,7 +124,6 @@ pub struct ResolvedFlowSource {
pub selector: FlowSelector, pub selector: FlowSelector,
pub workspace_id: String, pub workspace_id: String,
pub flow_id: String, pub flow_id: String,
pub revision: u64,
pub content_digest: String, pub content_digest: String,
pub definition: crate::CompiledFlowDefinition, pub definition: crate::CompiledFlowDefinition,
} }
+2 -8
View File
@@ -66,10 +66,7 @@ impl<'a> CheckedTarget<'a> {
} else { } else {
policy.is_readable_paths(logical, logical) policy.is_readable_paths(logical, logical)
}) { }) {
return Err(std::io::Error::new( return Err(crate::FsDenialReason::CheckoutTargetDenied.into_io_error());
std::io::ErrorKind::PermissionDenied,
"checkout target denied",
));
} }
let parent = if relative.as_os_str().is_empty() { let parent = if relative.as_os_str().is_empty() {
None None
@@ -294,10 +291,7 @@ impl FsAccessPolicy for CheckedCreate<'_, '_> {
if components.peek().is_some() if components.peek().is_some()
&& !self.target.policy.is_writable_paths(&parent, &parent) && !self.target.policy.is_writable_paths(&parent, &parent)
{ {
return Err(std::io::Error::new( return Err(crate::FsDenialReason::CheckoutCreateParentDenied.into_io_error());
std::io::ErrorKind::PermissionDenied,
"Create parent denied",
));
} }
} }
// Nested mkdir may have effects before failure; never auto-retry. // Nested mkdir may have effects before failure; never auto-retry.
+27 -22
View File
@@ -37,6 +37,26 @@ pub const MAX_RESULT_PATH_BYTES: usize = 1024 * 1024;
/// Provider-side ceiling for aggregate grep source bytes per operation. /// Provider-side ceiling for aggregate grep source bytes per operation.
pub const MAX_GREP_SOURCE_BYTES: u64 = 64 * 1024 * 1024; pub const MAX_GREP_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
/// Safe origin for a synthetic `PermissionDenied` from this crate.
/// Contains no paths or provider-authored text; real OS errors are not wrapped.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum FsDenialReason {
#[error("path is outside provider root")]
ProviderRootExceeded,
#[error("symbolic links are not permitted by this provider")]
ProviderSymlinkDenied,
#[error("checkout target denied")]
CheckoutTargetDenied,
#[error("Create parent denied")]
CheckoutCreateParentDenied,
}
impl FsDenialReason {
pub fn into_io_error(self) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::PermissionDenied, self)
}
}
/// Open `path` beneath `root` without following any symbolic link. Linux uses /// Open `path` beneath `root` without following any symbolic link. Linux uses
/// `openat2` so resolution and open are one kernel-enforced operation. Other /// `openat2` so resolution and open are one kernel-enforced operation. Other
/// platforms fail closed rather than silently weakening an External grant. /// platforms fail closed rather than silently weakening an External grant.
@@ -91,10 +111,7 @@ pub fn open_beneath_no_symlinks_at(
const RESOLVE_BENEATH: u64 = 0x08; const RESOLVE_BENEATH: u64 = 0x08;
if relative.is_absolute() { if relative.is_absolute() {
return Err(std::io::Error::new( return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
} }
let relative = if relative.as_os_str().is_empty() { let relative = if relative.as_os_str().is_empty() {
CString::new(".").expect("static path") CString::new(".").expect("static path")
@@ -122,10 +139,7 @@ pub fn open_beneath_no_symlinks_at(
if fd < 0 { if fd < 0 {
let error = std::io::Error::last_os_error(); let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ELOOP) { if error.raw_os_error() == Some(libc::ELOOP) {
return Err(std::io::Error::new( return Err(crate::FsDenialReason::ProviderSymlinkDenied.into_io_error());
std::io::ErrorKind::PermissionDenied,
"symbolic links are not permitted by this provider",
));
} }
return Err(error); return Err(error);
} }
@@ -146,12 +160,9 @@ pub fn open_beneath_no_symlinks_at(
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> { pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> {
let relative = path.strip_prefix(root).map_err(|_| { let relative = path
std::io::Error::new( .strip_prefix(root)
std::io::ErrorKind::PermissionDenied, .map_err(|_| crate::FsDenialReason::ProviderRootExceeded.into_io_error())?;
"path is outside provider root",
)
})?;
let root = open_root_no_symlinks(root)?; let root = open_root_no_symlinks(root)?;
open_beneath_no_symlinks_at(&root, relative) open_beneath_no_symlinks_at(&root, relative)
} }
@@ -184,20 +195,14 @@ pub fn atomic_write_beneath_no_symlinks_at(
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
if relative.is_absolute() { if relative.is_absolute() {
return Err(std::io::Error::new( return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
} }
let mut components = relative.components().peekable(); let mut components = relative.components().peekable();
let mut parent = root.try_clone()?; let mut parent = root.try_clone()?;
let mut file_name = None::<OsString>; let mut file_name = None::<OsString>;
while let Some(component) = components.next() { while let Some(component) = components.next() {
let std::path::Component::Normal(name) = component else { let std::path::Component::Normal(name) = component else {
return Err(std::io::Error::new( return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
}; };
if components.peek().is_none() { if components.peek().is_none() {
file_name = Some(name.to_os_string()); file_name = Some(name.to_os_string());
+13 -7
View File
@@ -100,8 +100,6 @@ pub struct JobRequest {
/// Stable caller-selected idempotency key within the host's namespace. /// Stable caller-selected idempotency key within the host's namespace.
pub job_id: String, pub job_id: String,
pub purpose: String, pub purpose: String,
/// Revision fenced by the structured result.
pub input_revision: String,
pub input_ref: String, pub input_ref: String,
/// Bounded immutable snapshot; must not contain credentials. /// Bounded immutable snapshot; must not contain credentials.
pub input: Value, pub input: Value,
@@ -126,7 +124,6 @@ impl JobRequest {
pub fn validate_fields(&self) -> Result<()> { pub fn validate_fields(&self) -> Result<()> {
validate_identifier("job_id", &self.job_id, 256)?; validate_identifier("job_id", &self.job_id, 256)?;
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?; validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
validate_identifier("input_revision", &self.input_revision, 256)?;
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?; validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?; validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
self.profile_selector()?; self.profile_selector()?;
@@ -157,6 +154,13 @@ impl JobRequest {
fingerprint(self) fingerprint(self)
} }
/// Content identity for result fencing; hosts compare it with the reserved
/// attempt's input, never with a caller-selected counter.
pub fn input_digest(&self) -> Result<String> {
self.validate_input()?;
fingerprint(&self.input)
}
pub fn worker_input(&self, attempt_id: &str) -> Result<String> { pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
worker_input(self, attempt_id) worker_input(self, attempt_id)
} }
@@ -167,7 +171,8 @@ impl JobRequest {
pub struct JobResultSubmission { pub struct JobResultSubmission {
pub job_id: String, pub job_id: String,
pub attempt_id: String, pub attempt_id: String,
pub input_revision: String, /// SHA-256 of the exact JSON encoding of the immutable input snapshot.
pub input_digest: String,
pub result: Value, pub result: Value,
} }
@@ -202,7 +207,8 @@ pub enum JobAttemptState {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct JobOutcome { pub struct JobOutcome {
pub job_id: String, pub job_id: String,
pub input_revision: String, /// SHA-256 of the exact JSON encoding of the immutable input snapshot.
pub input_digest: String,
pub attempt_id: String, pub attempt_id: String,
pub attempt: u8, pub attempt: u8,
pub state: JobState, pub state: JobState,
@@ -263,11 +269,11 @@ pub fn worker_input_with_label(
let input = serde_json::to_string(&request.input) let input = serde_json::to_string(&request.input)
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?; .map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
Ok(format!( Ok(format!(
"{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.", "{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_digest: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.",
request.instruction, request.instruction,
request.job_id, request.job_id,
attempt_id, attempt_id,
request.input_revision, request.input_digest()?,
request.input_ref, request.input_ref,
input input
)) ))
+26 -10
View File
@@ -5,7 +5,6 @@ fn request() -> JobRequest {
JobRequest { JobRequest {
job_id: "check:r7".into(), job_id: "check:r7".into(),
purpose: "snapshot_check".into(), purpose: "snapshot_check".into(),
input_revision: "7".into(),
input_ref: "snapshot://7".into(), input_ref: "snapshot://7".into(),
input: json!({"title": "Check me"}), input: json!({"title": "Check me"}),
instruction: "Check this immutable snapshot.".into(), instruction: "Check this immutable snapshot.".into(),
@@ -15,6 +14,25 @@ fn request() -> JobRequest {
} }
} }
#[test]
fn input_content_identity_is_computed_and_old_counter_is_not_accepted() {
let original = request();
let mut changed = original.clone();
changed.input = json!({"title": "changed"});
assert_ne!(
original.input_digest().unwrap(),
changed.input_digest().unwrap()
);
changed.instruction.push_str(" Extra instruction.");
assert_eq!(
changed.input_digest().unwrap(),
fingerprint(&changed.input).unwrap()
);
let mut old = serde_json::to_value(&original).unwrap();
old["input_revision"] = json!(7);
assert!(serde_json::from_value::<JobRequest>(old).is_err());
}
#[test] #[test]
fn shared_lifecycle_enums_round_trip_all_host_states() { fn shared_lifecycle_enums_round_trip_all_host_states() {
for (state, name) in [ for (state, name) in [
@@ -53,7 +71,7 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() { fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
let mut outcome = JobOutcome { let mut outcome = JobOutcome {
job_id: "job-1".into(), job_id: "job-1".into(),
input_revision: "7".into(), input_digest: request().input_digest().unwrap(),
attempt_id: "job-1:attempt:2".into(), attempt_id: "job-1:attempt:2".into(),
attempt: 2, attempt: 2,
state: JobState::Pending, state: JobState::Pending,
@@ -66,7 +84,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
assert_eq!( assert_eq!(
encoded, encoded,
json!({ json!({
"job_id": "job-1", "input_revision": "7", "attempt_id": "job-1:attempt:2", "job_id": "job-1", "input_digest": request().input_digest().unwrap(), "attempt_id": "job-1:attempt:2",
"attempt": 2, "state": "pending", "attempt_state": "dispatching", "attempt": 2, "state": "pending", "attempt_state": "dispatching",
"result": null, "failure_category": null, "failure_detail": null "result": null, "failure_category": null, "failure_detail": null
}) })
@@ -124,7 +142,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
fn neutral_request_and_result_have_strict_round_trip_contracts() { fn neutral_request_and_result_have_strict_round_trip_contracts() {
let request = request(); let request = request();
let encoded = serde_json::to_value(&request).unwrap(); let encoded = serde_json::to_value(&request).unwrap();
assert_eq!(encoded.as_object().unwrap().len(), 8); assert_eq!(encoded.as_object().unwrap().len(), 7);
assert!(encoded.get("serialization_key").is_none()); assert!(encoded.get("serialization_key").is_none());
assert_eq!( assert_eq!(
serde_json::from_value::<JobRequest>(encoded.clone()).unwrap(), serde_json::from_value::<JobRequest>(encoded.clone()).unwrap(),
@@ -137,10 +155,11 @@ fn neutral_request_and_result_have_strict_round_trip_contracts() {
with_provenance["source_worker"] = json!({}); with_provenance["source_worker"] = json!({});
assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err()); assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err());
let input_digest = request.input_digest().unwrap();
let submission = JobResultSubmission { let submission = JobResultSubmission {
job_id: request.job_id, job_id: request.job_id,
attempt_id: "check:r7:attempt:1".into(), attempt_id: "check:r7:attempt:1".into(),
input_revision: request.input_revision, input_digest,
result: json!({"ok": true}), result: json!({"ok": true}),
}; };
let mut encoded = serde_json::to_value(&submission).unwrap(); let mut encoded = serde_json::to_value(&submission).unwrap();
@@ -168,7 +187,6 @@ fn fingerprint_covers_every_immutable_field_and_preserves_replay() {
for mutate in [ for mutate in [
|r: &mut JobRequest| r.job_id.push('x'), |r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.purpose.push('x'), |r: &mut JobRequest| r.purpose.push('x'),
|r: &mut JobRequest| r.input_revision.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'), |r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.input = json!({"title": "changed"}), |r: &mut JobRequest| r.input = json!({"title": "changed"}),
|r: &mut JobRequest| r.instruction.push('x'), |r: &mut JobRequest| r.instruction.push('x'),
@@ -216,7 +234,6 @@ fn registry_profiles_are_not_authority_or_a_fixed_allowlist() {
fn validation_enforces_utf8_bytes_and_metadata_bounds() { fn validation_enforces_utf8_bytes_and_metadata_bounds() {
let mut r = request(); let mut r = request();
r.job_id = "x".repeat(256); r.job_id = "x".repeat(256);
r.input_revision = "x".repeat(256);
r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES); r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES);
r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES); r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES);
r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES); r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES);
@@ -225,7 +242,6 @@ fn validation_enforces_utf8_bytes_and_metadata_bounds() {
r.validate().unwrap(); r.validate().unwrap();
for mutate in [ for mutate in [
|r: &mut JobRequest| r.job_id.push('x'), |r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.input_revision.push('x'),
|r: &mut JobRequest| r.purpose.push('x'), |r: &mut JobRequest| r.purpose.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'), |r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.instruction.push('x'), |r: &mut JobRequest| r.instruction.push('x'),
@@ -287,7 +303,7 @@ fn result_digest_and_failure_detail_bound_exact_utf8_encoding() {
} }
#[test] #[test]
fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() { fn worker_input_is_content_and_attempt_bound_not_final_prose_authority() {
let request = request(); let request = request();
let attempt = attempt_id(&request.job_id, 2); let attempt = attempt_id(&request.job_id, 2);
assert_eq!(attempt, "check:r7:attempt:2"); assert_eq!(attempt, "check:r7:attempt:2");
@@ -297,7 +313,7 @@ fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() {
"Job envelope (immutable):", "Job envelope (immutable):",
"job_id: check:r7", "job_id: check:r7",
"attempt_id: check:r7:attempt:2", "attempt_id: check:r7:attempt:2",
"input_revision: 7", "input_digest: sha256:",
"input_ref: snapshot://7", "input_ref: snapshot://7",
r#"input_json: {"title":"Check me"}"#, r#"input_json: {"title":"Check me"}"#,
"structured Job result capability", "structured Job result capability",
+113 -14
View File
@@ -391,8 +391,7 @@ impl ResolvedMemoryFeatureConfig {
return Err("disabled Memory feature must not carry Workspace settings"); return Err("disabled Memory feature must not carry Workspace settings");
} }
if let Some(settings) = &self.workspace_settings if let Some(settings) = &self.workspace_settings
&& (settings.settings_revision == 0 && !is_normalized_workspace_memory_language(&settings.language)
|| !is_normalized_workspace_memory_language(&settings.language))
{ {
return Err("Memory Workspace settings snapshot metadata is invalid"); return Err("Memory Workspace settings snapshot metadata is invalid");
} }
@@ -473,8 +472,7 @@ impl ResolvedSubjektivFeatureConfig {
return Err("disabled subjektiv feature must not carry Workspace settings"); return Err("disabled subjektiv feature must not carry Workspace settings");
} }
if let Some(settings) = &self.workspace_settings if let Some(settings) = &self.workspace_settings
&& (settings.settings_revision == 0 && !is_normalized_workspace_memory_language(&settings.language)
|| !is_normalized_workspace_memory_language(&settings.language))
{ {
return Err("subjektiv Workspace settings snapshot metadata is invalid"); return Err("subjektiv Workspace settings snapshot metadata is invalid");
} }
@@ -705,10 +703,8 @@ pub fn is_normalized_workspace_memory_language(language: &str) -> bool {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceMemorySettingsSnapshot { pub struct WorkspaceMemorySettingsSnapshot {
/// Workspace that owns the settings revision. /// Workspace that owns these settings.
pub workspace_id: String, pub workspace_id: String,
/// Monotonic Workspace Memory settings revision.
pub settings_revision: u64,
/// Normalized language used for Memory extraction and consolidation output. /// Normalized language used for Memory extraction and consolidation output.
pub language: String, pub language: String,
} }
@@ -1155,7 +1151,8 @@ struct LegacyMemoryConfig {
consolidation_threshold_bytes: Option<u64>, consolidation_threshold_bytes: Option<u64>,
} }
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3; const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 4;
const MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3;
const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2; const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
/// Serialize a resolved Worker Manifest for durable Worker-specific storage. /// Serialize a resolved Worker Manifest for durable Worker-specific storage.
@@ -1187,6 +1184,7 @@ pub fn read_persisted_worker_manifest_snapshot(
)) ))
})?; })?;
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION && version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
{ {
return Err(serde_json::Error::io(std::io::Error::new( return Err(serde_json::Error::io(std::io::Error::new(
@@ -1218,6 +1216,9 @@ pub fn read_persisted_worker_manifest_snapshot(
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION { if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
migrate_legacy_manifest_authority(&mut manifest)?; migrate_legacy_manifest_authority(&mut manifest)?;
} }
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
migrate_legacy_memory_settings_counter(&mut manifest)?;
}
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?); return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
} }
@@ -1240,6 +1241,32 @@ fn validate_persisted_worker_manifest(
Ok(manifest) Ok(manifest)
} }
/// Frozen migration for schema 2/3 resolved manifests. Keep actual Workspace
/// identity and language unchanged; the obsolete sequence has no execution role.
fn migrate_legacy_memory_settings_counter(
manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> {
for namespace in ["memory", "subjektiv"] {
let Some(settings) = manifest
.get_mut("feature")
.and_then(|feature| feature.get_mut(namespace))
.and_then(|feature| feature.get_mut("workspace_settings"))
.and_then(serde_json::Value::as_object_mut)
else {
continue;
};
if let Some(counter) = settings.remove("settings_revision")
&& !counter.as_u64().is_some_and(|counter| counter > 0)
{
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy Workspace Memory settings snapshot has an invalid counter",
)));
}
}
Ok(())
}
fn migrate_legacy_manifest_authority( fn migrate_legacy_manifest_authority(
manifest: &mut serde_json::Value, manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> { ) -> Result<(), serde_json::Error> {
@@ -1385,6 +1412,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
} }
feature.insert("memory".to_string(), resolved); feature.insert("memory".to_string(), resolved);
migrate_legacy_manifest_authority(&mut snapshot)?; migrate_legacy_manifest_authority(&mut snapshot)?;
migrate_legacy_memory_settings_counter(&mut snapshot)?;
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?) validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
} }
@@ -1749,7 +1777,6 @@ model_id = "claude-sonnet-4-20250514"
worker_max_turns = 2\n\n\ worker_max_turns = 2\n\n\
[feature.memory.workspace_settings]\n\ [feature.memory.workspace_settings]\n\
workspace_id = \"workspace-1\"\n\ workspace_id = \"workspace-1\"\n\
settings_revision = 7\n\
language = \"日本語\"\n" language = \"日本語\"\n"
); );
let manifest = WorkerManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
@@ -1774,7 +1801,6 @@ model_id = "claude-sonnet-4-20250514"
fn resolved_memory_execution_validation_fails_closed() { fn resolved_memory_execution_validation_fails_closed() {
let snapshot = WorkspaceMemorySettingsSnapshot { let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}; };
let mut enabled = ResolvedMemoryFeatureConfig::default(); let mut enabled = ResolvedMemoryFeatureConfig::default();
@@ -1793,7 +1819,6 @@ model_id = "claude-sonnet-4-20250514"
fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() { fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() {
let snapshot = WorkspaceMemorySettingsSnapshot { let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}; };
let mut enabled = ResolvedSubjektivFeatureConfig::default(); let mut enabled = ResolvedSubjektivFeatureConfig::default();
@@ -1816,6 +1841,80 @@ model_id = "claude-sonnet-4-20250514"
assert!(WorkerManifest::from_toml(&toml).is_err()); assert!(WorkerManifest::from_toml(&toml).is_err());
} }
#[test]
fn persisted_counter_migration_preserves_memory_and_subject_settings() {
for version in [2, 3] {
let mut manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
manifest.feature.memory.profile.enabled = true;
manifest.feature.subjektiv.profile.enabled = true;
let settings = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".into(),
language: "日本語".into(),
};
manifest
.feature
.memory
.bind_workspace_settings(settings.clone())
.unwrap();
manifest
.feature
.subjektiv
.bind_workspace_settings(settings.clone())
.unwrap();
let mut snapshot = write_persisted_worker_manifest_snapshot(&manifest).unwrap();
snapshot["schema_version"] = version.into();
for namespace in ["memory", "subjektiv"] {
snapshot["manifest"]["feature"][namespace]["workspace_settings"]["settings_revision"] =
99.into();
}
let restored = read_persisted_worker_manifest_snapshot(snapshot).unwrap();
assert_eq!(
restored.feature.memory.workspace_settings(),
Some(settings.clone())
);
assert_eq!(
restored.feature.subjektiv.workspace_settings(),
Some(settings)
);
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
assert_eq!(current["schema_version"], 4);
for namespace in ["memory", "subjektiv"] {
assert_eq!(
current["manifest"]["feature"][namespace]["workspace_settings"],
serde_json::json!({
"workspace_id": "workspace-1", "language": "日本語"
})
);
}
read_persisted_worker_manifest_snapshot(current).unwrap();
}
}
#[test]
fn current_settings_snapshot_rejects_removed_counter_outside_migration() {
assert!(
serde_json::from_value::<WorkspaceMemorySettingsSnapshot>(serde_json::json!({
"workspace_id": "workspace-1", "language": "English", "settings_revision": 9
}))
.is_err()
);
let settings: WorkspaceMemorySettingsSnapshot = serde_json::from_value(serde_json::json!({
"workspace_id": "workspace-1", "language": "English"
}))
.unwrap();
let mut memory = ResolvedMemoryFeatureConfig::default();
memory.profile.enabled = true;
memory.bind_workspace_settings(settings.clone()).unwrap();
assert!(memory.validate_execution().is_ok());
for language in ["", " English", "English\n"] {
memory.workspace_settings = Some(WorkspaceMemorySettingsSnapshot {
language: language.into(),
..settings.clone()
});
assert!(memory.validate_execution().is_err());
}
}
#[test] #[test]
fn persisted_manifest_adapter_migrates_legacy_memory_authority() { fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
let mut manifest = let mut manifest =
@@ -1863,7 +1962,7 @@ model_id = "claude-sonnet-4-20250514"
"Français" "Français"
); );
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap(); let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
assert_eq!(current["schema_version"], 3); assert_eq!(current["schema_version"], 4);
assert!(current["manifest"].get("memory").is_none()); assert!(current["manifest"].get("memory").is_none());
let mut disabled = let mut disabled =
@@ -1905,7 +2004,7 @@ model_id = "claude-sonnet-4-20250514"
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap(); let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap(); let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
assert_eq!(current["schema_version"], 3); assert_eq!(current["schema_version"], 4);
assert!(current["manifest"].get("plugins").is_none()); assert!(current["manifest"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("plugins").is_none()); assert!(current["manifest"]["feature"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("workers").is_none()); assert!(current["manifest"]["feature"].get("workers").is_none());
@@ -1976,7 +2075,7 @@ model_id = "claude-sonnet-4-20250514"
assert!( assert!(
read_persisted_worker_manifest_snapshot(serde_json::json!({ read_persisted_worker_manifest_snapshot(serde_json::json!({
"schema_version": 4, "schema_version": 5,
"manifest": manifest, "manifest": manifest,
})) }))
.is_err() .is_err()
-1
View File
@@ -1489,7 +1489,6 @@ mod tests {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
for (field, value) in [ for (field, value) in [
("workspace_id", serde_json::json!("workspace-a")), ("workspace_id", serde_json::json!("workspace-a")),
("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")), ("language", serde_json::json!("Japanese")),
] { ] {
let artifact = serde_json::json!({ let artifact = serde_json::json!({
-2
View File
@@ -184,8 +184,6 @@ impl OperationCounts {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MemorySettingsAudit { pub struct MemorySettingsAudit {
pub workspace_id: String, pub workspace_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub settings_revision: u64,
pub language: String, pub language: String,
} }
+30 -2
View File
@@ -108,8 +108,9 @@ pub struct EvidenceOrigin {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_id: Option<String>, pub flow_definition_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] /// SHA-256 of the exact pinned Flow definition content, when available.
pub flow_definition_revision: Option<u64>, /// Historical origins without captured definition content leave this absent.
pub flow_definition_fingerprint: Option<String>,
} }
/// Host-resolved source/evidence metadata for an individual staging claim. /// Host-resolved source/evidence metadata for an individual staging claim.
@@ -156,6 +157,33 @@ pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError>
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn flow_origin_captures_content_identity_without_a_state_counter() {
let origin: EvidenceOrigin = serde_json::from_value(serde_json::json!({
"kind": "flow_instruction",
"flow_definition_id": "flow-definition",
"flow_definition_fingerprint": "a".repeat(64)
}))
.unwrap();
assert_eq!(
origin.flow_definition_fingerprint.as_deref(),
Some("a".repeat(64).as_str())
);
let value = serde_json::to_value(&origin).unwrap();
assert_eq!(value["flow_definition_fingerprint"], "a".repeat(64));
let historical: EvidenceOrigin = serde_json::from_value(serde_json::json!({
"kind": "flow_instruction", "flow_definition_id": "flow-definition"
}))
.unwrap();
assert!(historical.flow_definition_fingerprint.is_none());
assert!(
serde_json::to_value(historical)
.unwrap()
.get("flow_definition_fingerprint")
.is_none()
);
}
#[test] #[test]
fn splits_simple() { fn splits_simple() {
let doc = "---\nfoo: 1\n---\nbody here\n"; let doc = "---\nfoo: 1\n---\nbody here\n";
+1
View File
@@ -11,6 +11,7 @@ schemars = { workspace = true, features = ["chrono04"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
ticket = { path = "../ticket" }
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
[dev-dependencies] [dev-dependencies]
+109 -58
View File
@@ -8,7 +8,7 @@ use std::{
use thiserror::Error; use thiserror::Error;
use uuid::Uuid; use uuid::Uuid;
const SCHEMA_VERSION: i64 = 12; const SCHEMA_VERSION: i64 = 13;
const MAX_BODY_BYTES: usize = 16 * 1024; const MAX_BODY_BYTES: usize = 16 * 1024;
const DOMAIN_TABLES: [&str; 5] = [ const DOMAIN_TABLES: [&str; 5] = [
"merge_requests", "merge_requests",
@@ -103,7 +103,7 @@ pub struct ReviewRequestedEvent {
pub sequence: u64, pub sequence: u64,
pub subject_ref: String, pub subject_ref: String,
#[serde(default)] #[serde(default)]
pub ticket_item_revision: String, pub ticket_content_digest: String,
#[serde(default)] #[serde(default)]
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>, pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub requested_by: WorkerIdentity, pub requested_by: WorkerIdentity,
@@ -118,7 +118,7 @@ pub struct ReviewEvent {
pub request_event_id: String, pub request_event_id: String,
pub subject_ref: String, pub subject_ref: String,
#[serde(default)] #[serde(default)]
pub ticket_item_revision: String, pub ticket_content_digest: String,
#[serde(default)] #[serde(default)]
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>, pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub decision: ReviewDecision, pub decision: ReviewDecision,
@@ -249,7 +249,7 @@ pub enum MergeRequestEvidenceError {
ReviewRequestMissing, ReviewRequestMissing,
ReviewRequestMismatch, ReviewRequestMismatch,
ApprovalNotEffective, ApprovalNotEffective,
ItemRevisionMismatch, TicketContentMismatch,
SourceSnapshotMismatch, SourceSnapshotMismatch,
RequirementApprovalMissing, RequirementApprovalMissing,
} }
@@ -268,7 +268,7 @@ impl MergeRequestEvidenceError {
Self::ReviewRequestMissing => "review_request_missing", Self::ReviewRequestMissing => "review_request_missing",
Self::ReviewRequestMismatch => "review_request_mismatch", Self::ReviewRequestMismatch => "review_request_mismatch",
Self::ApprovalNotEffective => "approval_not_effective", Self::ApprovalNotEffective => "approval_not_effective",
Self::ItemRevisionMismatch => "item_revision_mismatch", Self::TicketContentMismatch => "ticket_content_mismatch",
Self::SourceSnapshotMismatch => "source_snapshot_mismatch", Self::SourceSnapshotMismatch => "source_snapshot_mismatch",
Self::RequirementApprovalMissing => "requirement_approval_missing", Self::RequirementApprovalMissing => "requirement_approval_missing",
} }
@@ -290,14 +290,14 @@ impl MergeRequestEvidenceError {
"Reviewer approval does not match linked ReviewRequested evidence" "Reviewer approval does not match linked ReviewRequested evidence"
} }
Self::ApprovalNotEffective => "Reviewer approval is not the effective review", Self::ApprovalNotEffective => "Reviewer approval is not the effective review",
Self::ItemRevisionMismatch => { Self::TicketContentMismatch => {
"Reviewer approval does not attest the current Ticket revision" "Reviewer approval does not attest the current Ticket digest"
} }
Self::SourceSnapshotMismatch => { Self::SourceSnapshotMismatch => {
"Reviewer approval does not attest the exact linked source set" "Reviewer approval does not attest the exact linked source set"
} }
Self::RequirementApprovalMissing => { Self::RequirementApprovalMissing => {
"no effective Reviewer approval attests the current Ticket revision and exact linked source set" "no effective Reviewer approval attests the current Ticket digest and exact linked source set"
} }
} }
} }
@@ -382,7 +382,7 @@ impl MergeRequest {
}) })
.ok_or(MergeRequestEvidenceError::ReviewRequestMissing)?; .ok_or(MergeRequestEvidenceError::ReviewRequestMissing)?;
if requested.subject_ref != review.subject_ref if requested.subject_ref != review.subject_ref
|| requested.ticket_item_revision != review.ticket_item_revision || requested.ticket_content_digest != review.ticket_content_digest
|| requested.ticket_merge_request_subjects != review.ticket_merge_request_subjects || requested.ticket_merge_request_subjects != review.ticket_merge_request_subjects
|| requested.reviewer != review.reviewer || requested.reviewer != review.reviewer
|| requested.sequence >= review.sequence || requested.sequence >= review.sequence
@@ -393,7 +393,7 @@ impl MergeRequest {
} }
} }
/// Finds an effective approval of the authoritative current Ticket revision and /// Finds an effective approval of the authoritative current Ticket digest and
/// exact linked source snapshot. Callers supply all linked requests and resolved /// exact linked source snapshot. Callers supply all linked requests and resolved
/// source refs (recorded merged refs for completion); this function does not read /// source refs (recorded merged refs for completion); this function does not read
/// authority, resolve selectors, require merged state, or mutate lifecycle state. /// authority, resolve selectors, require merged state, or mutate lifecycle state.
@@ -401,7 +401,7 @@ impl MergeRequest {
/// attestation. Snapshot ordering is immaterial, but duplicate ids are rejected. /// attestation. Snapshot ordering is immaterial, but duplicate ids are rejected.
pub fn requirement_approval<'a>( pub fn requirement_approval<'a>(
requests: &'a [MergeRequest], requests: &'a [MergeRequest],
item_revision: &str, content_digest: &str,
subjects: &[MergeRequestReviewSubject], subjects: &[MergeRequestReviewSubject],
event_id: Option<&str>, event_id: Option<&str>,
) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> { ) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> {
@@ -436,8 +436,10 @@ pub fn requirement_approval<'a>(
{ {
return Err(MergeRequestEvidenceError::ApprovalNotEffective); return Err(MergeRequestEvidenceError::ApprovalNotEffective);
} }
if review.ticket_item_revision != item_revision || item_revision.trim().is_empty() { if review.ticket_content_digest != content_digest
return Err(MergeRequestEvidenceError::ItemRevisionMismatch); || content_digest.trim().is_empty()
{
return Err(MergeRequestEvidenceError::TicketContentMismatch);
} }
if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) { if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) {
return Err(MergeRequestEvidenceError::SourceSnapshotMismatch); return Err(MergeRequestEvidenceError::SourceSnapshotMismatch);
@@ -508,7 +510,7 @@ pub struct OpenMergeRequest {
pub struct RequestMergeRequestReview { pub struct RequestMergeRequestReview {
pub merge_request_id: String, pub merge_request_id: String,
pub ticket_id: String, pub ticket_id: String,
pub ticket_item_revision: String, pub ticket_content_digest: String,
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>, pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub subject_ref: String, pub subject_ref: String,
pub child_session_id: String, pub child_session_id: String,
@@ -604,6 +606,7 @@ pub struct CompleteMergeRequest {
pub struct TicketCompletionEvent { pub struct TicketCompletionEvent {
pub operation_id: String, pub operation_id: String,
pub ticket_id: String, pub ticket_id: String,
/// Frozen historical payload only; not a content attestation or live API.
pub item_revision: String, pub item_revision: String,
pub merge_request_ids: Vec<String>, pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String, pub requirement_approval_event_id: String,
@@ -805,11 +808,11 @@ impl MergeRequestStore {
} }
let mut c = self.lock()?; let mut c = self.lock()?;
let t = c.transaction()?; let t = c.transaction()?;
let current_revision = current_ticket_revision(&t, &i.auth.workspace_id, &i.ticket_id)? let current_digest = current_ticket_content_digest(&t, &i.auth.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?; .ok_or(MergeRequestError::NotFound)?;
if current_revision != i.ticket_item_revision { if current_digest != i.ticket_content_digest {
return Err(MergeRequestError::Conflict( return Err(MergeRequestError::Conflict(
"Ticket item revision changed before review request".into(), "Ticket item digest changed before review request".into(),
)); ));
} }
let current_mr = load_mr(&t, &i.auth.workspace_id, &i.merge_request_id)? let current_mr = load_mr(&t, &i.auth.workspace_id, &i.merge_request_id)?
@@ -848,7 +851,7 @@ impl MergeRequestStore {
event_id: Uuid::now_v7().to_string(), event_id: Uuid::now_v7().to_string(),
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
subject_ref: i.subject_ref, subject_ref: i.subject_ref,
ticket_item_revision: i.ticket_item_revision, ticket_content_digest: i.ticket_content_digest,
ticket_merge_request_subjects: i.ticket_merge_request_subjects, ticket_merge_request_subjects: i.ticket_merge_request_subjects,
requested_by: WorkerIdentity { requested_by: WorkerIdentity {
runtime_id: i.auth.runtime_id, runtime_id: i.auth.runtime_id,
@@ -944,12 +947,12 @@ impl MergeRequestStore {
"review grant invalid".into(), "review grant invalid".into(),
)); ));
}; };
let (ticket_item_revision, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)? let (ticket_content_digest, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)?
.and_then(|request| { .and_then(|request| {
request.thread.into_iter().find_map(|event| match event { request.thread.into_iter().find_map(|event| match event {
MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => { MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => {
Some(( Some((
event.ticket_item_revision, event.ticket_content_digest,
event.ticket_merge_request_subjects, event.ticket_merge_request_subjects,
)) ))
} }
@@ -982,7 +985,7 @@ impl MergeRequestStore {
sequence: next_seq(&t, &ws, &mr)?, sequence: next_seq(&t, &ws, &mr)?,
request_event_id: req, request_event_id: req,
subject_ref: subject, subject_ref: subject,
ticket_item_revision, ticket_content_digest,
ticket_merge_request_subjects, ticket_merge_request_subjects,
decision: i.decision, decision: i.decision,
body: i.body, body: i.body,
@@ -1056,13 +1059,12 @@ impl MergeRequestStore {
} }
if let Some(review) = &review { if let Some(review) = &review {
let connection = self.lock()?; let connection = self.lock()?;
let current_revision = let current_digest =
current_ticket_revision(&connection, &i.auth.workspace_id, &i.ticket_id)? current_ticket_content_digest(&connection, &i.auth.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?; .ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision { if review.ticket_content_digest != current_digest {
b.push( b.push(
"approval predates the current Ticket revision; fresh review is required" "approval predates the current Ticket digest; fresh review is required".into(),
.into(),
); );
} }
} }
@@ -1147,12 +1149,12 @@ impl MergeRequestStore {
)); ));
} }
let connection = self.lock()?; let connection = self.lock()?;
let current_revision = let current_digest =
current_ticket_revision(&connection, &mr.workspace_id, &i.ticket_id)? current_ticket_content_digest(&connection, &mr.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?; .ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision { if review.ticket_content_digest != current_digest {
return Err(MergeRequestError::NotReady( return Err(MergeRequestError::NotReady(
"approval predates the current Ticket revision; fresh review is required".into(), "approval predates the current Ticket digest; fresh review is required".into(),
)); ));
} }
let state: Option<String> = connection let state: Option<String> = connection
@@ -1227,12 +1229,12 @@ impl MergeRequestStore {
"current effective review does not approve the source ref".into(), "current effective review does not approve the source ref".into(),
)); ));
} }
let current_revision = let current_digest =
current_ticket_revision(&transaction, &mr.workspace_id, &i.ticket_id)? current_ticket_content_digest(&transaction, &mr.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?; .ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision { if review.ticket_content_digest != current_digest {
return Err(MergeRequestError::NotReady( return Err(MergeRequestError::NotReady(
"approval predates the current Ticket revision; fresh review is required".into(), "approval predates the current Ticket digest; fresh review is required".into(),
)); ));
} }
let state: Option<String> = transaction let state: Option<String> = transaction
@@ -1655,34 +1657,13 @@ fn linked_merge_request_ids(
.map_err(Into::into) .map_err(Into::into)
} }
fn current_ticket_revision( fn current_ticket_content_digest(
connection: &Connection, connection: &Connection,
workspace_id: &str, workspace_id: &str,
ticket_id: &str, ticket_id: &str,
) -> Result<Option<String>, MergeRequestError> { ) -> Result<Option<String>, MergeRequestError> {
let event_index = connection ticket::sqlite_ticket_content_digest(connection, workspace_id, ticket_id)
.query_row( .map_err(|error| MergeRequestError::Operation(error.to_string()))
"SELECT event_index
FROM typed_ticket_events
WHERE workspace_id=?1 AND ticket_id=?2
AND kind IN ('create','item_edit')
ORDER BY event_index DESC
LIMIT 1",
params![workspace_id, ticket_id],
|row| row.get::<_, i64>(0),
)
.optional()?;
if let Some(event_index) = event_index {
return Ok(Some(format!("{ticket_id}:{event_index}")));
}
connection
.query_row(
"SELECT updated_at FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
params![workspace_id, ticket_id],
|row| row.get(0),
)
.optional()
.map_err(Into::into)
} }
fn load_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> { fn load_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> {
@@ -1773,12 +1754,82 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
match schema_state(c)? { match schema_state(c)? {
SchemaState::Fresh => fresh(c), SchemaState::Fresh => fresh(c),
SchemaState::Current(SCHEMA_VERSION) => verify(c), SchemaState::Current(SCHEMA_VERSION) => verify(c),
SchemaState::Current(12) => migrate_v12_content_attestations(c),
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!( SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
"unsupported schema {v}" "unsupported schema {v}"
))), ))),
} }
} }
/// Frozen v12 migration: historical counters cannot prove the reviewed content.
/// Preserve request/review identities and integration evidence, but do not
/// manufacture content attestations from today's Ticket. Fresh review is needed.
fn migrate_v12_content_attestations(c: &Connection) -> Result<(), MergeRequestError> {
let transaction = c.unchecked_transaction()?;
verify(&transaction)?;
// Keep the exact historical audit bytes independently of the current DTO
// projection. An unknown content attestation must not erase its old evidence.
transaction.execute_batch(
"CREATE TABLE merge_request_legacy_review_archive (
workspace_id TEXT NOT NULL,
merge_request_id TEXT NOT NULL,
event_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
PRIMARY KEY(workspace_id, merge_request_id, event_id)
);
INSERT INTO merge_request_legacy_review_archive
SELECT workspace_id,merge_request_id,event_id,payload_json
FROM merge_request_thread_events WHERE kind IN ('review_requested','review');
CREATE TRIGGER merge_request_legacy_review_archive_no_update
BEFORE UPDATE ON merge_request_legacy_review_archive
BEGIN SELECT RAISE(ABORT, 'historical review evidence is immutable'); END;
CREATE TRIGGER merge_request_legacy_review_archive_no_delete
BEFORE DELETE ON merge_request_legacy_review_archive
BEGIN SELECT RAISE(ABORT, 'historical review evidence is retained'); END;",
)?;
let rows = {
let mut statement = transaction.prepare(
"SELECT workspace_id,merge_request_id,event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review')",
)?;
statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?
.collect::<Result<Vec<_>, _>>()?
};
for (workspace_id, merge_request_id, event_id, payload) in rows {
let mut value: serde_json::Value = json(&payload)?;
let object = value
.as_object_mut()
.ok_or_else(|| MergeRequestError::Corrupt("review payload is not an object".into()))?;
object.remove("ticket_item_revision");
object.insert(
"ticket_content_digest".into(),
serde_json::Value::String(String::new()),
);
transaction.execute(
"UPDATE merge_request_thread_events SET payload_json=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND event_id=?3",
params![workspace_id, merge_request_id, event_id, value.to_string()],
)?;
}
transaction.execute(
"UPDATE merge_request_review_grants SET status='revoked',revoked_at=COALESCE(revoked_at,issued_at) WHERE status='issued'",
[],
)?;
transaction.execute(
"UPDATE merge_request_schema SET version=?1 WHERE singleton=1",
params![SCHEMA_VERSION],
)?;
verify(&transaction)?;
transaction.commit()?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SchemaState { enum SchemaState {
Fresh, Fresh,
+37 -62
View File
@@ -24,7 +24,7 @@ fn subjects() -> Vec<MergeRequestReviewSubject> {
fn add_review( fn add_review(
request: &mut MergeRequest, request: &mut MergeRequest,
event_id: &str, event_id: &str,
revision: &str, digest: &str,
snapshot: &[MergeRequestReviewSubject], snapshot: &[MergeRequestReviewSubject],
decision: ReviewDecision, decision: ReviewDecision,
) { ) {
@@ -37,7 +37,7 @@ fn add_review(
event_id: format!("request-{event_id}"), event_id: format!("request-{event_id}"),
sequence, sequence,
subject_ref: subject.subject_ref.clone(), subject_ref: subject.subject_ref.clone(),
ticket_item_revision: revision.into(), ticket_content_digest: digest.into(),
ticket_merge_request_subjects: snapshot.to_vec(), ticket_merge_request_subjects: snapshot.to_vec(),
requested_by: actor(), requested_by: actor(),
reviewer: actor(), reviewer: actor(),
@@ -48,7 +48,7 @@ fn add_review(
sequence: sequence + 1, sequence: sequence + 1,
request_event_id: requested.event_id.clone(), request_event_id: requested.event_id.clone(),
subject_ref: requested.subject_ref.clone(), subject_ref: requested.subject_ref.clone(),
ticket_item_revision: revision.into(), ticket_content_digest: digest.into(),
ticket_merge_request_subjects: snapshot.to_vec(), ticket_merge_request_subjects: snapshot.to_vec(),
decision, decision,
body: String::new(), body: String::new(),
@@ -84,7 +84,7 @@ fn merged_requests() -> Vec<MergeRequest> {
add_review( add_review(
&mut request, &mut request,
&approval_id, &approval_id,
"revision-one", "digest-one",
&snapshot, &snapshot,
ReviewDecision::Approve, ReviewDecision::Approve,
); );
@@ -109,22 +109,17 @@ fn merged_requests() -> Vec<MergeRequest> {
} }
#[test] #[test]
fn postmerge_latest_approval_attests_the_current_revision_without_replacing_integration() { fn postmerge_latest_approval_attests_the_current_digest_without_replacing_integration() {
let mut requests = merged_requests(); let mut requests = merged_requests();
let snapshot = subjects(); let snapshot = subjects();
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-two", &snapshot, Some("integration-MR-1")),
&requests, Err(MergeRequestEvidenceError::TicketContentMismatch)
"revision-two",
&snapshot,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
); );
add_review( add_review(
&mut requests[0], &mut requests[0],
"postmerge", "postmerge",
"revision-two", "digest-two",
&snapshot, &snapshot,
ReviewDecision::Approve, ReviewDecision::Approve,
); );
@@ -134,28 +129,23 @@ fn postmerge_latest_approval_attests_the_current_revision_without_replacing_inte
"integration-MR-1" "integration-MR-1"
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None) requirement_approval(&requests, "digest-two", &snapshot, None)
.unwrap() .unwrap()
.event_id, .event_id,
"postmerge" "postmerge"
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("postmerge")) requirement_approval(&requests, "digest-two", &snapshot, Some("postmerge"))
.unwrap() .unwrap()
.event_id, .event_id,
"postmerge" "postmerge"
); );
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-one", &snapshot, Some("integration-MR-1")),
&requests,
"revision-one",
&snapshot,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ApprovalNotEffective) Err(MergeRequestEvidenceError::ApprovalNotEffective)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("unknown")), requirement_approval(&requests, "digest-two", &snapshot, Some("unknown")),
Err(MergeRequestEvidenceError::RequirementApprovalMissing) Err(MergeRequestEvidenceError::RequirementApprovalMissing)
); );
} }
@@ -167,80 +157,70 @@ fn later_request_changes_prevents_older_requirement_approval_from_being_effectiv
add_review( add_review(
&mut requests[0], &mut requests[0],
"approved", "approved",
"revision-two", "digest-two",
&snapshot, &snapshot,
ReviewDecision::Approve, ReviewDecision::Approve,
); );
add_review( add_review(
&mut requests[0], &mut requests[0],
"changes", "changes",
"revision-two", "digest-two",
&snapshot, &snapshot,
ReviewDecision::RequestChanges, ReviewDecision::RequestChanges,
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None), requirement_approval(&requests, "digest-two", &snapshot, None),
Err(MergeRequestEvidenceError::RequirementApprovalMissing) Err(MergeRequestEvidenceError::RequirementApprovalMissing)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("approved")), requirement_approval(&requests, "digest-two", &snapshot, Some("approved")),
Err(MergeRequestEvidenceError::ApprovalNotEffective) Err(MergeRequestEvidenceError::ApprovalNotEffective)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("changes")), requirement_approval(&requests, "digest-two", &snapshot, Some("changes")),
Err(MergeRequestEvidenceError::ApprovalNotApproved) Err(MergeRequestEvidenceError::ApprovalNotApproved)
); );
assert!(requests[0].integration_approval().is_ok()); assert!(requests[0].integration_approval().is_ok());
} }
#[test] #[test]
fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation() { fn changed_digest_or_any_linked_source_invalidates_a_multiple_mr_attestation() {
let requests = merged_requests(); let requests = merged_requests();
let original = subjects(); let original = subjects();
assert!(requirement_approval(&requests, "revision-one", &original, None).is_ok()); assert!(requirement_approval(&requests, "digest-one", &original, None).is_ok());
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-two", &original, Some("integration-MR-1")),
&requests, Err(MergeRequestEvidenceError::TicketContentMismatch)
"revision-two",
&original,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
); );
let mut changed = original.clone(); let mut changed = original.clone();
changed[1].subject_ref = "changed-source-two".into(); changed[1].subject_ref = "changed-source-two".into();
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-one", &changed, Some("integration-MR-1")),
&requests,
"revision-one",
&changed,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch) Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
); );
let mut reversed = original.clone(); let mut reversed = original.clone();
reversed.reverse(); reversed.reverse();
assert!(requirement_approval(&requests, "revision-one", &reversed, None).is_ok()); assert!(requirement_approval(&requests, "digest-one", &reversed, None).is_ok());
// Even after the caller refreshes the linked request collection, an old // Even after the caller refreshes the linked request collection, an old
// attestation cannot authorize completion against a smaller linked set. // attestation cannot authorize completion against a smaller linked set.
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&requests[..1], &requests[..1],
"revision-one", "digest-one",
&original[..1], &original[..1],
Some("integration-MR-1") Some("integration-MR-1")
), ),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch) Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-one", &original[..1], None), requirement_approval(&requests, "digest-one", &original[..1], None),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch) Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
); );
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&requests, &requests,
"revision-one", "digest-one",
&[original[0].clone(), original[0].clone()], &[original[0].clone(), original[0].clone()],
None None
), ),
@@ -249,24 +229,24 @@ fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation()
} }
#[test] #[test]
fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_revision() { fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_digest() {
let mut requests = merged_requests(); let mut requests = merged_requests();
let mut snapshot = subjects(); let mut snapshot = subjects();
snapshot.pop(); snapshot.pop();
requests.pop(); requests.pop();
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None), requirement_approval(&requests, "digest-two", &snapshot, None),
Err(MergeRequestEvidenceError::RequirementApprovalMissing) Err(MergeRequestEvidenceError::RequirementApprovalMissing)
); );
add_review( add_review(
&mut requests[0], &mut requests[0],
"refreshed-set", "refreshed-set",
"revision-two", "digest-two",
&snapshot, &snapshot,
ReviewDecision::Approve, ReviewDecision::Approve,
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None) requirement_approval(&requests, "digest-two", &snapshot, None)
.unwrap() .unwrap()
.event_id, .event_id,
"refreshed-set" "refreshed-set"
@@ -282,7 +262,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
let original = subjects(); let original = subjects();
for (case, expected) in [ for (case, expected) in [
("missing", MergeRequestEvidenceError::ReviewRequestMissing), ("missing", MergeRequestEvidenceError::ReviewRequestMissing),
("revision", MergeRequestEvidenceError::ReviewRequestMismatch), ("digest", MergeRequestEvidenceError::ReviewRequestMismatch),
("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch), ("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch),
("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch), ("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch),
("source", MergeRequestEvidenceError::ApprovalSourceMismatch), ("source", MergeRequestEvidenceError::ApprovalSourceMismatch),
@@ -294,7 +274,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
review.request_event_id = "absent-request".into(); review.request_event_id = "absent-request".into();
} }
MergeRequestThreadEvent::ReviewRequested(requested) => match case { MergeRequestThreadEvent::ReviewRequested(requested) => match case {
"revision" => requested.ticket_item_revision = "other-revision".into(), "digest" => requested.ticket_content_digest = "other-digest".into(),
"snapshot" => { "snapshot" => {
requested.ticket_merge_request_subjects.pop(); requested.ticket_merge_request_subjects.pop();
} }
@@ -309,12 +289,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
} }
} }
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-one", &original, Some("integration-MR-1")),
&requests,
"revision-one",
&original,
Some("integration-MR-1")
),
Err(expected), Err(expected),
"{case}" "{case}"
); );
@@ -339,14 +314,14 @@ fn revoked_requirement_approval_is_rejected_and_discovery_uses_another_valid_mr(
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&requests, &requests,
"revision-one", "digest-one",
&subjects(), &subjects(),
Some("integration-MR-1") Some("integration-MR-1")
), ),
Err(MergeRequestEvidenceError::ApprovalRevoked) Err(MergeRequestEvidenceError::ApprovalRevoked)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-one", &subjects(), None) requirement_approval(&requests, "digest-one", &subjects(), None)
.unwrap() .unwrap()
.event_id, .event_id,
"integration-MR-2" "integration-MR-2"
@@ -441,8 +416,8 @@ fn evidence_error_codes_are_stable_machine_diagnostics_separate_from_messages()
"approval_not_effective", "approval_not_effective",
), ),
( (
MergeRequestEvidenceError::ItemRevisionMismatch, MergeRequestEvidenceError::TicketContentMismatch,
"item_revision_mismatch", "ticket_content_mismatch",
), ),
( (
MergeRequestEvidenceError::SourceSnapshotMismatch, MergeRequestEvidenceError::SourceSnapshotMismatch,
+254 -24
View File
@@ -35,11 +35,24 @@ fn auth_repo(repository_id: &str) -> MergeRequestAuth {
..auth() ..auth()
} }
} }
fn content_digest(title: &str) -> String {
let connection = Connection::open_in_memory().unwrap();
connection.execute_batch("CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,title TEXT,body TEXT); CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);").unwrap();
connection
.execute(
"INSERT INTO typed_tickets VALUES('W','T',?1,'Requirements')",
[title],
)
.unwrap();
ticket::sqlite_ticket_content_digest(&connection, "W", "T")
.unwrap()
.unwrap()
}
fn fixture() -> (tempfile::TempDir, MergeRequestStore) { fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let d = tempfile::tempdir().unwrap(); let d = tempfile::tempdir().unwrap();
let p = d.path().join("db"); let p = d.path().join("db");
let c = Connection::open(&p).unwrap(); let c = Connection::open(&p).unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap(); c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
drop(c); drop(c);
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
assignment_id: "A".into(), assignment_id: "A".into(),
@@ -50,6 +63,221 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap(); let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap();
(d, s) (d, s)
} }
/// Frozen v12 fixture: counters must never be promoted to content attestations.
fn freeze_v12_review_payloads(connection: &Connection) {
let rows = {
let mut statement = connection.prepare(
"SELECT event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review')",
).unwrap();
statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
for (event_id, payload) in rows {
let mut value: serde_json::Value = serde_json::from_str(&payload).unwrap();
let object = value.as_object_mut().unwrap();
object.remove("ticket_content_digest");
object.insert("ticket_item_revision".into(), serde_json::json!("T:0"));
connection
.execute(
"UPDATE merge_request_thread_events SET payload_json=?2 WHERE event_id=?1",
rusqlite::params![event_id, value.to_string()],
)
.unwrap();
}
connection
.execute("UPDATE merge_request_schema SET version=12", [])
.unwrap();
}
#[test]
fn v12_migration_revokes_old_grants_preserves_integration_and_requires_fresh_content_review() {
let (dir, store) = fixture();
open(&store);
let approved = approve(&store, "source", "old-consumed");
let completion = CompleteMergeRequest {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
operation_id: "integration".into(),
approval_event_id: approved.event_id.clone(),
current_subject_ref: "source".into(),
target_ref_before: "before".into(),
target_ref_after: "after".into(),
strategy: MergeStrategy::FastForward,
resolution: ConflictResolution::None,
auth: auth(),
now: at(5),
};
let merged = store.complete(completion.clone()).unwrap();
request(&store, "source", "old-issued");
let connection = Connection::open(dir.path().join("db")).unwrap();
freeze_v12_review_payloads(&connection);
let original_reviews = {
let mut statement = connection.prepare(
"SELECT event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review') ORDER BY event_id",
).unwrap();
statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
migrate(&connection).unwrap();
let archived_reviews = {
let mut statement = connection.prepare(
"SELECT event_id,payload_json FROM merge_request_legacy_review_archive ORDER BY event_id",
).unwrap();
statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
assert_eq!(
archived_reviews, original_reviews,
"historical review bytes must survive conversion"
);
assert!(
connection
.execute_batch("UPDATE merge_request_legacy_review_archive SET payload_json='{}'")
.is_err()
);
assert!(
connection
.execute_batch("DELETE FROM merge_request_legacy_review_archive")
.is_err()
);
let grants: Vec<(String, String, Option<String>)> = connection.prepare(
"SELECT capability_token,status,revoked_at FROM merge_request_review_grants ORDER BY capability_token",
).unwrap().query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap().collect::<Result<_, _>>().unwrap();
assert_eq!(grants[0].0, "old-consumed");
assert_eq!(grants[0].1, "consumed");
assert_eq!(grants[1].0, "old-issued");
assert_eq!(grants[1].1, "revoked");
assert!(grants[1].2.is_some());
let legacy_terms: i64 = connection.query_row(
"SELECT COUNT(*) FROM merge_request_thread_events WHERE payload_json LIKE '%ticket_item_revision%'",
[], |row| row.get(0),
).unwrap();
assert_eq!(legacy_terms, 0);
let saved = store.get_by_id("W", "MR").unwrap();
assert_eq!(saved.merged_result().unwrap(), &merged);
let integration = saved.integration_approval().unwrap();
assert_eq!(integration.event_id, approved.event_id);
assert!(integration.ticket_content_digest.is_empty());
let snapshot = vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(),
subject_ref: "source".into(),
}];
assert_eq!(
requirement_approval(
&[saved],
&content_digest("Title"),
&snapshot,
Some(&approved.event_id)
),
Err(MergeRequestEvidenceError::TicketContentMismatch),
);
assert!(matches!(
store.authorize_review_submission("MR", "old-issued"),
Err(MergeRequestError::Unauthorized(_)),
));
assert!(
store
.submit_review(SubmitMergeRequestReview {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
current_subject_ref: "source".into(),
capability_token: "old-issued".into(),
decision: ReviewDecision::Approve,
body: "late old review".into(),
findings: vec![],
now: at(6),
})
.is_err()
);
assert_eq!(
store.complete(completion).unwrap(),
merged,
"recorded merge replay stays intact"
);
let fresh = approve(&store, "source", "fresh");
assert_eq!(fresh.ticket_content_digest, content_digest("Title"));
migrate(&connection).unwrap();
let requests = store.list_for_ticket("W", "T").unwrap();
assert_eq!(
requirement_approval(
&requests,
&content_digest("Title"),
&snapshot,
Some(&fresh.event_id)
)
.unwrap(),
&fresh,
);
assert_eq!(requests[0].merged_result().unwrap(), &merged);
assert_eq!(
connection
.query_row("SELECT version FROM merge_request_schema", [], |row| row
.get::<_, i64>(0))
.unwrap(),
13
);
}
#[test]
fn v12_migration_rolls_back_payload_and_grant_changes_when_schema_update_fails() {
let (dir, store) = fixture();
open(&store);
request(&store, "source", "old-issued");
let connection = Connection::open(dir.path().join("db")).unwrap();
freeze_v12_review_payloads(&connection);
let before: String = connection
.query_row(
"SELECT payload_json FROM merge_request_thread_events WHERE kind='review_requested'",
[],
|row| row.get(0),
)
.unwrap();
connection.execute_batch(
"CREATE TRIGGER reject_schema_update BEFORE UPDATE ON merge_request_schema BEGIN SELECT RAISE(ABORT,'injected migration failure'); END;",
).unwrap();
assert!(migrate(&connection).is_err());
assert!(
connection
.prepare("SELECT * FROM merge_request_legacy_review_archive")
.is_err(),
"failed conversion must roll back its archive too"
);
assert_eq!(
connection
.query_row("SELECT version FROM merge_request_schema", [], |row| row
.get::<_, i64>(0))
.unwrap(),
12
);
assert_eq!(connection.query_row("SELECT payload_json FROM merge_request_thread_events WHERE kind='review_requested'", [], |row| row.get::<_, String>(0)).unwrap(), before);
assert_eq!(connection.query_row("SELECT status FROM merge_request_review_grants WHERE capability_token='old-issued'", [], |row| row.get::<_, String>(0)).unwrap(), "issued");
connection
.execute_batch("DROP TRIGGER reject_schema_update")
.unwrap();
migrate(&connection).unwrap();
assert!(
store
.authorize_review_submission("MR", "old-issued")
.is_err()
);
}
fn open_for(s: &MergeRequestStore, merge_request_id: &str, repository_id: &str) { fn open_for(s: &MergeRequestStore, merge_request_id: &str, repository_id: &str) {
s.open_merge_request(OpenMergeRequest { s.open_merge_request(OpenMergeRequest {
merge_request_id: merge_request_id.into(), merge_request_id: merge_request_id.into(),
@@ -108,7 +336,7 @@ fn request_for(
.collect(); .collect();
s.request_review(RequestMergeRequestReview { s.request_review(RequestMergeRequestReview {
merge_request_id: merge_request_id.into(), merge_request_id: merge_request_id.into(),
ticket_item_revision: "t".into(), ticket_content_digest: content_digest("Title"),
ticket_merge_request_subjects, ticket_merge_request_subjects,
ticket_id: "T".into(), ticket_id: "T".into(),
subject_ref: subject.into(), subject_ref: subject.into(),
@@ -236,13 +464,13 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
assert!(active); assert!(active);
let json = serde_json::to_string(&mr).unwrap(); let json = serde_json::to_string(&mr).unwrap();
for banned in [ for banned in [
"revision_id", "digest_id",
"attempt_id", "attempt_id",
"base_commit", "base_commit",
"head_commit", "head_commit",
"source_commit", "source_commit",
"result_commit", "result_commit",
"current_revision", "current_digest",
] { ] {
assert!(!json.contains(banned), "{banned} in {json}") assert!(!json.contains(banned), "{banned} in {json}")
} }
@@ -375,7 +603,7 @@ fn review_revocation_invalidates_readiness() {
} }
#[test] #[test]
fn fresh_schema_uses_version_12_and_reopens_as_current() { fn fresh_schema_uses_version_13_and_reopens_as_current() {
let c = Connection::open_in_memory().unwrap(); let c = Connection::open_in_memory().unwrap();
c.execute_batch( c.execute_batch(
"CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));", "CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
@@ -388,7 +616,7 @@ fn fresh_schema_uses_version_12_and_reopens_as_current() {
r.get::<_, i64>(0) r.get::<_, i64>(0)
}) })
.unwrap(), .unwrap(),
12 13
); );
merge_request::migrate(&c).unwrap(); merge_request::migrate(&c).unwrap();
} }
@@ -724,9 +952,8 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
let stale = approve(&store, "subject", "token-stale"); let stale = approve(&store, "subject", "token-stale");
let connection = Connection::open(dir.path().join("db")).unwrap(); let connection = Connection::open(dir.path().join("db")).unwrap();
connection connection
.execute( .execute_batch(
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", "UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
[],
) )
.unwrap(); .unwrap();
connection connection
@@ -750,7 +977,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
readiness readiness
.blockers .blockers
.iter() .iter()
.any(|blocker| blocker.contains("Ticket revision")) .any(|blocker| blocker.contains("Ticket digest"))
); );
let stale_completion = CompleteMergeRequest { let stale_completion = CompleteMergeRequest {
@@ -789,7 +1016,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
.request_review(RequestMergeRequestReview { .request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
ticket_id: "T".into(), ticket_id: "T".into(),
ticket_item_revision: "T:1".into(), ticket_content_digest: content_digest("Rescoped"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject { ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
subject_ref: "subject".into(), subject_ref: "subject".into(),
@@ -852,9 +1079,8 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
.unwrap(); .unwrap();
let connection = Connection::open(dir.path().join("db")).unwrap(); let connection = Connection::open(dir.path().join("db")).unwrap();
connection connection
.execute( .execute_batch(
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", "UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
[],
) )
.unwrap(); .unwrap();
connection connection
@@ -873,11 +1099,11 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&before_refresh, &before_refresh,
"T:1", &content_digest("Rescoped"),
&snapshot, &snapshot,
Some(&integration_approval.event_id), Some(&integration_approval.event_id),
), ),
Err(MergeRequestEvidenceError::ItemRevisionMismatch) Err(MergeRequestEvidenceError::TicketContentMismatch)
); );
assert_eq!(before_refresh[0].merged_result().unwrap(), &merge); assert_eq!(before_refresh[0].merged_result().unwrap(), &merge);
assert_eq!( assert_eq!(
@@ -899,7 +1125,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
.request_review(RequestMergeRequestReview { .request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
ticket_id: "T".into(), ticket_id: "T".into(),
ticket_item_revision: "T:1".into(), ticket_content_digest: content_digest("Rescoped"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject { ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
subject_ref: "subject".into(), subject_ref: "subject".into(),
@@ -927,7 +1153,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&refreshed, &refreshed,
"T:1", &content_digest("Rescoped"),
&snapshot, &snapshot,
Some(&requirement_review.event_id), Some(&requirement_review.event_id),
) )
@@ -935,7 +1161,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
&requirement_review &requirement_review
); );
assert_eq!( assert_eq!(
requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(), requirement_approval(&refreshed, &content_digest("Rescoped"), &snapshot, None).unwrap(),
&requirement_review &requirement_review
); );
assert_eq!(refreshed[0].merged_result().unwrap(), &merge); assert_eq!(refreshed[0].merged_result().unwrap(), &merge);
@@ -970,7 +1196,7 @@ fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() {
let result = store.request_review(RequestMergeRequestReview { let result = store.request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
ticket_id: "T".into(), ticket_id: "T".into(),
ticket_item_revision: "t".into(), ticket_content_digest: content_digest("Title"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject { ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
subject_ref: "subject-one".into(), subject_ref: "subject-one".into(),
@@ -1058,7 +1284,13 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!( assert_eq!(
requirement_approval(&requests, "t", &snapshot, Some(&second.event_id)).unwrap(), requirement_approval(
&requests,
&content_digest("Title"),
&snapshot,
Some(&second.event_id)
)
.unwrap(),
&second &second
); );
let first_request = requests let first_request = requests
@@ -1217,9 +1449,7 @@ fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_app
match case { match case {
"source-mismatch" => altered.subject_ref = "different-source".into(), "source-mismatch" => altered.subject_ref = "different-source".into(),
"not-approved" => altered.decision = ReviewDecision::RequestChanges, "not-approved" => altered.decision = ReviewDecision::RequestChanges,
"request-mismatch" => { "request-mismatch" => altered.ticket_content_digest = "different-digest".into(),
altered.ticket_item_revision = "different-revision".into()
}
_ => unreachable!(), _ => unreachable!(),
} }
connection.execute( connection.execute(
+45 -30
View File
@@ -103,11 +103,15 @@ impl AuthenticatedInputSource {
/// Caller-owned identity for one state-changing Worker command. /// Caller-owned identity for one state-changing Worker command.
/// ///
/// A controller accepts command ids in strictly increasing order. Exact retries /// Commands operate on the controller state at admission time, not a caller's
/// of an accepted id must retain the same command kind. /// previously observed Run. A controller accepts command ids in strictly
/// increasing order; retrying an accepted id cannot apply it to a later Run.
/// Exact retries must retain the same command kind. Removed preconditions are
/// rejected at the wire boundary rather than treated as arrival-time commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct WorkerCommandEnvelope { pub struct WorkerCommandEnvelope {
pub command_id: u64, pub command_id: u64,
} }
@@ -312,16 +316,15 @@ pub enum Method {
/// are immutable and therefore cannot be cancelled here. /// are immutable and therefore cannot be cancelled here.
CancelPendingSubmission { CancelPendingSubmission {
submission_id: String, submission_id: String,
expected_revision: u64,
}, },
/// Remove every queued submission while preserving the active run. /// Remove exactly the observed ordered set of submissions, preserving the
/// active run. Reject if the FIFO content changed before admission.
ClearPendingSubmissions { ClearPendingSubmissions {
expected_revision: u64, expected_submission_ids: Vec<String>,
}, },
/// Activate the next queued submission while the Worker is idle. This is an /// Activate the next queued submission while the Worker is idle. This is an
/// explicit recovery operation and never resumes a paused run implicitly. /// explicit recovery operation and never resumes a paused run implicitly.
ContinuePending { ContinuePending {
expected_revision: u64,
expected_head_id: String, expected_head_id: String,
}, },
Resume { Resume {
@@ -365,7 +368,7 @@ pub enum Method {
ListCompletions { ListCompletions {
kind: CompletionKind, kind: CompletionKind,
prefix: String, prefix: String,
/// Client query generation. Echoed verbatim so identical-prefix ABA /// Client query identity. Echoed verbatim so identical-prefix ABA
/// responses cannot cross edits, target switches, or permission changes. /// responses cannot cross edits, target switches, or permission changes.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>, request_id: Option<String>,
@@ -766,7 +769,6 @@ pub enum InternalWorkerKind {
pub struct CompactionLifecycle { pub struct CompactionLifecycle {
pub schema_version: u32, pub schema_version: u32,
pub compaction_id: String, pub compaction_id: String,
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub internal_worker: Option<InternalWorkerRef>, pub internal_worker: Option<InternalWorkerRef>,
pub state: CompactionLifecycleState, pub state: CompactionLifecycleState,
@@ -806,14 +808,13 @@ pub struct InternalWorkerRef {
pub kind: InternalWorkerKind, pub kind: InternalWorkerKind,
} }
/// Reconnect state for one visible Internal Worker. The revision fences live /// Reconnect state for one visible Internal Worker. The parent serializes
/// `Event::InternalWorker` updates that raced with parent snapshot assembly. /// snapshot publication and subsequent live events on the same ordered stream.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InternalWorkerSnapshot { pub struct InternalWorkerSnapshot {
pub worker: InternalWorkerRef, pub worker: InternalWorkerRef,
pub revision: u64,
pub session: SessionSnapshot, pub session: SessionSnapshot,
/// Public execution metadata for this child. Older snapshots and service /// Public execution metadata for this child. Older snapshots and service
/// workers may omit it; clients must never substitute the parent's values. /// workers may omit it; clients must never substitute the parent's values.
@@ -869,12 +870,11 @@ pub struct PendingSubmissionSummary {
pub byte_len: u64, pub byte_len: u64,
} }
/// Revisioned session-owned FIFO projection used by snapshots and live events. /// Session-owned FIFO content projection used by snapshots and live events.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct PendingSubmissionsSnapshot { pub struct PendingSubmissionsSnapshot {
pub revision: u64,
#[serde(default)] #[serde(default)]
pub notification_count: u32, pub notification_count: u32,
/// Ordered display-only previews of waiting notifications, each bounded like /// Ordered display-only previews of waiting notifications, each bounded like
@@ -1356,7 +1356,7 @@ pub enum Event {
Snapshot { Snapshot {
session: SessionSnapshot, session: SessionSnapshot,
greeting: Greeting, greeting: Greeting,
/// Full revisioned live execution state. `Stopped` remains Runtime /// Full live execution state. `Stopped` remains Runtime
/// catalog authority and is deliberately not represented here. /// catalog authority and is deliberately not represented here.
state: WorkerStateSnapshot, state: WorkerStateSnapshot,
/// Unfinished model output that has already streamed in the current /// Unfinished model output that has already streamed in the current
@@ -1370,10 +1370,10 @@ pub enum Event {
}, },
/// A live event from a parent-owned Internal Worker. The payload reuses the /// A live event from a parent-owned Internal Worker. The payload reuses the
/// normal Worker event vocabulary while the wrapper carries stable origin /// normal Worker event vocabulary while the wrapper carries stable origin
/// identity and a per-child revision fence. /// identity. Apply in arrival order on the parent's serialized stream; a
/// reconnect snapshot replaces the projection before live events resume.
InternalWorker { InternalWorker {
worker: InternalWorkerRef, worker: InternalWorkerRef,
revision: u64,
event: Box<Event>, event: Box<Event>,
}, },
/// Terminal removal fence for one parent-owned Internal Worker session. /// Terminal removal fence for one parent-owned Internal Worker session.
@@ -1383,7 +1383,6 @@ pub enum Event {
/// the projection. /// the projection.
InternalWorkerRemoved { InternalWorkerRemoved {
worker: InternalWorkerRef, worker: InternalWorkerRef,
revision: u64,
}, },
/// Server-side segment log rotated to a fresh `SegmentStart`. /// Server-side segment log rotated to a fresh `SegmentStart`.
/// ///
@@ -2276,6 +2275,28 @@ mod tests {
assert!(error.to_string().contains("params")); assert!(error.to_string().contains("params"));
} }
#[test]
fn lifecycle_commands_reject_removed_nested_preconditions() {
for method in ["pause", "cancel", "resume", "compact", "shutdown"] {
let wire = serde_json::json!({
"method": method,
"params": { "command": { "command_id": 11 } }
});
// Current commands are arrival-time operations, not Run-bound CAS.
assert!(serde_json::from_value::<Method>(wire.clone()).is_ok());
for field in [
"expected_execution_generation",
"expected_worker_state_revision",
] {
let mut old_command = wire.clone();
old_command["params"]["command"][field] = serde_json::json!(1);
let error = serde_json::from_value::<Method>(old_command)
.expect_err("removed command preconditions must not be silently ignored");
assert!(error.to_string().contains(field), "{method}: {error}");
}
}
}
#[test] #[test]
fn lifecycle_methods_roundtrip_with_command_identity() { fn lifecycle_methods_roundtrip_with_command_identity() {
for method in [ for method in [
@@ -3048,7 +3069,6 @@ mod tests {
CompactionLifecycle { CompactionLifecycle {
schema_version: 2, schema_version: 2,
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(), compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
revision: 1,
internal_worker: None, internal_worker: None,
state, state,
started_at_ms: 1_700_000_000_000, started_at_ms: 1_700_000_000_000,
@@ -3069,7 +3089,9 @@ mod tests {
assert_eq!(parsed["event"], "compact_start"); assert_eq!(parsed["event"], "compact_start");
assert_eq!(parsed["data"]["lifecycle"]["state"], "running"); assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1)); assert!(
matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.state == CompactionLifecycleState::Running)
);
} }
#[test] #[test]
@@ -3292,7 +3314,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: InternalWorkerKind::SubWorker, kind: InternalWorkerKind::SubWorker,
}, },
revision: 7,
event: Box::new(Event::TextDone { event: Box::new(Event::TextDone {
text: "result".into(), text: "result".into(),
}), }),
@@ -3300,14 +3321,9 @@ mod tests {
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Event::InternalWorker { Event::InternalWorker { worker, event } => {
worker,
revision,
event,
} => {
assert_eq!(worker.session_id, "session-1"); assert_eq!(worker.session_id, "session-1");
assert_eq!(worker.parent_session_id.as_deref(), Some("parent-session")); assert_eq!(worker.parent_session_id.as_deref(), Some("parent-session"));
assert_eq!(revision, 7);
assert!(matches!(*event, Event::TextDone { ref text } if text == "result")); assert!(matches!(*event, Event::TextDone { ref text } if text == "result"));
} }
other => panic!("expected internal Worker event, got {other:?}"), other => panic!("expected internal Worker event, got {other:?}"),
@@ -3323,21 +3339,20 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: InternalWorkerKind::SubWorker, kind: InternalWorkerKind::SubWorker,
}, },
revision: 8,
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!( assert!(matches!(
decoded, decoded,
Event::InternalWorkerRemoved { worker, revision } Event::InternalWorkerRemoved { worker }
if worker.session_id == "session-1" && revision == 8 if worker.session_id == "session-1"
)); ));
} }
#[test] #[test]
fn pending_notification_previews_are_optional_and_roundtrip() { fn pending_notification_previews_are_optional_and_roundtrip() {
let legacy = serde_json::json!({ let legacy = serde_json::json!({
"revision": 1, "notification_count": 1, "notification_count": 1,
"head_id": "notification-head", "submissions": [] "head_id": "notification-head", "submissions": []
}); });
let mut snapshot: PendingSubmissionsSnapshot = let mut snapshot: PendingSubmissionsSnapshot =
-1
View File
@@ -322,7 +322,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
parent_session_id: Some("fixture-parent-session".into()), parent_session_id: Some("fixture-parent-session".into()),
kind: InternalWorkerKind::SubWorker, kind: InternalWorkerKind::SubWorker,
}, },
revision: 1,
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(), pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
+3 -12
View File
@@ -250,7 +250,6 @@ pub struct WorkingDirectoryRepository {
pub id: String, pub id: String,
pub provider: String, pub provider: String,
pub source: RepositorySource, pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String, pub source_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>, pub selector: Option<RepositorySelector>,
@@ -291,9 +290,6 @@ pub struct BackendResourceHandle {
pub operation: BackendResourceOperation, pub operation: BackendResourceOperation,
pub expires_at_unix_seconds: i64, pub expires_at_unix_seconds: i64,
pub nonce: String, pub nonce: String,
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
pub max_bytes: u64, pub max_bytes: u64,
pub content_type: String, pub content_type: String,
pub redaction: ResourceRedactionPolicy, pub redaction: ResourceRedactionPolicy,
@@ -305,14 +301,14 @@ pub struct BackendResourceHandle {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RepositorySshCredentialCandidate { pub struct RepositorySshCredentialCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, pub public_key_fingerprint: String,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RepositorySshMaterializationAccess { pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>, pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String, pub host_trust_id: String,
pub host_trust_revision: u64, pub host_key_fingerprint: String,
pub access: RepositoryAccessMode, pub access: RepositoryAccessMode,
pub expires_at_epoch_seconds: u64, pub expires_at_epoch_seconds: u64,
pub repository_id: String, pub repository_id: String,
@@ -326,7 +322,6 @@ pub struct RepositoryMaterializationContext {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
pub operation_id: String, pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String, pub config_projection_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>, pub ssh: Option<RepositorySshMaterializationAccess>,
@@ -427,7 +422,7 @@ pub struct BackendJobExecutionBinding {
pub job_id: String, pub job_id: String,
pub attempt_id: String, pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>, pub input_digest: Option<String>,
#[serde(default)] #[serde(default)]
pub subjektiv_consolidation: bool, pub subjektiv_consolidation: bool,
} }
@@ -689,13 +684,11 @@ pub struct WorkerRetentionExecutionRequest {
pub workspace_id: String, pub workspace_id: String,
pub source_runtime_id: String, pub source_runtime_id: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub expected_worker_revision: String,
pub source_created_at: String, pub source_created_at: String,
pub removed_at: String, pub removed_at: String,
pub effective_profile: Option<String>, pub effective_profile: Option<String>,
pub retention_class: Option<String>, pub retention_class: Option<String>,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition, pub diagnostics_disposition: DiagnosticsDisposition,
} }
@@ -718,7 +711,6 @@ pub struct WorkerSessionArchiveManifest {
pub content_bytes: u64, pub content_bytes: u64,
pub content_file_count: u64, pub content_file_count: u64,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String, pub operation_id: String,
pub input_fingerprint: String, pub input_fingerprint: String,
} }
@@ -727,7 +719,6 @@ pub struct WorkerSessionArchiveManifest {
pub struct WorkerRetentionExecutionResult { pub struct WorkerRetentionExecutionResult {
pub operation_id: String, pub operation_id: String,
pub input_fingerprint: String, pub input_fingerprint: String,
pub expected_worker_revision: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition, pub diagnostics_disposition: DiagnosticsDisposition,
+77 -31
View File
@@ -41,6 +41,39 @@ fn canonical_positive_decimal(value: &str) -> bool {
&& value.bytes().all(|byte| byte.is_ascii_digit()) && value.bytes().all(|byte| byte.is_ascii_digit())
&& value.parse::<i64>().is_ok_and(|value| value > 0) && value.parse::<i64>().is_ok_and(|value| value > 0)
} }
fn node_mutation_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
let value = String::deserialize(deserializer)?;
if value.len() > 128 || value.chars().any(char::is_control) {
return Err(serde::de::Error::custom(
"invalid committed mutation request ID",
));
}
Ok(value)
}
fn mutation_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
let value = node_mutation_id(deserializer)?;
if value.is_empty() {
return Err(serde::de::Error::custom(
"expected a committed mutation request ID",
));
}
Ok(value)
}
fn optional_mutation_id<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)?
.map(|value| {
if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
Err(serde::de::Error::custom(
"invalid committed mutation request ID",
))
} else {
Ok(value)
}
})
.transpose()
}
fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> { fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
let value = String::deserialize(deserializer)?; let value = String::deserialize(deserializer)?;
if value.len() > DRIVE_TEXT_MAX_BYTES { if value.len() > DRIVE_TEXT_MAX_BYTES {
@@ -110,9 +143,10 @@ pub struct DriveEntry {
pub parent: Option<DriveEntryRef>, pub parent: Option<DriveEntryRef>,
pub name: String, pub name: String,
pub kind: DriveEntryKind, pub kind: DriveEntryKind,
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "node_mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
pub revision: String, /// Request ID of the last committed mutation, or empty for the immutable root.
pub last_mutation_id: String,
/// File length in bytes; absent for folders. /// File length in bytes; absent for folders.
#[schemars(range(min = 0, max = 16_777_216))] #[schemars(range(min = 0, max = 16_777_216))]
pub size: Option<u32>, pub size: Option<u32>,
@@ -201,10 +235,10 @@ pub struct DriveReadChunkQuery {
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(regex(pattern = "^[1-9][0-9]*$"))]
pub id: String, pub id: String,
/// Bind a multi-chunk read to the same file revision. /// Require each chunk to match the same committed mutation observed in metadata.
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
pub expected_revision: String, pub expected_mutation_id: String,
#[schemars(range(min = 0, max = 16_777_216))] #[schemars(range(min = 0, max = 16_777_216))]
pub offset: u32, pub offset: u32,
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")] #[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
@@ -219,8 +253,8 @@ pub struct DriveDownloadQuery {
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(regex(pattern = "^[1-9][0-9]*$"))]
pub id: String, pub id: String,
#[serde(default, deserialize_with = "optional_decimal")] #[serde(default, deserialize_with = "optional_mutation_id")]
pub expected_revision: Option<String>, pub expected_mutation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -248,26 +282,26 @@ pub enum DriveMutation {
}, },
UpdateText { UpdateText {
id: DriveEntryRef, id: DriveEntryRef,
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
expected_revision: String, expected_mutation_id: String,
#[serde(deserialize_with = "bounded_text")] #[serde(deserialize_with = "bounded_text")]
text: String, text: String,
content_type: String, content_type: String,
}, },
Relocate { Relocate {
id: DriveEntryRef, id: DriveEntryRef,
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
expected_revision: String, expected_mutation_id: String,
parent: DriveEntryRef, parent: DriveEntryRef,
name: String, name: String,
}, },
Delete { Delete {
id: DriveEntryRef, id: DriveEntryRef,
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
expected_revision: String, expected_mutation_id: String,
}, },
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -287,8 +321,8 @@ pub enum DriveUploadOperation {
Update, Update,
} }
/// Flat query metadata accompanies the binary body. The service rejects invalid /// Flat query metadata accompanies the binary body. The service rejects invalid
/// combinations: create requires parent_id/name and forbids id/expected_revision; /// combinations: create requires parent_id/name and forbids id/expected_mutation_id;
/// update requires id/expected_revision and forbids parent_id/name. Both targets /// update requires id/expected_mutation_id and forbids parent_id/name. Both targets
/// are bound by entry_workspace_id; declared size and SHA256 must match the body. /// are bound by entry_workspace_id; declared size and SHA256 must match the body.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -303,8 +337,8 @@ pub struct DriveUploadQuery {
pub name: Option<String>, pub name: Option<String>,
#[serde(default, deserialize_with = "optional_decimal")] #[serde(default, deserialize_with = "optional_decimal")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default, deserialize_with = "optional_decimal")] #[serde(default, deserialize_with = "optional_mutation_id")]
pub expected_revision: Option<String>, pub expected_mutation_id: Option<String>,
pub content_type: String, pub content_type: String,
/// Values above the file limit still decode as u32 so the service returns /// Values above the file limit still decode as u32 so the service returns
/// typed Limit rather than a generic request-decoding error. /// typed Limit rather than a generic request-decoding error.
@@ -422,7 +456,7 @@ impl DriveApiError {
message: match code { message: match code {
Denied => "Drive access denied", Denied => "Drive access denied",
NotFound => "Drive resource not found", NotFound => "Drive resource not found",
Conflict => "Drive revision or name conflict", Conflict => "Drive content, location, or name conflict",
Invalid => "Invalid Drive request", Invalid => "Invalid Drive request",
Limit => "Drive request exceeds a limit", Limit => "Drive request exceeds a limit",
StorageUnavailable => "Drive storage unavailable", StorageUnavailable => "Drive storage unavailable",
@@ -614,7 +648,7 @@ mod tests {
let value = json!({ let value = json!({
"entry":{"workspace_id":"a","node_id":"7"}, "entry":{"workspace_id":"a","node_id":"7"},
"parent":{"workspace_id":"a","node_id":"1"}, "parent":{"workspace_id":"a","node_id":"1"},
"name":"file.txt","kind":"file","revision":"2","size":5, "name":"file.txt","kind":"file","last_mutation_id":"2","size":5,
"content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z", "content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z",
"latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7" "latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7"
}); });
@@ -665,7 +699,7 @@ mod tests {
fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() { fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() {
let valid = json!({"request_id":"request-a", "mutation":{ let valid = json!({"request_id":"request-a", "mutation":{
"operation":"update_text", "id":{"workspace_id":"a","node_id":"7"}, "operation":"update_text", "id":{"workspace_id":"a","node_id":"7"},
"expected_revision":"9007199254740993", "text":"hello", "content_type":"text/plain" "expected_mutation_id":"write-資料/one", "text":"hello", "content_type":"text/plain"
}}); }});
let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap(); let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), valid); assert_eq!(serde_json::to_value(request).unwrap(), valid);
@@ -675,9 +709,16 @@ mod tests {
let mut invalid = valid.clone(); let mut invalid = valid.clone();
invalid["mutation"]["id"] = json!("7"); invalid["mutation"]["id"] = json!("7");
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err()); assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
for bad in [
"".to_string(),
"\ncontrol".to_string(),
"x".repeat(129),
"é".repeat(65),
] {
let mut invalid = valid.clone(); let mut invalid = valid.clone();
invalid["mutation"]["expected_revision"] = json!("01"); invalid["mutation"]["expected_mutation_id"] = json!(bad);
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err()); assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
}
// UTF-8 byte count, not character count, determines the text bound. // UTF-8 byte count, not character count, determines the text bound.
let mut boundary = valid; let mut boundary = valid;
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2)); boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2));
@@ -695,18 +736,18 @@ mod tests {
) )
.is_err() .is_err()
); );
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":size})).is_err()); assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":0,"length":size})).is_err());
} }
let default: DriveReadTextQuery = let default: DriveReadTextQuery =
serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap(); serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap();
assert_eq!(default.max_bytes, 65536); assert_eq!(default.max_bytes, 65536);
let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":65536})).unwrap(); let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":0,"length":65536})).unwrap();
assert_eq!(chunk.length, 65536); assert_eq!(chunk.length, 65536);
} }
#[test] #[test]
fn drive_upload_requires_binding_declared_integrity_and_request_identity() { fn drive_upload_requires_binding_declared_integrity_and_request_identity() {
let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_revision":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)}); let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_mutation_id":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)});
assert!(serde_json::from_value::<DriveUploadQuery>(valid.clone()).is_ok()); assert!(serde_json::from_value::<DriveUploadQuery>(valid.clone()).is_ok());
for field in ["entry_workspace_id", "size", "sha256", "request_id"] { for field in ["entry_workspace_id", "size", "sha256", "request_id"] {
let mut missing = valid.clone(); let mut missing = valid.clone();
@@ -725,7 +766,7 @@ mod tests {
"service must receive oversized declared sizes for typed Limit classification" "service must receive oversized declared sizes for typed Limit classification"
); );
} }
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok()); assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok());
let mut invalid = valid; let mut invalid = valid;
invalid["bytes"] = json!([0, 255]); invalid["bytes"] = json!([0, 255]);
assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err()); assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err());
@@ -763,7 +804,12 @@ mod tests {
for (code, wire, status, message) in [ for (code, wire, status, message) in [
(Denied, "denied", 403, "Drive access denied"), (Denied, "denied", 403, "Drive access denied"),
(NotFound, "not_found", 404, "Drive resource not found"), (NotFound, "not_found", 404, "Drive resource not found"),
(Conflict, "conflict", 409, "Drive revision or name conflict"), (
Conflict,
"conflict",
409,
"Drive content, location, or name conflict",
),
(Invalid, "invalid", 400, "Invalid Drive request"), (Invalid, "invalid", 400, "Invalid Drive request"),
(Limit, "limit", 413, "Drive request exceeds a limit"), (Limit, "limit", 413, "Drive request exceeds a limit"),
( (
File diff suppressed because it is too large Load Diff
@@ -1996,33 +1996,39 @@ mod tests {
#[test] #[test]
fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() { fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut fixture: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = fixture["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"] = serde_json::json!({"type": "integer", "format": "uint64", "minimum": 0, "maximum": 9_007_199_254_740_991_u64});
fixture["components"]["schemas"]["RepositorySummary"]["required"]
.as_array_mut()
.unwrap()
.push(serde_json::json!("size_bytes"));
let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] =
serde_json::json!(9_007_199_254_740_992_u64); serde_json::json!(9_007_199_254_740_992_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err(); let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number")); assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] = document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["minimum"] =
serde_json::json!(-1); serde_json::json!(-1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err(); let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number")); assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] =
serde_json::json!(9_007_199_254_740_990_u64); serde_json::json!(9_007_199_254_740_990_u64);
let output = generate_repository_typescript(&document.to_string()).unwrap(); let output = generate_repository_typescript(&document.to_string()).unwrap();
assert!(output.contains( assert!(output.contains(
"source_revision: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }" "size_bytes: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }"
)); ));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] = document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["format"] =
serde_json::json!("int64"); serde_json::json!("int64");
let error = generate_repository_typescript(&document.to_string()).unwrap_err(); let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("unsupported integer format")); assert!(error.to_string().contains("unsupported integer format"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"] document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
.as_array_mut() .as_array_mut()
.unwrap() .unwrap()
+15 -8
View File
@@ -82,9 +82,6 @@ pub struct WorkspaceConfigNode {
pub struct WorkspaceConfigObserveResponse { pub struct WorkspaceConfigObserveResponse {
pub connection_id: String, pub connection_id: String,
pub validator: String, pub validator: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub revision: u64,
pub digest: String, pub digest: String,
/// Canonical commit entrypoints from this exact metadata snapshot. /// Canonical commit entrypoints from this exact metadata snapshot.
pub entrypoints: Vec<String>, pub entrypoints: Vec<String>,
@@ -121,9 +118,6 @@ pub struct WorkspaceConfigCommitRequest {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceConfigCommitResponse { pub struct WorkspaceConfigCommitResponse {
pub validator: String, pub validator: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub revision: u64,
pub digest: String, pub digest: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -178,11 +172,11 @@ mod tests {
#[test] #[test]
fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() { fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() {
let value = serde_json::json!({"connection_id":"c", "validator":"v", "revision":7, "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]}); let value = serde_json::json!({"connection_id":"c", "validator":"v", "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]});
let response: WorkspaceConfigObserveResponse = let response: WorkspaceConfigObserveResponse =
serde_json::from_value(value.clone()).unwrap(); serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value); assert_eq!(serde_json::to_value(response).unwrap(), value);
for field in ["revision", "digest", "entrypoints"] { for field in ["validator", "digest", "entrypoints"] {
let mut missing = value.clone(); let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field); missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(missing).is_err()); assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(missing).is_err());
@@ -208,6 +202,19 @@ mod tests {
); );
} }
#[test]
fn workspace_config_commit_response_is_content_based() {
let value = serde_json::json!({"validator":"v", "digest":"d"});
let response: WorkspaceConfigCommitResponse =
serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value);
for field in ["validator", "digest"] {
let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<WorkspaceConfigCommitResponse>(missing).is_err());
}
}
#[test] #[test]
fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() { fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() {
for classification in [ for classification in [
+3 -1
View File
@@ -51,7 +51,9 @@ pub enum LoggedSessionHistoryOrigin {
FlowInstruction { FlowInstruction {
selector: String, selector: String,
definition_id: String, definition_id: String,
definition_revision: u64, /// Exact compiled source used for these instructions; absent in older logs.
#[serde(default, skip_serializing_if = "Option::is_none")]
definition_digest: Option<String>,
instance_id: String, instance_id: String,
state_id: String, state_id: String,
}, },
+2 -2
View File
@@ -3662,7 +3662,7 @@ mod tests {
LoggedSessionHistoryOrigin::FlowInstruction { LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(), selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(), definition_id: "flow-definition".into(),
definition_revision: 7, definition_digest: Some("sha256:flow-source".into()),
instance_id: "flow-instance".into(), instance_id: "flow-instance".into(),
state_id: "implement".into(), state_id: "implement".into(),
}, },
@@ -3695,7 +3695,7 @@ mod tests {
origin: LoggedSessionHistoryOrigin::FlowInstruction { origin: LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(), selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(), definition_id: "flow-definition".into(),
definition_revision: 7, definition_digest: Some("sha256:flow-source".into()),
instance_id: "flow-instance".into(), instance_id: "flow-instance".into(),
state_id: "implement".into(), state_id: "implement".into(),
}, },
+1 -1
View File
@@ -1156,7 +1156,7 @@ mod tests {
)))], )))],
extensions: vec![SessionExtension::new( extensions: vec![SessionExtension::new(
"flow.runtime.v1", "flow.runtime.v1",
serde_json::json!({ "state": "implement", "revision": 0 }), serde_json::json!({ "state": "implement" }),
)], )],
}; };
let json = serde_json::to_string(&entry).unwrap(); let json = serde_json::to_string(&entry).unwrap();
+23 -11
View File
@@ -77,7 +77,6 @@ impl SystemReminder {
pub struct PromptRenderProvenance { pub struct PromptRenderProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>, pub workspace_id: Option<String>,
pub config_revision: u64,
pub source_digest: String, pub source_digest: String,
pub projection_digest: String, pub projection_digest: String,
pub logical_name: String, pub logical_name: String,
@@ -176,10 +175,14 @@ pub enum SystemItem {
}, },
/// A Host-fetched change to user-managed Subject behavior. Each changed /// A Host-fetched change to user-managed Subject behavior. Each changed
/// revision is appended rather than rewriting prior prompt/session content. /// body is appended rather than rewriting prior prompt/session content.
SubjectBehaviorRefresh { SubjectBehaviorRefresh {
body: String, body: String,
behavior_revision: u64, /// Exact user-managed behavior acknowledged after durable commit. Old
/// logs omit this value; an empty value is not an acknowledgement of a
/// nonempty behavior and does not replace their stored rendered body.
#[serde(default)]
behavior_md: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
prompt_provenance: Option<PromptRenderProvenance>, prompt_provenance: Option<PromptRenderProvenance>,
}, },
@@ -292,22 +295,31 @@ mod tests {
} }
#[test] #[test]
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() { fn subject_behavior_refresh_round_trips_with_exact_body() {
let item = SystemItem::SubjectBehaviorRefresh { let item = SystemItem::SubjectBehaviorRefresh {
body: "current Subject context".to_string(), body: "current Subject context".to_string(),
behavior_revision: 7, behavior_md: "exact behavior".to_string(),
prompt_provenance: None, prompt_provenance: None,
}; };
let raw = serde_json::to_string(&item).unwrap(); let raw = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.history_text(), "current Subject context"); assert_eq!(parsed.history_text(), "current Subject context");
assert!(matches!( assert!(
parsed, matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior")
SystemItem::SubjectBehaviorRefresh { );
behavior_revision: 7,
..
} }
));
#[test]
fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() {
let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#;
let item: SystemItem = serde_json::from_str(old).unwrap();
assert_eq!(item.history_text(), "exact archived body");
assert!(
serde_json::to_value(item)
.unwrap()
.get("behavior_revision")
.is_none()
);
} }
#[test] #[test]
+35 -23
View File
@@ -13,7 +13,10 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use thiserror::Error; use thiserror::Error;
const SCHEMA_VERSION: i64 = 2; #[path = "job_store/legacy_migrations.rs"]
mod legacy_migrations;
const SCHEMA_VERSION: i64 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -69,7 +72,10 @@ impl JobSnapshot {
pub fn outcome(&self) -> job::JobOutcome { pub fn outcome(&self) -> job::JobOutcome {
job::JobOutcome { job::JobOutcome {
job_id: self.request.job_id.clone(), job_id: self.request.job_id.clone(),
input_revision: self.request.input_revision.clone(), input_digest: self
.request
.input_digest()
.expect("validated immutable Job input"),
attempt_id: self.attempt.attempt_id.clone(), attempt_id: self.attempt.attempt_id.clone(),
attempt: self.attempt.number, attempt: self.attempt.number,
state: match self.state { state: match self.state {
@@ -135,8 +141,8 @@ pub enum JobStoreError {
ConcurrentLimit, ConcurrentLimit,
#[error("result or lifecycle operation is not bound to the current Job attempt")] #[error("result or lifecycle operation is not bound to the current Job attempt")]
AttemptMismatch, AttemptMismatch,
#[error("result input revision does not match the immutable request")] #[error("result input digest does not match the immutable request")]
RevisionMismatch, DigestMismatch,
#[error("accepted Job result cannot be changed")] #[error("accepted Job result cannot be changed")]
ResultConflict, ResultConflict,
#[error("invalid Job transition: {0}")] #[error("invalid Job transition: {0}")]
@@ -176,8 +182,11 @@ impl JobStore {
tx.execute_batch(GRANT_SCHEMA)?; tx.execute_batch(GRANT_SCHEMA)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
} }
1 => { 1 | 2 => {
if version == 1 {
tx.execute_batch(GRANT_SCHEMA)?; tx.execute_batch(GRANT_SCHEMA)?;
}
legacy_migrations::migrate_legacy_jobs(&tx)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
} }
SCHEMA_VERSION => {} SCHEMA_VERSION => {}
@@ -370,8 +379,8 @@ impl JobStore {
.transaction_with_behavior(TransactionBehavior::Immediate)?; .transaction_with_behavior(TransactionBehavior::Immediate)?;
let snapshot = require_snapshot(&tx, &bound.job_id)?; let snapshot = require_snapshot(&tx, &bound.job_id)?;
check_attempt(&snapshot, &bound.attempt_id)?; check_attempt(&snapshot, &bound.attempt_id)?;
if snapshot.request.input_revision != bound.input_revision { if snapshot.request.input_digest()? != bound.input_digest {
return Err(JobStoreError::RevisionMismatch); return Err(JobStoreError::DigestMismatch);
} }
let (digest, encoded) = let (digest, encoded) =
job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?; job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?;
@@ -527,13 +536,13 @@ fn insert_attempt(
number: u8, number: u8,
) -> Result<(), JobStoreError> { ) -> Result<(), JobStoreError> {
conn.execute( conn.execute(
"INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state) "INSERT INTO job_attempts (job_id, number, attempt_id, input_digest, state)
VALUES (?1, ?2, ?3, ?4, 'reserved')", VALUES (?1, ?2, ?3, ?4, 'reserved')",
params![ params![
request.job_id, request.job_id,
number, number,
job::attempt_id(&request.job_id, number), job::attempt_id(&request.job_id, number),
request.input_revision request.input_digest()?
], ],
)?; )?;
Ok(()) Ok(())
@@ -669,7 +678,7 @@ CREATE TABLE job_attempts (
job_id TEXT NOT NULL REFERENCES job_intents(job_id), job_id TEXT NOT NULL REFERENCES job_intents(job_id),
number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255), number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255),
attempt_id TEXT NOT NULL UNIQUE, attempt_id TEXT NOT NULL UNIQUE,
input_revision TEXT NOT NULL, input_digest TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown', 'cancelled')), state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown', 'cancelled')),
result_json TEXT, result_json TEXT,
result_digest TEXT, result_digest TEXT,
@@ -685,7 +694,7 @@ CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF
job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents
BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END; BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END;
CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF
job_id, number, attempt_id, input_revision ON job_attempts job_id, number, attempt_id, input_digest ON job_attempts
BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END; BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END;
CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts
WHEN OLD.result_json IS NOT NULL AND WHEN OLD.result_json IS NOT NULL AND
@@ -705,7 +714,6 @@ mod tests {
JobRequest { JobRequest {
job_id: id.into(), job_id: id.into(),
purpose: "fixture".into(), purpose: "fixture".into(),
input_revision: "revision-1".into(),
input_ref: "fixture:input".into(), input_ref: "fixture:input".into(),
input: json!({"immutable": [1, 2, 3]}), input: json!({"immutable": [1, 2, 3]}),
instruction: "Produce a structured result".into(), instruction: "Produce a structured result".into(),
@@ -725,7 +733,10 @@ mod tests {
JobResultSubmission { JobResultSubmission {
job_id: snapshot.request.job_id.clone(), job_id: snapshot.request.job_id.clone(),
attempt_id: snapshot.attempt.attempt_id.clone(), attempt_id: snapshot.attempt.attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(), input_digest: snapshot
.request
.input_digest()
.expect("validated immutable Job input"),
result, result,
} }
} }
@@ -762,10 +773,10 @@ mod tests {
for sql in [ for sql in [
"UPDATE job_intents SET request_json = '{}' WHERE job_id = 'identity'", "UPDATE job_intents SET request_json = '{}' WHERE job_id = 'identity'",
"UPDATE job_intents SET fingerprint = 'changed' WHERE job_id = 'identity'", "UPDATE job_intents SET fingerprint = 'changed' WHERE job_id = 'identity'",
"UPDATE job_attempts SET input_revision = 'changed' WHERE job_id = 'identity'", "UPDATE job_attempts SET input_digest = 'changed' WHERE job_id = 'identity'",
"UPDATE job_attempts SET attempt_id = 'changed' WHERE job_id = 'identity'", "UPDATE job_attempts SET attempt_id = 'changed' WHERE job_id = 'identity'",
"UPDATE job_intents SET current_attempt = 2 WHERE job_id = 'identity'", "UPDATE job_intents SET current_attempt = 2 WHERE job_id = 'identity'",
"INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state) "INSERT INTO job_attempts (job_id, number, attempt_id, input_digest, state)
VALUES ('absent', 1, 'absent:attempt:1', 'r', 'reserved')", VALUES ('absent', 1, 'absent:attempt:1', 'r', 'reserved')",
] { ] {
assert!( assert!(
@@ -818,9 +829,6 @@ mod tests {
assert_eq!(store.reserve(original.clone()).unwrap(), snapshot); assert_eq!(store.reserve(original.clone()).unwrap(), snapshot);
let mut variants = Vec::new(); let mut variants = Vec::new();
let mut changed = original.clone(); let mut changed = original.clone();
changed.input_revision = "revision-2".into();
variants.push(changed);
let mut changed = original.clone();
changed.input = json!({"changed": true}); changed.input = json!({"changed": true});
variants.push(changed); variants.push(changed);
let mut changed = original.clone(); let mut changed = original.clone();
@@ -917,12 +925,16 @@ mod tests {
Err(JobStoreError::AttemptMismatch) Err(JobStoreError::AttemptMismatch)
)); ));
let prior: (String, String, String) = store.conn.query_row( let prior: (String, String, String) = store.conn.query_row(
"SELECT state, failure, input_revision FROM job_attempts WHERE job_id = 'retry' AND number = 1", "SELECT state, failure, input_digest FROM job_attempts WHERE job_id = 'retry' AND number = 1",
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
).unwrap(); ).unwrap();
assert_eq!( assert_eq!(
prior, prior,
("failed".into(), "timeout".into(), "revision-1".into()) (
"failed".into(),
"timeout".into(),
original.input_digest().unwrap()
)
); );
store store
.finish( .finish(
@@ -960,10 +972,10 @@ mod tests {
Err(JobStoreError::AttemptMismatch) Err(JobStoreError::AttemptMismatch)
)); ));
let mut invalid = valid.clone(); let mut invalid = valid.clone();
invalid.input_revision = "revision-2".into(); invalid.input_digest = "wrong-digest".into();
assert!(matches!( assert!(matches!(
store.accept(&invalid), store.accept(&invalid),
Err(JobStoreError::RevisionMismatch) Err(JobStoreError::DigestMismatch)
)); ));
let mut invalid = valid.clone(); let mut invalid = valid.clone();
invalid.result = json!("x".repeat(32)); invalid.result = json!("x".repeat(32));
@@ -1537,7 +1549,7 @@ mod tests {
let absent = JobResultSubmission { let absent = JobResultSubmission {
job_id: "missing".into(), job_id: "missing".into(),
attempt_id: "attempt".into(), attempt_id: "attempt".into(),
input_revision: "r".into(), input_digest: request("missing").input_digest().unwrap(),
result: json!({}), result: json!({}),
}; };
assert!(matches!( assert!(matches!(
@@ -0,0 +1,295 @@
use super::*;
/// Frozen schema-1/2 adapter. Preserve original intent and attempt bindings
/// before replacing counters with identities derived from immutable input.
pub(super) fn migrate_legacy_jobs(conn: &Connection) -> Result<(), JobStoreError> {
conn.execute_batch(
"CREATE TABLE legacy_job_archive (
job_id TEXT PRIMARY KEY NOT NULL,
request_json TEXT NOT NULL,
fingerprint TEXT NOT NULL,
attempts_json TEXT NOT NULL
);
DROP TRIGGER job_intent_immutable;
DROP TRIGGER job_attempt_immutable;",
)?;
let intents = conn
.prepare("SELECT job_id, request_json, fingerprint FROM job_intents")?
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
for (id, original, fingerprint) in intents {
let attempts = conn.prepare(
"SELECT number, attempt_id, input_revision, state, result_json, result_digest, failure
FROM job_attempts WHERE job_id = ?1 ORDER BY number",
)?.query_map([&id], |r| Ok(serde_json::json!({
"number":r.get::<_, u8>(0)?, "attempt_id":r.get::<_, String>(1)?,
"input_revision":r.get::<_, String>(2)?, "state":r.get::<_, String>(3)?,
"result_json":r.get::<_, Option<String>>(4)?, "result_digest":r.get::<_, Option<String>>(5)?,
"failure":r.get::<_, Option<String>>(6)?
})))?.collect::<Result<Vec<_>, _>>()?;
conn.execute(
"INSERT INTO legacy_job_archive VALUES (?1, ?2, ?3, ?4)",
params![id, original, fingerprint, serde_json::to_string(&attempts)?],
)?;
let mut value: Value = serde_json::from_str(&original)?;
value
.as_object_mut()
.ok_or_else(|| JobStoreError::Corrupt("legacy request is not an object".into()))?
.remove("input_revision");
let request: JobRequest = serde_json::from_value(value)?;
if request.job_id != id {
return Err(JobStoreError::Corrupt(
"legacy request Job id mismatch".into(),
));
}
conn.execute(
"UPDATE job_intents SET request_json = ?2, fingerprint = ?3 WHERE job_id = ?1",
params![id, serde_json::to_string(&request)?, request.fingerprint()?],
)?;
conn.execute(
"UPDATE job_attempts SET input_revision = ?2 WHERE job_id = ?1",
params![id, request.input_digest()?],
)?;
}
conn.execute_batch(
"ALTER TABLE job_attempts RENAME COLUMN input_revision TO input_digest;
CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF
job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents
BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END;
CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF
job_id, number, attempt_id, input_digest ON job_attempts
BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END;
CREATE TRIGGER legacy_job_archive_immutable BEFORE UPDATE ON legacy_job_archive
BEGIN SELECT RAISE(ABORT, 'immutable legacy Job archive'); END;
CREATE TRIGGER legacy_job_archive_retained BEFORE DELETE ON legacy_job_archive
BEGIN SELECT RAISE(ABORT, 'retained legacy Job archive'); END;",
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn request(id: &str) -> JobRequest {
JobRequest {
job_id: id.into(),
purpose: "migration".into(),
input_ref: "fixture:input".into(),
input: json!({"immutable":[1,2,3]}),
instruction: "Return a result".into(),
profile: "builtin:job".into(),
serialization_key: None,
limits: Default::default(),
}
}
fn seed(path: &Path, version: i64) -> (String, String, String) {
let mut conn = Connection::open(path).unwrap();
feature_storage::configure_connection(&conn).unwrap();
let tx = conn.transaction().unwrap();
tx.execute_batch(&SCHEMA.replace("input_digest", "input_revision"))
.unwrap();
if version == 2 {
tx.execute_batch(GRANT_SCHEMA).unwrap();
}
let mut original = serde_json::to_value(request("done")).unwrap();
original["input_revision"] = json!("old-token");
let original = serde_json::to_string_pretty(&original).unwrap();
let receipt = "{\"accepted\":true,\"input_revision\":\"receipt-domain-data\"}".to_string();
let digest = job::result_digest(
&serde_json::from_str(&receipt).unwrap(),
job::ABSOLUTE_MAX_RESULT_BYTES,
)
.unwrap()
.0;
tx.execute("INSERT INTO job_intents (job_id,request_json,fingerprint,max_concurrent,state,current_attempt,acknowledged)
VALUES ('done',?1,'legacy-fingerprint',8,'completed',2,1)", [&original]).unwrap();
tx.execute(
"INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state,failure)
VALUES ('done',1,'done:attempt:1','old-token','failed','timeout')",
[],
)
.unwrap();
tx.execute("INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state,result_json,result_digest)
VALUES ('done',2,'done:attempt:2','old-token','completed',?1,?2)", params![receipt,digest]).unwrap();
let grant = "{\"subject_id\":\"local-subject\",\"candidate_ids\":[\"c1\"]}".to_string();
if version == 2 {
tx.execute("INSERT INTO job_domain_grants VALUES ('done',?1)", [&grant])
.unwrap();
}
for (id, state) in [("pending", "reserved"), ("running", "dispatched")] {
let mut value = serde_json::to_value(request(id)).unwrap();
value["input_revision"] = json!("old-token");
tx.execute("INSERT INTO job_intents (job_id,request_json,fingerprint,max_concurrent,state,current_attempt)
VALUES (?1,?2,'legacy-fingerprint',8,'pending',1)", params![id,serde_json::to_string(&value).unwrap()]).unwrap();
tx.execute(
"INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state)
VALUES (?1,1,?2,'old-token',?3)",
params![id, job::attempt_id(id, 1), state],
)
.unwrap();
}
tx.pragma_update(None, "user_version", version).unwrap();
tx.commit().unwrap();
(original, receipt, grant)
}
#[test]
fn legacy_versions_preserve_intent_receipts_attempts_grants_and_replay() {
for version in [1, 2] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("jobs.sqlite3");
let (original, receipt, grant) = seed(&path, version);
let mut store = JobStore::open(&path).unwrap();
let grant_value =
(version == 2).then(|| serde_json::from_str::<Value>(&grant).unwrap());
let snapshot = store
.reserve_granted(request("done"), grant_value.clone())
.unwrap();
assert!(snapshot.acknowledged);
assert_eq!(snapshot.state, JobState::Completed);
assert_eq!(snapshot.attempt.number, 2);
assert_eq!(
snapshot.result,
Some(serde_json::from_str(&receipt).unwrap())
);
let replay = JobResultSubmission {
job_id: "done".into(),
attempt_id: snapshot.attempt.attempt_id.clone(),
input_digest: request("done").input_digest().unwrap(),
result: snapshot.result.clone().unwrap(),
};
assert_eq!(store.accept(&replay).unwrap(), snapshot);
let mut stale = replay.clone();
stale.attempt_id = "done:attempt:1".into();
assert!(matches!(
store.accept(&stale),
Err(JobStoreError::AttemptMismatch)
));
let mut changed = request("done");
changed.input = json!({"different":true});
assert!(matches!(
store.reserve_granted(changed, grant_value.clone()),
Err(JobStoreError::IntentConflict(_))
));
let archived: (String, String, String) = store.conn.query_row(
"SELECT request_json,fingerprint,attempts_json FROM legacy_job_archive WHERE job_id='done'", [],
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap();
assert_eq!(archived.0, original);
assert_eq!(archived.1, "legacy-fingerprint");
let attempts: Value = serde_json::from_str(&archived.2).unwrap();
assert_eq!(attempts[0]["failure"], "timeout");
assert_eq!(attempts[1]["input_revision"], "old-token");
assert_eq!(attempts[1]["result_json"], receipt);
if version == 2 {
let persisted: String = store
.conn
.query_row("SELECT grant_json FROM job_domain_grants", [], |r| r.get(0))
.unwrap();
assert_eq!(persisted, grant);
}
let digests: Vec<String> = store
.conn
.prepare(
"SELECT input_digest FROM job_attempts WHERE job_id='done' ORDER BY number",
)
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(digests, vec![request("done").input_digest().unwrap(); 2]);
assert_eq!(store.get("running").unwrap().state, JobState::Unknown);
assert_eq!(
store.reserve(request("pending")).unwrap().attempt.state,
JobAttemptState::Reserved
);
assert!(
store
.conn
.execute_batch("DELETE FROM legacy_job_archive")
.is_err()
);
assert!(
store
.conn
.execute_batch("UPDATE legacy_job_archive SET fingerprint='changed'")
.is_err()
);
assert!(
store
.conn
.execute_batch("UPDATE job_intents SET fingerprint='changed'")
.is_err()
);
assert!(
store
.conn
.execute_batch("UPDATE job_attempts SET input_digest='changed'")
.is_err()
);
assert!(
!store
.conn
.prepare("PRAGMA foreign_key_check")
.unwrap()
.exists([])
.unwrap()
);
drop(store);
let mut store = JobStore::open(&path).unwrap();
assert_eq!(
store.reserve_granted(request("done"), grant_value).unwrap(),
snapshot
);
}
}
#[test]
fn failed_migration_rolls_back_schema_archive_and_immutable_triggers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("jobs.sqlite3");
seed(&path, 2);
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"DROP TRIGGER job_intent_immutable;
UPDATE job_intents SET request_json='{}' WHERE job_id='running';
CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF request_json ON job_intents
BEGIN SELECT RAISE(ABORT,'immutable'); END;",
)
.unwrap();
drop(conn);
assert!(JobStore::open(&path).is_err());
let conn = Connection::open(&path).unwrap();
assert_eq!(
conn.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0))
.unwrap(),
2
);
assert!(conn.prepare("SELECT * FROM legacy_job_archive").is_err());
let binding: String = conn
.query_row(
"SELECT input_revision FROM job_attempts WHERE job_id='done' AND number=2",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(binding, "old-token");
assert!(
conn.execute_batch("UPDATE job_intents SET request_json='{}' WHERE job_id='done'")
.is_err()
);
assert!(
conn.execute_batch("UPDATE job_attempts SET input_revision='changed'")
.is_err()
);
}
}
+12 -6
View File
@@ -219,7 +219,10 @@ impl StandaloneJobs {
store: self.store.clone(), store: self.store.clone(),
job_id: job_id.into(), job_id: job_id.into(),
attempt_id: snapshot.attempt.attempt_id.clone(), attempt_id: snapshot.attempt.attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(), input_digest: snapshot
.request
.input_digest()
.map_err(JobStoreError::from)?,
deadline, deadline,
}); });
let grant = match ( let grant = match (
@@ -295,7 +298,10 @@ impl StandaloneJobs {
store: store.clone(), store: store.clone(),
job_id: id.clone(), job_id: id.clone(),
attempt_id: attempt_id.clone(), attempt_id: attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(), input_digest: snapshot
.request
.input_digest()
.map_err(JobStoreError::from)?,
deadline, deadline,
}); });
let (cancel, mut cancellation) = watch::channel(false); let (cancel, mut cancellation) = watch::channel(false);
@@ -451,14 +457,14 @@ struct BoundResultSink {
store: Arc<Mutex<Option<JobStore>>>, store: Arc<Mutex<Option<JobStore>>>,
job_id: String, job_id: String,
attempt_id: String, attempt_id: String,
input_revision: String, input_digest: String,
deadline: tokio::time::Instant, deadline: tokio::time::Instant,
} }
impl worker::job::JobResultSink for BoundResultSink { impl worker::job::JobResultSink for BoundResultSink {
fn submit(&self, submission: JobResultSubmission) -> Result<(), String> { fn submit(&self, submission: JobResultSubmission) -> Result<(), String> {
if submission.job_id != self.job_id if submission.job_id != self.job_id
|| submission.attempt_id != self.attempt_id || submission.attempt_id != self.attempt_id
|| submission.input_revision != self.input_revision || submission.input_digest != self.input_digest
{ {
return Err("Job result capability binding mismatch".into()); return Err("Job result capability binding mismatch".into());
} }
@@ -482,7 +488,7 @@ struct BoundAttemptFence {
store: Arc<Mutex<Option<JobStore>>>, store: Arc<Mutex<Option<JobStore>>>,
job_id: String, job_id: String,
attempt_id: String, attempt_id: String,
input_revision: String, input_digest: String,
deadline: tokio::time::Instant, deadline: tokio::time::Instant,
} }
impl JobAttemptFence for BoundAttemptFence { impl JobAttemptFence for BoundAttemptFence {
@@ -499,7 +505,7 @@ impl JobAttemptFence for BoundAttemptFence {
if snapshot.state != JobState::Pending if snapshot.state != JobState::Pending
|| snapshot.attempt.state != JobAttemptState::Dispatched || snapshot.attempt.state != JobAttemptState::Dispatched
|| snapshot.attempt.attempt_id != self.attempt_id || snapshot.attempt.attempt_id != self.attempt_id
|| snapshot.request.input_revision != self.input_revision || snapshot.request.input_digest().map_err(|e| e.to_string())? != self.input_digest
{ {
return Err("Job domain grant is not bound to the current live attempt".into()); return Err("Job domain grant is not bound to the current live attempt".into());
} }
+9 -10
View File
@@ -11,7 +11,6 @@ fn request(id: &str) -> JobRequest {
JobRequest { JobRequest {
job_id: id.into(), job_id: id.into(),
purpose: "test".into(), purpose: "test".into(),
input_revision: "r1".into(),
input_ref: "fixture:input".into(), input_ref: "fixture:input".into(),
input: serde_json::json!({"data":42}), input: serde_json::json!({"data":42}),
instruction: "Return a structured result".into(), instruction: "Return a structured result".into(),
@@ -25,7 +24,7 @@ fn sink(jobs: &StandaloneJobs, id: &str, attempt: &str) -> BoundResultSink {
store: jobs.store.clone(), store: jobs.store.clone(),
job_id: id.into(), job_id: id.into(),
attempt_id: attempt.into(), attempt_id: attempt.into(),
input_revision: "r1".into(), input_digest: request(id).input_digest().unwrap(),
deadline: tokio::time::Instant::now() + Duration::from_secs(10), deadline: tokio::time::Instant::now() + Duration::from_secs(10),
} }
} }
@@ -33,7 +32,7 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission {
JobResultSubmission { JobResultSubmission {
job_id: id.into(), job_id: id.into(),
attempt_id: attempt.into(), attempt_id: attempt.into(),
input_revision: "r1".into(), input_digest: request(id).input_digest().unwrap(),
result: serde_json::json!({"valid":true}), result: serde_json::json!({"valid":true}),
} }
} }
@@ -99,7 +98,7 @@ type CapturedDomainAttempt = (JobSnapshot, Arc<dyn JobAttemptFence>);
#[derive(Default)] #[derive(Default)]
struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>); struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>);
fn domain_grant(id: &str) -> Value { fn domain_grant(id: &str) -> Value {
json!({"consumer":"test-domain", "job_id":id, "input_revision":"r1"}) json!({"consumer":"test-domain", "job_id":id, "input_digest":request(id).input_digest().unwrap()})
} }
fn domain_request(id: &str) -> JobRequest { fn domain_request(id: &str) -> JobRequest {
let mut intent = request(id); let mut intent = request(id);
@@ -244,7 +243,7 @@ async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_f
} }
#[tokio::test] #[tokio::test]
async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispatch() { async fn foreign_job_digest_and_consumer_grants_are_refused_before_model_dispatch() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let jobs = let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
@@ -253,12 +252,12 @@ async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispa
for (id, grant) in [ for (id, grant) in [
("foreign-job", domain_grant("another-job")), ("foreign-job", domain_grant("another-job")),
( (
"foreign-revision", "foreign-digest",
json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}), json!({"consumer":"test-domain","job_id":"foreign-digest","input_digest":"r0"}),
), ),
( (
"foreign-consumer", "foreign-consumer",
json!({"consumer":"another-domain","job_id":"foreign-consumer","input_revision":"r1"}), json!({"consumer":"another-domain","job_id":"foreign-consumer","input_digest":request("foreign-consumer").input_digest().unwrap()}),
), ),
] { ] {
let reserved = jobs.request_granted(domain_request(id), grant).unwrap(); let reserved = jobs.request_granted(domain_request(id), grant).unwrap();
@@ -378,7 +377,7 @@ async fn bound_domain_does_not_change_root_generic_result_only_execution() {
} }
#[tokio::test] #[tokio::test]
async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline() { async fn host_result_capability_cannot_cross_job_attempt_digest_or_deadline() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let jobs = let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
@@ -398,7 +397,7 @@ async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline()
); );
assert!(bound.submit(result("first", "first:attempt:2")).is_err()); assert!(bound.submit(result("first", "first:attempt:2")).is_err());
let mut stale = result("first", &first.attempt.attempt_id); let mut stale = result("first", &first.attempt.attempt_id);
stale.input_revision = "r0".into(); stale.input_digest = "r0".into();
assert!(bound.submit(stale).is_err()); assert!(bound.submit(stale).is_err());
let mut expired = sink(&jobs, "first", &first.attempt.attempt_id); let mut expired = sink(&jobs, "first", &first.attempt.attempt_id);
expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1); expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1);
+146 -26
View File
@@ -17,7 +17,7 @@ const LEASE_FILE: &str = "lease.json";
const LEASE_LOCK_FILE: &str = "lease.lock"; const LEASE_LOCK_FILE: &str = "lease.lock";
const SESSIONS_DIR: &str = "sessions"; const SESSIONS_DIR: &str = "sessions";
const WORKER_DIR: &str = "worker"; const WORKER_DIR: &str = "worker";
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StandaloneCwdIdentity { pub struct StandaloneCwdIdentity {
@@ -79,7 +79,6 @@ pub enum StandaloneShutdownReason {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneWorkerRecord { pub struct StandaloneWorkerRecord {
pub schema_version: u32, pub schema_version: u32,
pub revision: u64,
pub worker_id: WorkerId, pub worker_id: WorkerId,
/// User-facing Worker name resolved from the profile. /// User-facing Worker name resolved from the profile.
pub worker_name: String, pub worker_name: String,
@@ -194,7 +193,6 @@ impl StandaloneWorkerStore {
let now = now_unix_ms()?; let now = now_unix_ms()?;
let record = StandaloneWorkerRecord { let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
revision: 1,
worker_id: allocation.worker_id, worker_id: allocation.worker_id,
worker_name: manifest.worker.name.clone(), worker_name: manifest.worker.name.clone(),
storage_key, storage_key,
@@ -326,13 +324,12 @@ impl StandaloneWorkerStore {
active_segment_id: Option<SegmentId>, active_segment_id: Option<SegmentId>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone(); let mut next = record.clone();
next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?; next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id; next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id; next.active_segment_id = active_segment_id;
next.status = StandaloneWorkerStatus::Active; next.status = StandaloneWorkerStatus::Active;
next.shutdown_reason = None; next.shutdown_reason = None;
self.commit_record(Some(record.revision), &next)?; self.commit_record(Some(record), &next)?;
Ok(next) Ok(next)
} }
@@ -344,13 +341,12 @@ impl StandaloneWorkerStore {
reason: StandaloneShutdownReason, reason: StandaloneShutdownReason,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone(); let mut next = record.clone();
next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?; next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id; next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id; next.active_segment_id = active_segment_id;
next.status = StandaloneWorkerStatus::Stopped; next.status = StandaloneWorkerStatus::Stopped;
next.shutdown_reason = Some(reason); next.shutdown_reason = Some(reason);
self.commit_record(Some(record.revision), &next)?; self.commit_record(Some(record), &next)?;
Ok(next) Ok(next)
} }
@@ -405,7 +401,7 @@ impl StandaloneWorkerStore {
fn commit_record( fn commit_record(
&self, &self,
expected_revision: Option<u64>, expected_record: Option<&StandaloneWorkerRecord>,
next: &StandaloneWorkerRecord, next: &StandaloneWorkerRecord,
) -> Result<(), StandaloneStoreError> { ) -> Result<(), StandaloneStoreError> {
let dir = self.worker_dir(next.worker_id); let dir = self.worker_dir(next.worker_id);
@@ -421,19 +417,21 @@ impl StandaloneWorkerStore {
StandaloneStoreError::Io(error) StandaloneStoreError::Io(error)
} }
})?; })?;
writeln!(marker_file, "{}", next.revision).map_err(StandaloneStoreError::Io)?; writeln!(marker_file, "{}", next.worker_id).map_err(StandaloneStoreError::Io)?;
marker_file.sync_all().map_err(StandaloneStoreError::Io)?; marker_file.sync_all().map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?; sync_directory(&dir)?;
if let Some(expected) = expected_revision { if let Some(expected) = expected_record {
let current = self.load_record_while_committing(next.worker_id)?; let current = self.load_record_while_committing(next.worker_id)?;
if current.revision != expected { // The create-new commit marker excludes other writers across this
let _ = fs::remove_file(&marker); // comparison and replacement. Compare the observed metadata itself,
return Err(StandaloneStoreError::RevisionConflict { // including its manifest, pointer and lifecycle state, not a counter.
id: next.worker_id, if serde_json::to_value(&current).map_err(StandaloneStoreError::Json)?
expected, != serde_json::to_value(expected).map_err(StandaloneStoreError::Json)?
found: current.revision, {
}); fs::remove_file(&marker).map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?;
return Err(StandaloneStoreError::MetadataChanged(next.worker_id));
} }
} }
@@ -681,6 +679,19 @@ fn decode_worker_record(
"standalone Worker record must be an object", "standalone Worker record must be an object",
)) ))
})?; })?;
// Frozen schema-1 adapter: the counter was not domain data. Preserve
// every metadata field and use content comparison for future writes.
if object
.get("schema_version")
.and_then(serde_json::Value::as_u64)
== Some(1)
{
object.remove("revision");
object.insert(
"schema_version".to_string(),
serde_json::json!(SCHEMA_VERSION),
);
}
let persisted_manifest = object.remove("manifest").ok_or_else(|| { let persisted_manifest = object.remove("manifest").ok_or_else(|| {
serde_json::Error::io(io::Error::new( serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData, io::ErrorKind::InvalidData,
@@ -770,14 +781,8 @@ pub enum StandaloneStoreError {
LeaseOwnershipLost, LeaseOwnershipLost,
#[error("standalone Worker {0} must be stopped before deletion")] #[error("standalone Worker {0} must be stopped before deletion")]
DeleteActive(WorkerId), DeleteActive(WorkerId),
#[error( #[error("standalone Worker {0} metadata changed since it was read")]
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})" MetadataChanged(WorkerId),
)]
RevisionConflict {
id: WorkerId,
expected: u64,
found: u64,
},
#[error("system clock is before the Unix epoch or out of range")] #[error("system clock is before the Unix epoch or out of range")]
Clock, Clock,
#[error("standalone metadata serialization failed")] #[error("standalone metadata serialization failed")]
@@ -816,7 +821,6 @@ permission = "write"
let manifest = test_manifest(); let manifest = test_manifest();
let record = StandaloneWorkerRecord { let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
revision: 6,
worker_id, worker_id,
worker_name: manifest.worker.name.clone(), worker_name: manifest.worker.name.clone(),
storage_key: "standalone-test".to_string(), storage_key: "standalone-test".to_string(),
@@ -859,6 +863,122 @@ permission = "write"
); );
} }
#[test]
fn metadata_write_compares_observed_content_even_with_equal_timestamps() {
let root = tempfile::tempdir().unwrap();
let store = StandaloneWorkerStore::open(root.path().join("workers")).unwrap();
let allocation = store
.allocate(root.path(), StaleLeasePolicy::Reject)
.unwrap();
let record = store
.commit_created(
&allocation,
test_manifest(),
"metadata-test".into(),
session_store::new_session_id(),
None,
)
.unwrap();
// Simulate another committed pointer change in the same clock tick.
let mut changed = record.clone();
changed.active_session_id = session_store::new_session_id();
store.commit_record(Some(&record), &changed).unwrap();
assert!(matches!(
store.mark_stopped(
&record,
record.active_session_id,
None,
StandaloneShutdownReason::UserExit
),
Err(StandaloneStoreError::MetadataChanged(_))
));
assert_eq!(
store.load(record.worker_id).unwrap().active_session_id,
changed.active_session_id
);
assert!(
!store
.worker_dir(record.worker_id)
.join(COMMIT_MARKER)
.exists()
);
let stopped = store
.mark_stopped(
&changed,
changed.active_session_id,
None,
StandaloneShutdownReason::UserExit,
)
.unwrap();
assert_eq!(stopped.status, StandaloneWorkerStatus::Stopped);
}
#[test]
fn schema_one_metadata_preserves_content_without_an_update_counter() {
let root = tempfile::tempdir().unwrap();
let store = StandaloneWorkerStore::open(root.path().join("workers")).unwrap();
let allocation = store
.allocate(root.path(), StaleLeasePolicy::Reject)
.unwrap();
let record = store
.commit_created_connected(
&allocation,
test_manifest(),
"migration-test".into(),
session_store::new_session_id(),
None,
Some(crate::subjektiv::StandaloneSubjectBinding {
scope_id: "local-scope".into(),
subject_id: "local-subject".into(),
}),
)
.unwrap();
let record = store
.mark_stopped(
&record,
record.active_session_id,
Some(session_store::new_segment_id()),
StandaloneShutdownReason::UserExit,
)
.unwrap();
let mut persisted = Vec::new();
write_worker_record(&mut persisted, &record).unwrap();
let mut legacy: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
legacy["schema_version"] = serde_json::json!(1);
legacy["revision"] = serde_json::json!(42);
let legacy_bytes = serde_json::to_vec(&legacy).unwrap();
fs::write(
store.worker_dir(record.worker_id).join(RECORD_FILE),
&legacy_bytes,
)
.unwrap();
let migrated = store.load(record.worker_id).unwrap();
assert_eq!(
serde_json::to_value(&migrated).unwrap(),
serde_json::to_value(&record).unwrap()
);
let mut encoded = Vec::new();
write_worker_record(&mut encoded, &migrated).unwrap();
let saved: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
assert!(saved.get("revision").is_none());
assert_eq!(saved["schema_version"], 2);
let resumed = store
.update_active_pointer(
&migrated,
migrated.active_session_id,
migrated.active_segment_id,
)
.unwrap();
let disk: serde_json::Value = serde_json::from_slice(
&fs::read(store.worker_dir(record.worker_id).join(RECORD_FILE)).unwrap(),
)
.unwrap();
assert!(disk.get("revision").is_none());
assert_eq!(disk["schema_version"], 2);
assert_eq!(resumed.subject, record.subject);
assert_eq!(resumed.active_segment_id, record.active_segment_id);
}
#[test] #[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() { fn lease_liveness_requires_positive_live_or_stale_evidence() {
assert_eq!( assert_eq!(
+4 -5
View File
@@ -352,7 +352,7 @@ fn operation_error(error: subjektiv::api::OperationError) -> SubjektivHostError
} else if message.contains("candidate_decision_conflict") { } else if message.contains("candidate_decision_conflict") {
"candidate_decision_conflict" "candidate_decision_conflict"
} else { } else {
"revision_conflict" "change_conflict"
} }
.into(), .into(),
message, message,
@@ -655,7 +655,7 @@ impl LocalSubjectHost {
worker_id: Some(self.worker_id.to_string()), worker_id: Some(self.worker_id.to_string()),
flow_selector: None, flow_selector: None,
flow_definition_id: None, flow_definition_id: None,
flow_definition_revision: None, flow_definition_fingerprint: None,
}; };
anchor.origin = Some(origin.clone()); anchor.origin = Some(origin.clone());
source.origin = Some(origin); source.origin = Some(origin);
@@ -835,8 +835,7 @@ impl SubjektivHost for LocalSubjectHost {
job::DEFAULT_MAX_RESULT_BYTES, job::DEFAULT_MAX_RESULT_BYTES,
) )
.map_err(operation_error)?; .map_err(operation_error)?;
let subject = self self.catalog
.catalog
.store .store
.subject(&self.binding.subject_id) .subject(&self.binding.subject_id)
.map_err(domain_error)? .map_err(domain_error)?
@@ -847,7 +846,7 @@ impl SubjektivHost for LocalSubjectHost {
}; };
let request=job::JobRequest { let request=job::JobRequest {
job_id:format!("subjektiv-consolidation:{}",uuid::Uuid::now_v7()),purpose:"subjektiv_consolidation".into(), job_id:format!("subjektiv-consolidation:{}",uuid::Uuid::now_v7()),purpose:"subjektiv_consolidation".into(),
input_revision:subject.store_revision.to_string(),input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id), input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id),
input:serde_json::json!({"subject_id":self.binding.subject_id,"candidate_ids":grant.candidate_ids}), input:serde_json::json!({"subject_id":self.binding.subject_id,"candidate_ids":grant.candidate_ids}),
instruction:"Resolve ONLY candidate_ids with MemoryStagingList/Read, recall and MemoryApplyCandidate. Already resolved IDs are durable receipts; do not repeat them. Leave new candidates for a later Job. When the entire immutable batch has a disposition, call SubmitJobResult with result: {subject_id, candidate_ids}. The Host awaits clean-context surface generation and verifies actual receipts/outcome before accepting success; never supply surface claims.".into(), instruction:"Resolve ONLY candidate_ids with MemoryStagingList/Read, recall and MemoryApplyCandidate. Already resolved IDs are durable receipts; do not repeat them. Leave new candidates for a later Job. When the entire immutable batch has a disposition, call SubmitJobResult with result: {subject_id, candidate_ids}. The Host awaits clean-context surface generation and verifies actual receipts/outcome before accepting success; never supply surface claims.".into(),
profile:"builtin:standalone-subjektiv-consolidation".into(),serialization_key:Some(format!("subjektiv:{}",self.binding.subject_id)), profile:"builtin:standalone-subjektiv-consolidation".into(),serialization_key:Some(format!("subjektiv:{}",self.binding.subject_id)),
+1 -1
View File
@@ -487,7 +487,7 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
source: manifest::ProfileRegistrySource::User, source: manifest::ProfileRegistrySource::User,
name: "user-standalone".to_string(), name: "user-standalone".to_string(),
path: None, path: None,
provenance: Some("user-config-revision-7".to_string()), provenance: Some("user-config-digest".to_string()),
}, },
profile: Some(manifest::ProfileMetadata { profile: Some(manifest::ProfileMetadata {
name: Some("User standalone".to_string()), name: Some("User standalone".to_string()),
-1
View File
@@ -91,7 +91,6 @@ fn request(id: &str) -> JobRequest {
JobRequest { JobRequest {
job_id: id.into(), job_id: id.into(),
purpose: "generic_test".into(), purpose: "generic_test".into(),
input_revision: "r1".into(),
input_ref: "test:immutable".into(), input_ref: "test:immutable".into(),
input: serde_json::json!({"test_input":42}), input: serde_json::json!({"test_input":42}),
instruction: "Return a structured validity result".into(), instruction: "Return a structured validity result".into(),
+7 -7
View File
@@ -273,7 +273,7 @@ impl ScriptedClient {
"SubmitMemorySurface", "SubmitMemorySurface",
json!({"points":[{ json!({"points":[{
"body_md":format!("- {}", material["body_md"].as_str().unwrap()), "body_md":format!("- {}", material["body_md"].as_str().unwrap()),
"memory_refs":[{"memory_id":material["memory_id"], "revision":material["revision"]}] "memory_refs":[{"memory_id":material["memory_id"], "change_id":material["change_id"]}]
}]}), }]}),
) )
} }
@@ -1155,7 +1155,7 @@ async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and
); );
let memory = store.list_memories(&subject.id).unwrap().pop().unwrap(); let memory = store.list_memories(&subject.id).unwrap().pop().unwrap();
assert_eq!(memory.claim, claim); assert_eq!(memory.claim, claim);
assert_eq!(memory.revision, 1); assert!(!memory.change_id.is_empty());
assert_eq!(memory.source_candidate_ids, vec![ids[0].as_str().unwrap()]); assert_eq!(memory.source_candidate_ids, vec![ids[0].as_str().unwrap()]);
let surface = store.resident_surface(&subject.id).unwrap(); let surface = store.resident_surface(&subject.id).unwrap();
assert_eq!(surface.availability, subjektiv::SurfaceAvailability::Ready); assert_eq!(surface.availability, subjektiv::SurfaceAvailability::Ready);
@@ -1167,14 +1167,14 @@ async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and
assert!(snapshot.body_md.contains(claim)); assert!(snapshot.body_md.contains(claim));
assert_eq!( assert_eq!(
snapshot.memory_refs, snapshot.memory_refs,
vec![subjektiv::MemoryRevisionRef { vec![subjektiv::MemoryChangeRef {
memory_id: memory.id.clone(), memory_id: memory.id.clone(),
revision: 1 change_id: memory.change_id.clone()
}] }]
); );
assert_eq!( assert_eq!(
snapshot.built_from_store_revision, snapshot.built_from_memory_fingerprint,
accepted["surface"]["store_revision"].as_u64().unwrap() accepted["surface"]["memory_fingerprint"].as_str().unwrap()
); );
memory memory
}); });
@@ -1260,7 +1260,7 @@ async fn transport_loss_after_candidate_apply_preserves_memory_and_later_job_onl
let memory = store.list_memories(&subject.id).unwrap(); let memory = store.list_memories(&subject.id).unwrap();
assert_eq!(memory.len(), 1); assert_eq!(memory.len(), 1);
assert_eq!(memory[0].claim, claim); assert_eq!(memory[0].claim, claim);
assert_eq!(memory[0].revision, 1); assert!(!memory[0].change_id.is_empty());
assert_ne!( assert_ne!(
store.resident_surface(&subject.id).unwrap().availability, store.resident_surface(&subject.id).unwrap().availability,
subjektiv::SurfaceAvailability::Ready subjektiv::SurfaceAvailability::Ready
+1
View File
@@ -13,6 +13,7 @@ manifest.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
sha2.workspace = true
server-api.workspace = true server-api.workspace = true
thiserror.workspace = true thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
+24 -3
View File
@@ -17,7 +17,9 @@ identity under its operator-managed state directory, **not a fake Workspace**.
Subject IDs are independently issued random domain identities. An ID is a lookup Subject IDs are independently issued random domain identities. An ID is a lookup
key, never an authorization credential. key, never an authorization credential.
Existing SQL schema and migrations are retained. In particular, Historical migration plans remain frozen. Migration 7 replaces state counters
with opaque Memory change identities and explicit predecessor history; the
original serialized records are retained in an immutable audit archive.
`store_scope.workspace_id` still stores the physical storage scope, under its `store_scope.workspace_id` still stores the physical storage scope, under its
historical column name. Existing Backend placement and snapshot manifest keys historical column name. Existing Backend placement and snapshot manifest keys
are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue
@@ -29,7 +31,7 @@ Workspace value. `workspace_id()` is a Backend compatibility alias for
`api::execute(store, &HostOperationContext, operation)` is the common dispatcher `api::execute(store, &HostOperationContext, operation)` is the common dispatcher
used by Backend and local Hosts. It owns query/cursor rules, bounded body and used by Backend and local Hosts. It owns query/cursor rules, bounded body and
nested provenance projections, immutable revision reads, proposal validation, nested provenance projections, immutable change reads, proposal validation,
explicit staging/receipt rules, resident context, candidate decisions and explicit staging/receipt rules, resident context, candidate decisions and
surface generation/publication/failure projections. surface generation/publication/failure projections.
@@ -72,5 +74,24 @@ helpers never run or replay a Job. The store's `prepare_job_surface_generation`,
`require_job_surface_generation`, and `validate_job_surface_outcome` are public `require_job_surface_generation`, and `validate_job_surface_outcome` are public
trusted-domain methods for both Backend and local Hosts. trusted-domain methods for both Backend and local Hosts.
## Content conditions and history
Memory updates require the exact `expected_change_id` read by the caller.
Each committed change receives a new opaque ID and retains `previous_change_id`;
IDs are never sorted or incremented to infer history. Body/evidence continuations
pin that immutable ID, and history cursors pin a predecessor-chain head.
Subject behavior updates compare `expected_behavior_md` byte-for-byte and do not
participate in Memory freshness. Surface runs, publications and query cursors use
`memory_fingerprint`, a SHA-256 digest of the length-framed, ordered current
`(memory_id, change_id)` identities. The Subject stores no redundant freshness
counter. Publication remains first-wins for the captured Memory input set.
Old Surface input sets cannot be proven from historical counters. Migration 7
retains those snapshots and runs as archival history with deliberately detached
input identities, marks existing surfaces stale, and requires regeneration.
Old Flow provenance keeps its exact original bytes in the archive; a definition
fingerprint is not fabricated from an old numeric label.
Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`, Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`,
`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`. `serde`, `serde_json`, `server-api`, `sha2`, `thiserror`, `uuid`; tests use `tempfile`.
+113 -114
View File
@@ -24,7 +24,7 @@ pub const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024;
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct SubjektivQueryCursor { struct SubjektivQueryCursor {
subject_id: String, subject_id: String,
store_revision: u64, memory_fingerprint: String,
query: Option<String>, query: Option<String>,
kinds: Vec<String>, kinds: Vec<String>,
states: Vec<String>, states: Vec<String>,
@@ -33,10 +33,10 @@ struct SubjektivQueryCursor {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct SubjektivRevisionCursor { struct SubjektivChangeCursor {
subject_id: String, subject_id: String,
memory_id: String, memory_id: String,
max_revision: u64, head_change_id: String,
offset: usize, offset: usize,
} }
@@ -45,8 +45,8 @@ struct SubjektivRevisionCursor {
struct SubjektivEvidenceCursor { struct SubjektivEvidenceCursor {
subject_id: String, subject_id: String,
memory_id: String, memory_id: String,
revision: u64, change_id: String,
/// Candidate/derivation reference offset within the immutable revision. /// Candidate/derivation reference offset within the immutable change_id.
offset: usize, offset: usize,
/// Nested offsets keep compatible historical candidates with more anchors /// Nested offsets keep compatible historical candidates with more anchors
/// resumable without admitting new over-limit records. /// resumable without admitting new over-limit records.
@@ -92,14 +92,14 @@ pub fn subjektiv_surface_prepare_response(
server_api::SubjektivSurfacePrepareResponse { server_api::SubjektivSurfacePrepareResponse {
generation_id: generation.id, generation_id: generation.id,
current_snapshot_id: None, current_snapshot_id: None,
store_revision: generation.store_revision, memory_fingerprint: generation.memory_fingerprint,
active_memory_count: generation.active_memory_count, active_memory_count: generation.active_memory_count,
materials: generation materials: generation
.materials .materials
.into_iter() .into_iter()
.map(|material| server_api::SubjektivSurfaceMaterial { .map(|material| server_api::SubjektivSurfaceMaterial {
memory_id: material.memory_id, memory_id: material.memory_id,
revision: material.revision, change_id: material.change_id.clone(),
kind: material.kind, kind: material.kind,
body_md: material.body_md, body_md: material.body_md,
why_useful: material.why_useful, why_useful: material.why_useful,
@@ -120,7 +120,7 @@ pub fn subjektiv_candidate_summary(
candidate_id: candidate.id, candidate_id: candidate.id,
kind: candidate.kind, kind: candidate.kind,
claim: candidate.claim, claim: candidate.claim,
revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), change_proposal: candidate.change_proposal.map(subjektiv_proposal_metadata),
created_at: candidate.created_at, created_at: candidate.created_at,
} }
} }
@@ -137,30 +137,26 @@ pub fn subjektiv_candidate(
source: candidate.source, source: candidate.source,
evidence: candidate.evidence, evidence: candidate.evidence,
source_refs: candidate.source_refs, source_refs: candidate.source_refs,
revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), change_proposal: candidate.change_proposal.map(subjektiv_proposal_metadata),
created_at: candidate.created_at, created_at: candidate.created_at,
} }
} }
pub fn subjektiv_proposal_metadata( pub fn subjektiv_proposal_metadata(
proposal: crate::RevisionProposal, proposal: crate::ChangeProposal,
) -> server_api::SubjektivMemoryRevisionProposalMetadata { ) -> server_api::SubjektivMemoryChangeProposalMetadata {
server_api::SubjektivMemoryRevisionProposalMetadata { server_api::SubjektivMemoryChangeProposalMetadata {
memory_id: proposal.memory_id, memory_id: proposal.memory_id,
expected_revision: proposal.expected_revision, expected_change_id: proposal.expected_change_id.clone(),
intent: match proposal.intent { intent: match proposal.intent {
crate::RevisionProposalIntent::Revise => { crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
server_api::SubjektivMemoryRevisionIntent::Revise crate::ChangeProposalIntent::Resolve => {
server_api::SubjektivMemoryChangeIntent::Resolve
} }
crate::RevisionProposalIntent::Resolve => { crate::ChangeProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Resolve server_api::SubjektivMemoryChangeIntent::Retract
}
crate::RevisionProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Retract
}
crate::RevisionProposalIntent::Reopen => {
server_api::SubjektivMemoryRevisionIntent::Reopen
} }
crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
}, },
change_reason: proposal.change_reason, change_reason: proposal.change_reason,
} }
@@ -172,15 +168,13 @@ pub fn subjektiv_candidate_decision(
let decision = match input.decision { let decision = match input.decision {
server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => { server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => {
let target = match target { let target = match target {
server_api::SubjektivMemoryApplyTarget::Create => { server_api::SubjektivMemoryApplyTarget::Create => crate::MemoryChangeTarget::Create,
crate::MemoryRevisionTarget::Create
}
server_api::SubjektivMemoryApplyTarget::Revise { server_api::SubjektivMemoryApplyTarget::Revise {
memory_id, memory_id,
expected_revision, expected_change_id,
} => crate::MemoryRevisionTarget::Revise { } => crate::MemoryChangeTarget::Revise {
memory_id, memory_id,
expected_revision, expected_change_id,
}, },
}; };
crate::CandidateDecision::Apply { crate::CandidateDecision::Apply {
@@ -196,9 +190,9 @@ pub fn subjektiv_candidate_decision(
derived_from: memory derived_from: memory
.derived_from .derived_from
.into_iter() .into_iter()
.map(|reference| crate::MemoryRevisionRef { .map(|reference| crate::MemoryChangeRef {
memory_id: reference.memory_id, memory_id: reference.memory_id,
revision: reference.revision, change_id: reference.change_id.clone(),
}) })
.collect(), .collect(),
change_reason: memory.change_reason, change_reason: memory.change_reason,
@@ -225,9 +219,9 @@ pub fn subjektiv_candidate_decision(
}, },
affected_memory: affected_memory affected_memory: affected_memory
.into_iter() .into_iter()
.map(|reference| crate::MemoryRevisionRef { .map(|reference| crate::MemoryChangeRef {
memory_id: reference.memory_id, memory_id: reference.memory_id,
revision: reference.revision, change_id: reference.change_id.clone(),
}) })
.collect(), .collect(),
}, },
@@ -281,17 +275,17 @@ pub fn subjektiv_candidate_decision_response(
.into_iter() .into_iter()
.map(|reference| server_api::SubjektivMemoryAffectedRef { .map(|reference| server_api::SubjektivMemoryAffectedRef {
memory_id: reference.memory_id, memory_id: reference.memory_id,
revision: reference.revision, change_id: reference.change_id.clone(),
operation, operation,
}) })
.collect(), .collect(),
memory: receipt memory: receipt
.memory .memory
.map(|memory| server_api::SubjektivMemoryRevisionRef { .map(|memory| server_api::SubjektivMemoryChangeRef {
memory_id: memory.id, memory_id: memory.id,
revision: memory.revision, change_id: memory.change_id.clone(),
}), }),
store_revision: receipt.store_revision, memory_fingerprint: receipt.memory_fingerprint,
surface_dirty: receipt.surface_dirty, surface_dirty: receipt.surface_dirty,
} }
} }
@@ -316,10 +310,19 @@ pub fn subjektiv_memory_query(
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
let kinds = canonical_subjektiv_kinds(input.kinds)?; let kinds = canonical_subjektiv_kinds(input.kinds)?;
let states = canonical_subjektiv_states(input.states)?; let states = canonical_subjektiv_states(input.states)?;
let subject = store let _subject = store
.subject(subject_id) .subject(subject_id)
.map_err(subjektiv_store_error)? .map_err(subjektiv_store_error)?
.ok_or_else(|| OperationError::SubjectNotFound(subject_id.to_string()))?; .ok_or_else(|| OperationError::SubjectNotFound(subject_id.to_string()))?;
let all_records = store
.list_memories(subject_id)
.map_err(subjektiv_store_error)?;
let memory_fingerprint = crate::memory_identity_fingerprint(
&all_records
.iter()
.map(|r| (r.id.clone(), r.change_id.clone()))
.collect::<Vec<_>>(),
);
let mut offset = 0; let mut offset = 0;
if let Some(cursor) = input.cursor { if let Some(cursor) = input.cursor {
let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?; let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?;
@@ -333,7 +336,7 @@ pub fn subjektiv_memory_query(
) )
.into()); .into());
} }
if cursor.store_revision != subject.store_revision { if cursor.memory_fingerprint != memory_fingerprint {
return Err(OperationError::Conflict( return Err(OperationError::Conflict(
"stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(), "stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(),
) )
@@ -344,9 +347,7 @@ pub fn subjektiv_memory_query(
let kind_filter = kinds.iter().cloned().collect::<HashSet<_>>(); let kind_filter = kinds.iter().cloned().collect::<HashSet<_>>();
let state_filter = states.iter().cloned().collect::<HashSet<_>>(); let state_filter = states.iter().cloned().collect::<HashSet<_>>();
let records = store let records = all_records
.list_memories(subject_id)
.map_err(subjektiv_store_error)?
.into_iter() .into_iter()
.filter(|record| { .filter(|record| {
kind_filter.contains(record.kind.as_str()) kind_filter.contains(record.kind.as_str())
@@ -369,7 +370,7 @@ pub fn subjektiv_memory_query(
.iter() .iter()
.map(|record| server_api::SubjektivMemoryQueryItem { .map(|record| server_api::SubjektivMemoryQueryItem {
id: record.id.clone(), id: record.id.clone(),
revision: record.revision, change_id: record.change_id.clone(),
kind: record.kind.clone(), kind: record.kind.clone(),
state: api_memory_state(record.state), state: api_memory_state(record.state),
claim: record.claim.clone(), claim: record.claim.clone(),
@@ -383,7 +384,7 @@ pub fn subjektiv_memory_query(
"query", "query",
&SubjektivQueryCursor { &SubjektivQueryCursor {
subject_id: subject_id.to_string(), subject_id: subject_id.to_string(),
store_revision: subject.store_revision, memory_fingerprint: memory_fingerprint,
query, query,
kinds, kinds,
states, states,
@@ -407,18 +408,18 @@ pub fn subjektiv_memory_read(
let current = store let current = store
.scoped_memory(subject_id, &input.memory_id) .scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
let record = match input.revision { let record = match input.change_id.clone() {
Some(0) => { Some(id) if id.trim().is_empty() => {
return Err( return Err(OperationError::InvalidInput(
OperationError::InvalidInput("revision must be a positive integer".into()).into(), "change_id must be nonempty".into(),
); ));
} }
Some(revision) => store Some(change_id) => store
.scoped_memory_revision(subject_id, &input.memory_id, revision) .scoped_memory_change(subject_id, &input.memory_id, change_id.clone())
.map_err(subjektiv_store_error)? .map_err(subjektiv_store_error)?
.ok_or_else(|| { .ok_or_else(|| {
OperationError::InvalidInput(format!( OperationError::InvalidInput(format!(
"memory_revision_not_found: Memory `{}` has no revision {revision}", "memory_change_not_found: Memory `{}` has no change_id {change_id}",
input.memory_id input.memory_id
)) ))
})?, })?,
@@ -433,9 +434,9 @@ pub fn subjektiv_memory_read(
} }
let body_offset = input.offset.unwrap_or(0); let body_offset = input.offset.unwrap_or(0);
let body_byte_offset = input.byte_offset.unwrap_or(0); let body_byte_offset = input.byte_offset.unwrap_or(0);
if (body_offset > 0 || body_byte_offset > 0) && input.revision.is_none() { if (body_offset > 0 || body_byte_offset > 0) && input.change_id.is_none() {
return Err(OperationError::InvalidInput( return Err(OperationError::InvalidInput(
"body continuation requires the exact revision returned by the first read".into(), "body continuation requires the exact change_id returned by the first read".into(),
) )
.into()); .into());
} }
@@ -477,10 +478,10 @@ pub fn subjektiv_memory_read(
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
if cursor.subject_id != subject_id if cursor.subject_id != subject_id
|| cursor.memory_id != record.id || cursor.memory_id != record.id
|| cursor.revision != record.revision || cursor.change_id != record.change_id
{ {
return Err(OperationError::InvalidInput( return Err(OperationError::InvalidInput(
"subjektiv evidence cursor does not match subject, Memory, or revision".into(), "subjektiv evidence cursor does not match subject, Memory, or change_id".into(),
) )
.into()); .into());
} }
@@ -570,9 +571,9 @@ pub fn subjektiv_memory_read(
} }
let derived_index = evidence_offset - record.source_candidate_ids.len(); let derived_index = evidence_offset - record.source_candidate_ids.len();
let reference = &record.derived_from[derived_index]; let reference = &record.derived_from[derived_index];
derived_from.push(server_api::SubjektivMemoryRevisionRef { derived_from.push(server_api::SubjektivMemoryChangeRef {
memory_id: reference.memory_id.clone(), memory_id: reference.memory_id.clone(),
revision: reference.revision, change_id: reference.change_id.clone(),
}); });
next_evidence_offset += 1; next_evidence_offset += 1;
} else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { } else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 {
@@ -592,7 +593,7 @@ pub fn subjektiv_memory_read(
&SubjektivEvidenceCursor { &SubjektivEvidenceCursor {
subject_id: subject_id.to_string(), subject_id: subject_id.to_string(),
memory_id: record.id.clone(), memory_id: record.id.clone(),
revision: record.revision, change_id: record.change_id.clone(),
offset: next_evidence_offset, offset: next_evidence_offset,
evidence_offset: next_nested_evidence_offset, evidence_offset: next_nested_evidence_offset,
source_ref_offset: next_nested_source_ref_offset, source_ref_offset: next_nested_source_ref_offset,
@@ -603,8 +604,8 @@ pub fn subjektiv_memory_read(
let mut response = server_api::SubjektivMemoryReadResponse { let mut response = server_api::SubjektivMemoryReadResponse {
memory_id: record.id, memory_id: record.id,
revision: record.revision, change_id: record.change_id.clone(),
current_revision: current.revision, current_change_id: current.change_id.clone(),
kind: record.kind, kind: record.kind,
state: api_memory_state(record.state), state: api_memory_state(record.state),
claim: record.claim, claim: record.claim,
@@ -635,36 +636,41 @@ pub fn subjektiv_memory_read(
Ok(response) Ok(response)
} }
pub fn subjektiv_memory_list_revisions( pub fn subjektiv_memory_list_changes(
store: &crate::SubjektivStore, store: &crate::SubjektivStore,
subject_id: &str, subject_id: &str,
input: server_api::SubjektivMemoryListRevisionsRequest, input: server_api::SubjektivMemoryListChangesRequest,
) -> OperationResult<server_api::SubjektivMemoryListRevisionsResponse> { ) -> OperationResult<server_api::SubjektivMemoryListChangesResponse> {
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
let current = store let current = store
.scoped_memory(subject_id, &input.memory_id) .scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
let (max_revision, offset) = if let Some(cursor) = input.cursor { let (head_change_id, offset) = if let Some(cursor) = input.cursor {
let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?; let cursor: SubjektivChangeCursor = decode_subjektiv_cursor("changes", &cursor)?;
if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id { if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id {
return Err(OperationError::InvalidInput( return Err(OperationError::InvalidInput(
"subjektiv revision cursor does not match subject or Memory".into(), "subjektiv change_id cursor does not match subject or Memory".into(),
) )
.into()); .into());
} }
(cursor.max_revision, cursor.offset) (cursor.head_change_id, cursor.offset)
} else { } else {
(current.revision, 0) (current.change_id.clone(), 0)
}; };
let records = store let records = store
.list_memory_revisions(subject_id, &input.memory_id) .list_memory_changes(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)? .map_err(subjektiv_store_error)?
.into_iter() .into_iter()
.filter(|record| record.revision <= max_revision) .skip_while(|record| record.change_id != head_change_id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if records.is_empty() {
return Err(OperationError::InvalidInput(
"subjektiv history cursor refers to an unknown change".into(),
));
}
if offset > records.len() { if offset > records.len() {
return Err(OperationError::InvalidInput( return Err(OperationError::InvalidInput(
"subjektiv revision cursor offset is invalid".into(), "subjektiv change_id cursor offset is invalid".into(),
) )
.into()); .into());
} }
@@ -672,8 +678,8 @@ pub fn subjektiv_memory_list_revisions(
let has_more = end < records.len(); let has_more = end < records.len();
let items = records[offset..end] let items = records[offset..end]
.iter() .iter()
.map(|record| server_api::SubjektivMemoryRevisionItem { .map(|record| server_api::SubjektivMemoryChangeItem {
revision: record.revision, change_id: record.change_id.clone(),
kind: record.kind.clone(), kind: record.kind.clone(),
state: api_memory_state(record.state), state: api_memory_state(record.state),
claim: record.claim.clone(), claim: record.claim.clone(),
@@ -684,19 +690,19 @@ pub fn subjektiv_memory_list_revisions(
let next_cursor = has_more let next_cursor = has_more
.then(|| { .then(|| {
encode_subjektiv_cursor( encode_subjektiv_cursor(
"revisions", "changes",
&SubjektivRevisionCursor { &SubjektivChangeCursor {
subject_id: subject_id.to_string(), subject_id: subject_id.to_string(),
memory_id: input.memory_id.clone(), memory_id: input.memory_id.clone(),
max_revision, head_change_id,
offset: end, offset: end,
}, },
) )
}) })
.transpose()?; .transpose()?;
Ok(server_api::SubjektivMemoryListRevisionsResponse { Ok(server_api::SubjektivMemoryListChangesResponse {
memory_id: input.memory_id, memory_id: input.memory_id,
current_revision: current.revision, current_change_id: current.change_id.clone(),
items, items,
next_cursor, next_cursor,
has_more, has_more,
@@ -708,25 +714,25 @@ pub fn subjektiv_memory_validate_proposal(
subject_id: &str, subject_id: &str,
input: server_api::SubjektivMemoryValidateProposalRequest, input: server_api::SubjektivMemoryValidateProposalRequest,
) -> OperationResult<server_api::SubjektivMemoryProposalValidationResponse> { ) -> OperationResult<server_api::SubjektivMemoryProposalValidationResponse> {
if input.expected_revision == 0 { if input.expected_change_id.trim().is_empty() {
return Err( return Err(
OperationError::InvalidInput("expected_revision must be positive".into()).into(), OperationError::InvalidInput("expected_change_id must be nonempty".into()).into(),
); );
} }
let current = store let current = store
.scoped_memory(subject_id, &input.memory_id) .scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
if current.revision != input.expected_revision { if current.change_id != input.expected_change_id {
return Err(OperationError::Conflict(format!( return Err(OperationError::Conflict(format!(
"revision_conflict: Memory `{}` expected {}, current {}", "change_conflict: Memory `{}` expected {}, current {}",
input.memory_id, input.expected_revision, current.revision input.memory_id, input.expected_change_id, current.change_id
)) ))
.into()); .into());
} }
validate_subjektiv_proposal_transition(current.state, input.intent)?; validate_subjektiv_proposal_transition(current.state, input.intent)?;
Ok(server_api::SubjektivMemoryProposalValidationResponse { Ok(server_api::SubjektivMemoryProposalValidationResponse {
memory_id: input.memory_id, memory_id: input.memory_id,
current_revision: current.revision, current_change_id: current.change_id.clone(),
kind: current.kind, kind: current.kind,
state: api_memory_state(current.state), state: api_memory_state(current.state),
intent: input.intent, intent: input.intent,
@@ -821,10 +827,10 @@ pub fn subjektiv_memory_stage_explicit(
.proposal .proposal
.as_ref() .as_ref()
.map(|proposal| { .map(|proposal| {
crate::RevisionProposal::new( crate::ChangeProposal::new(
domain_proposal_intent(proposal.intent), domain_proposal_intent(proposal.intent),
proposal.memory_id.clone(), proposal.memory_id.clone(),
proposal.expected_revision, proposal.expected_change_id.clone(),
proposal.change_reason.clone(), proposal.change_reason.clone(),
) )
}) })
@@ -836,7 +842,7 @@ pub fn subjektiv_memory_stage_explicit(
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
if current.kind != input.kind { if current.kind != input.kind {
return Err(OperationError::InvalidInput( return Err(OperationError::InvalidInput(
"revision proposal kind must be derived from its target Memory".into(), "change_id proposal kind must be derived from its target Memory".into(),
) )
.into()); .into());
} }
@@ -859,7 +865,7 @@ pub fn subjektiv_memory_stage_explicit(
let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging); let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging);
if let Some(proposal) = proposal.clone() { if let Some(proposal) = proposal.clone() {
staging staging
.attach_revision_proposal(proposal) .attach_change_proposal(proposal)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
} }
let (staged, _) = store let (staged, _) = store
@@ -873,7 +879,7 @@ pub fn subjektiv_memory_stage_explicit(
.as_ref() .as_ref()
.map(|proposal| server_api::SubjektivMemoryProposalTarget { .map(|proposal| server_api::SubjektivMemoryProposalTarget {
memory_id: proposal.memory_id.clone(), memory_id: proposal.memory_id.clone(),
expected_revision: proposal.expected_revision, expected_change_id: proposal.expected_change_id.clone(),
}), }),
intent: proposal intent: proposal
.as_ref() .as_ref()
@@ -883,10 +889,10 @@ pub fn subjektiv_memory_stage_explicit(
pub fn validate_subjektiv_proposal_transition( pub fn validate_subjektiv_proposal_transition(
state: crate::MemoryState, state: crate::MemoryState,
intent: server_api::SubjektivMemoryRevisionIntent, intent: server_api::SubjektivMemoryChangeIntent,
) -> OperationResult<()> { ) -> OperationResult<()> {
use crate::MemoryState::{Active, Resolved}; use crate::MemoryState::{Active, Resolved};
use server_api::SubjektivMemoryRevisionIntent::{Reopen, Resolve, Retract, Revise}; use server_api::SubjektivMemoryChangeIntent::{Reopen, Resolve, Retract, Revise};
let valid = match intent { let valid = match intent {
Revise => matches!(state, Active | Resolved), Revise => matches!(state, Active | Resolved),
Resolve => state == Active, Resolve => state == Active,
@@ -979,32 +985,24 @@ pub fn subjektiv_memory_state_name(state: crate::MemoryState) -> &'static str {
} }
pub fn domain_proposal_intent( pub fn domain_proposal_intent(
intent: server_api::SubjektivMemoryRevisionIntent, intent: server_api::SubjektivMemoryChangeIntent,
) -> crate::RevisionProposalIntent { ) -> crate::ChangeProposalIntent {
match intent { match intent {
server_api::SubjektivMemoryRevisionIntent::Revise => crate::RevisionProposalIntent::Revise, server_api::SubjektivMemoryChangeIntent::Revise => crate::ChangeProposalIntent::Revise,
server_api::SubjektivMemoryRevisionIntent::Resolve => { server_api::SubjektivMemoryChangeIntent::Resolve => crate::ChangeProposalIntent::Resolve,
crate::RevisionProposalIntent::Resolve server_api::SubjektivMemoryChangeIntent::Retract => crate::ChangeProposalIntent::Retract,
} server_api::SubjektivMemoryChangeIntent::Reopen => crate::ChangeProposalIntent::Reopen,
server_api::SubjektivMemoryRevisionIntent::Retract => {
crate::RevisionProposalIntent::Retract
}
server_api::SubjektivMemoryRevisionIntent::Reopen => crate::RevisionProposalIntent::Reopen,
} }
} }
pub fn api_proposal_intent( pub fn api_proposal_intent(
intent: crate::RevisionProposalIntent, intent: crate::ChangeProposalIntent,
) -> server_api::SubjektivMemoryRevisionIntent { ) -> server_api::SubjektivMemoryChangeIntent {
match intent { match intent {
crate::RevisionProposalIntent::Revise => server_api::SubjektivMemoryRevisionIntent::Revise, crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
crate::RevisionProposalIntent::Resolve => { crate::ChangeProposalIntent::Resolve => server_api::SubjektivMemoryChangeIntent::Resolve,
server_api::SubjektivMemoryRevisionIntent::Resolve crate::ChangeProposalIntent::Retract => server_api::SubjektivMemoryChangeIntent::Retract,
} crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
crate::RevisionProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Retract
}
crate::RevisionProposalIntent::Reopen => server_api::SubjektivMemoryRevisionIntent::Reopen,
} }
} }
@@ -1189,6 +1187,7 @@ pub fn bounded_subjektiv_evidence_origin(
&mut origin.worker_id, &mut origin.worker_id,
&mut origin.flow_selector, &mut origin.flow_selector,
&mut origin.flow_definition_id, &mut origin.flow_definition_id,
&mut origin.flow_definition_fingerprint,
] { ] {
if let Some(value) = field.take() { if let Some(value) = field.take() {
*field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); *field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
@@ -1294,10 +1293,10 @@ pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError {
crate::SubjektivError::SubjectScopeMismatch { .. } => { crate::SubjektivError::SubjectScopeMismatch { .. } => {
OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}")) OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}"))
} }
crate::SubjektivError::RevisionConflict { .. } crate::SubjektivError::MemoryChangeConflict { .. }
| crate::SubjektivError::SubjectBehaviorConflict { .. } | crate::SubjektivError::SubjectBehaviorConflict { .. }
| crate::SubjektivError::SurfaceGenerationConflict(_) => { | crate::SubjektivError::SurfaceGenerationConflict(_) => {
OperationError::Conflict(format!("revision_conflict: {error}")) OperationError::Conflict(format!("change_conflict: {error}"))
} }
crate::SubjektivError::CandidateResolved(_) crate::SubjektivError::CandidateResolved(_)
| crate::SubjektivError::DecisionRequestConflict(_) => { | crate::SubjektivError::DecisionRequestConflict(_) => {
+9 -10
View File
@@ -28,14 +28,13 @@ pub fn execute(
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
Response::ResidentContext(server_api::SubjektivResidentContextOutput { Response::ResidentContext(server_api::SubjektivResidentContextOutput {
behavior_md: resident.subject.behavior_md, behavior_md: resident.subject.behavior_md,
behavior_revision: resident.subject.behavior_revision,
memory_surface: resident_summary_output(resident.surface), memory_surface: resident_summary_output(resident.surface),
}) })
} }
Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?), Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?),
Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?), Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?),
Op::ListRevisions(input) => { Op::ListChanges(input) => {
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?) Response::ListChanges(subjektiv_memory_list_changes(store, subject_id, input)?)
} }
Op::ValidateProposal(input) => { Op::ValidateProposal(input) => {
context.body()?; context.body()?;
@@ -169,7 +168,7 @@ pub fn execute(
if resident.availability == crate::SurfaceAvailability::Ready { if resident.availability == crate::SurfaceAvailability::Ready {
response.current_snapshot_id = resident response.current_snapshot_id = resident
.snapshot .snapshot
.filter(|s| s.built_from_store_revision == response.store_revision) .filter(|s| s.built_from_memory_fingerprint == response.memory_fingerprint)
.map(|s| s.id); .map(|s| s.id);
} }
Response::SurfacePrepared(response) Response::SurfacePrepared(response)
@@ -194,9 +193,9 @@ pub fn execute(
memory_refs: point memory_refs: point
.memory_refs .memory_refs
.into_iter() .into_iter()
.map(|r| crate::MemoryRevisionRef { .map(|r| crate::MemoryChangeRef {
memory_id: r.memory_id, memory_id: r.memory_id,
revision: r.revision, change_id: r.change_id,
}) })
.collect(), .collect(),
}) })
@@ -206,7 +205,7 @@ pub fn execute(
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse { Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
snapshot_id: snapshot.id, snapshot_id: snapshot.id,
built_from_store_revision: snapshot.built_from_store_revision, built_from_memory_fingerprint: snapshot.built_from_memory_fingerprint,
empty: snapshot.body_md.is_empty(), empty: snapshot.body_md.is_empty(),
}) })
} }
@@ -222,7 +221,7 @@ pub fn execute(
) )
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
} }
let revision = store let memory_fingerprint = store
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code) .fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
let status = if let Some(job) = job { let status = if let Some(job) = job {
@@ -230,7 +229,7 @@ pub fn execute(
.validate_job_surface_outcome( .validate_job_surface_outcome(
subject_id, subject_id,
&input.generation_id, &input.generation_id,
revision, memory_fingerprint.clone(),
"failed", "failed",
None, None,
Some(&input.reason_code), Some(&input.reason_code),
@@ -247,7 +246,7 @@ pub fn execute(
"failed" "failed"
}; };
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse { Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
store_revision: revision, memory_fingerprint,
status: status.into(), status: status.into(),
}) })
} }
+117 -6
View File
@@ -23,10 +23,15 @@ fn query(cursor: Option<String>) -> Op {
}) })
} }
fn read(id: &str, revision: Option<u64>, offset: Option<usize>, byte_offset: Option<usize>) -> Op { fn read(
id: &str,
change_id: Option<String>,
offset: Option<usize>,
byte_offset: Option<usize>,
) -> Op {
Op::Read(server_api::SubjektivMemoryReadRequest { Op::Read(server_api::SubjektivMemoryReadRequest {
memory_id: id.into(), memory_id: id.into(),
revision, change_id,
offset, offset,
byte_offset, byte_offset,
limit: Some(1), limit: Some(1),
@@ -86,7 +91,7 @@ fn neutral_scope_reopen_and_context_attenuation() {
} }
#[test] #[test]
fn shared_query_cursor_and_utf8_fixed_revision_projection() { fn shared_query_cursor_and_utf8_fixed_change_projection() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope"); let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store let subject = store
@@ -148,7 +153,12 @@ fn shared_query_cursor_and_utf8_fixed_revision_projection() {
let Response::Read(page) = execute( let Response::Read(page) = execute(
&store, &store,
&context, &context,
read(&first.id, Some(1), offset, byte_offset), read(
&first.id,
Some(first.change_id.clone()),
offset,
byte_offset,
),
) )
.unwrap() else { .unwrap() else {
panic!() panic!()
@@ -247,7 +257,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
entry_range: Some([1, 1]), entry_range: Some([1, 1]),
kind: memory::schema::EvidenceKind::new("model_output"), kind: memory::schema::EvidenceKind::new("model_output"),
origin: None, origin: None,
excerpt: Some("Use immutable revisions".into()), excerpt: Some("Use immutable changes".into()),
summary: None, summary: None,
}], }],
source_refs: vec![memory::schema::SourceEvidenceRef { source_refs: vec![memory::schema::SourceEvidenceRef {
@@ -319,7 +329,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
kind: CandidateKind::Lesson, kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active, state: server_api::SubjektivMemoryState::Active,
claim: "Preserve correction history".into(), claim: "Preserve correction history".into(),
body_md: "Use immutable revisions".into(), body_md: "Use immutable changes".into(),
why_useful: "Allows auditing".into(), why_useful: "Allows auditing".into(),
staleness: None, staleness: None,
derived_from: vec![], derived_from: vec![],
@@ -341,3 +351,104 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
) )
.unwrap(); .unwrap();
} }
#[test]
fn history_pages_pin_ancestry_and_reject_unknown_heads() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let draft = |body: &str| {
MemoryDraft::active(CandidateKind::Lesson, "claim", body, "useful", "evidence")
};
let first = store.create_memory(&subject.id, draft("first")).unwrap();
let second = store
.revise_memory(
&subject.id,
&first.id,
first.change_id.clone(),
draft("second"),
)
.unwrap();
let input = |cursor| server_api::SubjektivMemoryListChangesRequest {
memory_id: first.id.clone(),
limit: Some(1),
cursor,
};
let page = subjektiv_memory_list_changes(&store, &subject.id, input(None)).unwrap();
assert_eq!(page.items[0].change_id, second.change_id);
assert!(page.has_more);
let third = store
.revise_memory(
&subject.id,
&first.id,
second.change_id.clone(),
draft("third"),
)
.unwrap();
let next = subjektiv_memory_list_changes(&store, &subject.id, input(page.next_cursor)).unwrap();
assert_eq!(next.items[0].change_id, first.change_id);
assert_eq!(next.current_change_id, third.change_id);
assert!(!next.has_more);
let invalid = encode_subjektiv_cursor(
"changes",
&SubjektivChangeCursor {
subject_id: subject.id.clone(),
memory_id: first.id.clone(),
head_change_id: "not-a-history-entry".into(),
offset: 0,
},
)
.unwrap();
assert!(subjektiv_memory_list_changes(&store, &subject.id, input(Some(invalid))).is_err());
let historic = subjektiv_memory_read(
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: first.id,
change_id: Some(second.change_id.clone()),
offset: None,
byte_offset: None,
limit: None,
evidence_cursor: None,
},
)
.unwrap();
assert_eq!(historic.body_md, "second");
assert_eq!(historic.change_id, second.change_id);
assert_eq!(historic.current_change_id, third.change_id);
}
#[test]
fn query_content_condition_ignores_behavior_and_detects_aba_memory_writes() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let draft = |body: &str| {
MemoryDraft::active(CandidateKind::Lesson, "claim", body, "useful", "evidence")
};
let first = store.create_memory(&subject.id, draft("A")).unwrap();
store.create_memory(&subject.id, draft("other")).unwrap();
let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else {
panic!()
};
let cursor = page.next_cursor.unwrap();
store
.update_subject_behavior(&subject.id, "", "New behavior".into())
.unwrap();
execute(&store, &context, query(Some(cursor.clone()))).unwrap();
let second = store
.revise_memory(&subject.id, &first.id, first.change_id.clone(), draft("B"))
.unwrap();
store
.revise_memory(&subject.id, &first.id, second.change_id, draft("A"))
.unwrap();
assert!(matches!(
execute(&store, &context, query(Some(cursor))),
Err(OperationError::Conflict(_))
));
}
+3 -3
View File
@@ -65,7 +65,7 @@ struct ConsolidationResult {
struct SurfaceResult { struct SurfaceResult {
availability: String, availability: String,
generation_id: String, generation_id: String,
store_revision: u64, memory_fingerprint: String,
#[serde(default)] #[serde(default)]
snapshot_id: Option<String>, snapshot_id: Option<String>,
#[serde(default)] #[serde(default)]
@@ -105,7 +105,7 @@ pub fn validate_result(
.validate_job_surface_outcome( .validate_job_surface_outcome(
subject_id, subject_id,
&result.surface.generation_id, &result.surface.generation_id,
result.surface.store_revision, result.surface.memory_fingerprint,
&result.surface.availability, &result.surface.availability,
result.surface.snapshot_id.as_deref(), result.surface.snapshot_id.as_deref(),
result.surface.reason_code.as_deref(), result.surface.reason_code.as_deref(),
@@ -118,7 +118,7 @@ pub fn validate_result(
} }
/// Enriches a previously verified result with compact durable dispositions. Full /// Enriches a previously verified result with compact durable dispositions. Full
/// reasons, affected revisions and provenance remain in the receipt store. This /// reasons, affected changes and provenance remain in the receipt store. This
/// deterministic projection neither applies candidates nor creates a new ledger. /// deterministic projection neither applies candidates nor creates a new ledger.
pub fn with_dispositions( pub fn with_dispositions(
store: &SubjektivStore, store: &SubjektivStore,
+4 -4
View File
@@ -22,7 +22,7 @@ fn escaped_ids_use_exact_t704_complete_result_budget() {
.collect(); .collect();
let result = serde_json::json!({ let result = serde_json::json!({
"subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions, "subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions,
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) } "surface": { "availability": "failed", "generation_id": "g".repeat(100), "memory_fingerprint": "f".repeat(64), "reason_code": "\\\"".repeat(128) }
}); });
assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize); assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize);
assert!( assert!(
@@ -119,7 +119,7 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
.publish_surface_generation(&subject.id, &generation.id, vec![]) .publish_surface_generation(&subject.id, &generation.id, vec![])
.unwrap(); .unwrap();
let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch, let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}}); "surface": {"availability": "ready", "generation_id": generation.id, "memory_fingerprint": snapshot.built_from_memory_fingerprint, "snapshot_id": snapshot.id}});
assert!( assert!(
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready) validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
.unwrap_err() .unwrap_err()
@@ -170,11 +170,11 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
let failure = store let failure = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt"))) .prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap(); .unwrap();
let revision = store let memory_fingerprint = store
.fail_surface_generation(&subject.id, &failure.id, "model_failure") .fail_surface_generation(&subject.id, &failure.id, "model_failure")
.unwrap(); .unwrap();
let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch, let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}}); "surface": {"availability": "failed", "generation_id": failure.id, "memory_fingerprint": memory_fingerprint, "reason_code": "model_failure"}});
validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap(); validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap();
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err()); assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
let mut false_reason = failed; let mut false_reason = failed;
File diff suppressed because it is too large Load Diff
+709 -1330
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
//! Current schema creation. Frozen data conversion lives with the old migrations.
use super::*;
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
legacy_migrations::remove_state_numbers(tx)
}
pub(super) fn create(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch(include_str!("schema.sql"))?;
Ok(())
}
+390
View File
@@ -0,0 +1,390 @@
CREATE TABLE store_scope (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
workspace_id TEXT NOT NULL UNIQUE
);
CREATE TABLE subjects (
subject_id TEXT PRIMARY KEY,
role TEXT NOT NULL,
behavior_md TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL CHECK (state IN ('active', 'retired')),
record_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE staging_records (
subject_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (
kind IN (
'preference', 'working_assumption', 'constraint',
'decision', 'open_question', 'lesson'
)
),
record_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, candidate_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
);
CREATE TABLE memory_records (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
current_change_id TEXT NOT NULL CHECK (length(current_change_id) > 0),
kind TEXT NOT NULL CHECK (
kind IN (
'preference', 'working_assumption', 'constraint',
'decision', 'open_question', 'lesson'
)
),
state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')),
record_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (subject_id, memory_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, memory_id, current_change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id)
DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE memory_changes (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
previous_change_id TEXT,
kind TEXT NOT NULL CHECK (
kind IN (
'preference', 'working_assumption', 'constraint',
'decision', 'open_question', 'lesson'
)
),
state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')),
record_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, memory_id, change_id),
FOREIGN KEY (subject_id, memory_id)
REFERENCES memory_records(subject_id, memory_id)
ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
FOREIGN KEY (subject_id, memory_id, previous_change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id)
ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
CHECK (previous_change_id IS NULL OR previous_change_id <> change_id)
);
CREATE TABLE memory_change_candidates (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
candidate_id TEXT NOT NULL,
PRIMARY KEY (subject_id, memory_id, change_id, candidate_id),
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TABLE memory_change_derivations (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
source_memory_id TEXT NOT NULL,
source_change_id TEXT NOT NULL CHECK (length(source_change_id) > 0),
PRIMARY KEY (
subject_id, memory_id, change_id, source_memory_id, source_change_id
),
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, source_memory_id, source_change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE staging_resolutions (
subject_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
resolution_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (
action IN ('applied', 'discarded', 'invalid', 'duplicate', 'already_covered')
),
reason TEXT NOT NULL,
affected_refs_json TEXT NOT NULL,
staging_raw_json TEXT NOT NULL,
resolution_json TEXT NOT NULL,
resolved_at TEXT NOT NULL,
PRIMARY KEY (subject_id, candidate_id),
UNIQUE (resolution_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TABLE staging_resolution_targets (
subject_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
PRIMARY KEY (subject_id, candidate_id, memory_id, change_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE surface_snapshots (
subject_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
built_from_memory_fingerprint TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, snapshot_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
);
CREATE TABLE surface_snapshot_refs (
subject_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
PRIMARY KEY (subject_id, snapshot_id, memory_id),
FOREIGN KEY (subject_id, snapshot_id)
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE memory_change_seals (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
PRIMARY KEY (subject_id, memory_id, change_id),
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE staging_resolution_seals (
subject_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
PRIMARY KEY (subject_id, candidate_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TABLE surface_snapshot_seals (
subject_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
PRIMARY KEY (subject_id, snapshot_id),
FOREIGN KEY (subject_id, snapshot_id)
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT
);
CREATE TRIGGER memory_change_candidates_no_late_insert
BEFORE INSERT ON memory_change_candidates
WHEN EXISTS (
SELECT 1 FROM memory_change_seals
WHERE subject_id = NEW.subject_id
AND memory_id = NEW.memory_id
AND change_id = NEW.change_id
) BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id evidence is sealed');
END;
CREATE TRIGGER memory_change_derivations_no_late_insert
BEFORE INSERT ON memory_change_derivations
WHEN EXISTS (
SELECT 1 FROM memory_change_seals
WHERE subject_id = NEW.subject_id
AND memory_id = NEW.memory_id
AND change_id = NEW.change_id
) BEGIN
SELECT RAISE(ABORT, 'subjektiv derivations are sealed');
END;
CREATE TRIGGER staging_resolution_targets_no_late_insert
BEFORE INSERT ON staging_resolution_targets
WHEN EXISTS (
SELECT 1 FROM staging_resolution_seals
WHERE subject_id = NEW.subject_id
AND candidate_id = NEW.candidate_id
) BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution targets are sealed');
END;
CREATE TRIGGER surface_snapshot_refs_no_late_insert
BEFORE INSERT ON surface_snapshot_refs
WHEN EXISTS (
SELECT 1 FROM surface_snapshot_seals
WHERE subject_id = NEW.subject_id
AND snapshot_id = NEW.snapshot_id
) BEGIN
SELECT RAISE(ABORT, 'subjektiv surface references are sealed');
END;
CREATE TRIGGER memory_change_seals_no_update
BEFORE UPDATE ON memory_change_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id seals are immutable');
END;
CREATE TRIGGER memory_change_seals_no_delete
BEFORE DELETE ON memory_change_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id seals are retained');
END;
CREATE TRIGGER staging_resolution_seals_no_update
BEFORE UPDATE ON staging_resolution_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution seals are immutable');
END;
CREATE TRIGGER staging_resolution_seals_no_delete
BEFORE DELETE ON staging_resolution_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution seals are retained');
END;
CREATE TRIGGER surface_snapshot_seals_no_update
BEFORE UPDATE ON surface_snapshot_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv snapshot seals are immutable');
END;
CREATE TRIGGER surface_snapshot_seals_no_delete
BEFORE DELETE ON surface_snapshot_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv snapshot seals are retained');
END;
CREATE TRIGGER memory_changes_no_update
BEFORE UPDATE ON memory_changes BEGIN
SELECT RAISE(ABORT, 'subjektiv memory changes are immutable');
END;
CREATE TRIGGER memory_changes_no_delete
BEFORE DELETE ON memory_changes BEGIN
SELECT RAISE(ABORT, 'subjektiv memory changes are retained');
END;
CREATE TRIGGER memory_change_candidates_no_update
BEFORE UPDATE ON memory_change_candidates BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id evidence is immutable');
END;
CREATE TRIGGER memory_change_candidates_no_delete
BEFORE DELETE ON memory_change_candidates BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id evidence is retained');
END;
CREATE TRIGGER memory_change_derivations_no_update
BEFORE UPDATE ON memory_change_derivations BEGIN
SELECT RAISE(ABORT, 'subjektiv derivations are immutable');
END;
CREATE TRIGGER memory_change_derivations_no_delete
BEFORE DELETE ON memory_change_derivations BEGIN
SELECT RAISE(ABORT, 'subjektiv derivations are retained');
END;
CREATE TRIGGER staging_records_no_update
BEFORE UPDATE ON staging_records BEGIN
SELECT RAISE(ABORT, 'subjektiv staging records are immutable');
END;
CREATE TRIGGER staging_records_no_delete
BEFORE DELETE ON staging_records BEGIN
SELECT RAISE(ABORT, 'subjektiv staging records are retained');
END;
CREATE TRIGGER staging_resolutions_no_update
BEFORE UPDATE ON staging_resolutions BEGIN
SELECT RAISE(ABORT, 'subjektiv staging resolutions are immutable');
END;
CREATE TRIGGER staging_resolutions_no_delete
BEFORE DELETE ON staging_resolutions BEGIN
SELECT RAISE(ABORT, 'subjektiv staging resolutions are retained');
END;
CREATE TRIGGER staging_resolution_targets_no_update
BEFORE UPDATE ON staging_resolution_targets BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution targets are immutable');
END;
CREATE TRIGGER staging_resolution_targets_no_delete
BEFORE DELETE ON staging_resolution_targets BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution targets are retained');
END;
CREATE TRIGGER surface_snapshots_no_update
BEFORE UPDATE ON surface_snapshots BEGIN
SELECT RAISE(ABORT, 'subjektiv surface snapshots are immutable');
END;
CREATE TRIGGER surface_snapshots_no_delete
BEFORE DELETE ON surface_snapshots BEGIN
SELECT RAISE(ABORT, 'subjektiv surface snapshots are retained');
END;
CREATE TRIGGER surface_snapshot_refs_no_update
BEFORE UPDATE ON surface_snapshot_refs BEGIN
SELECT RAISE(ABORT, 'subjektiv surface references are immutable');
END;
CREATE TRIGGER surface_snapshot_refs_no_delete
BEFORE DELETE ON surface_snapshot_refs BEGIN
SELECT RAISE(ABORT, 'subjektiv surface references are retained');
END;
CREATE TABLE subject_session_attributions (
session_id TEXT PRIMARY KEY,
subject_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
record_json TEXT NOT NULL,
attributed_at TEXT NOT NULL,
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
);
CREATE INDEX subject_session_attributions_by_subject
ON subject_session_attributions(subject_id, attributed_at, session_id);
CREATE TRIGGER subject_session_attributions_no_update
BEFORE UPDATE ON subject_session_attributions BEGIN
SELECT RAISE(ABORT, 'subjektiv session attribution is immutable');
END;
CREATE TRIGGER subject_session_attributions_no_delete
BEFORE DELETE ON subject_session_attributions BEGIN
SELECT RAISE(ABORT, 'subjektiv session attribution is retained');
END;
CREATE TABLE candidate_decision_receipts (
subject_id TEXT NOT NULL,
request_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
request_json TEXT NOT NULL,
receipt_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, request_id),
UNIQUE (subject_id, candidate_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TRIGGER candidate_decision_receipts_no_update
BEFORE UPDATE ON candidate_decision_receipts BEGIN
SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable');
END;
CREATE TRIGGER candidate_decision_receipts_no_delete
BEFORE DELETE ON candidate_decision_receipts BEGIN
SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained');
END;
CREATE TABLE surface_generation_state (
subject_id TEXT PRIMARY KEY,
memory_fingerprint TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')),
snapshot_id TEXT,
reason_code TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, snapshot_id)
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT
);
CREATE TABLE surface_generation_runs (
subject_id TEXT NOT NULL,
generation_id TEXT NOT NULL,
memory_fingerprint TEXT NOT NULL,
active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0),
materials_json TEXT NOT NULL,
job_id TEXT,
attempt_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, generation_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
);
CREATE INDEX surface_generation_runs_by_change
ON surface_generation_runs(subject_id, memory_fingerprint, created_at, generation_id);
CREATE TRIGGER surface_generation_runs_no_update
BEFORE UPDATE ON surface_generation_runs BEGIN
SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable');
END;
CREATE TRIGGER surface_generation_runs_no_delete
BEFORE DELETE ON surface_generation_runs BEGIN
SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained');
END;
+175 -55
View File
@@ -88,8 +88,8 @@ pub enum TicketError {
InvalidReadWriteTargetCount(usize), InvalidReadWriteTargetCount(usize),
#[error("ticket target authority is unavailable")] #[error("ticket target authority is unavailable")]
TargetAuthorityUnavailable, TargetAuthorityUnavailable,
#[error("stale ticket item revision: expected `{expected}`, found `{actual}`")] #[error("stale ticket content: expected `{expected}`, found `{actual}`")]
StaleItemRevision { expected: String, actual: String }, StaleContent { expected: String, actual: String },
#[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")] #[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")]
StaleWorkflowState { expected: String, actual: String }, StaleWorkflowState { expected: String, actual: String },
#[error("invalid ticket workflow transition `{from}` -> `{to}`")] #[error("invalid ticket workflow transition `{from}` -> `{to}`")]
@@ -473,7 +473,7 @@ impl TicketStateChange {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TicketCompletion { pub struct TicketCompletion {
pub expected_item_revision: String, pub expected_content_digest: String,
pub expected_state: TicketWorkflowState, pub expected_state: TicketWorkflowState,
pub operation_key: String, pub operation_key: String,
pub reason: String, pub reason: String,
@@ -483,7 +483,7 @@ pub struct TicketCompletion {
pub author: Option<String>, pub author: Option<String>,
} }
/// External state mutation with item/state CAS and an immutable operation receipt. /// External state mutation with content/state CAS and an immutable operation receipt.
/// Exact replay returns the current authoritative Ticket without a new event or /// Exact replay returns the current authoritative Ticket without a new event or
/// reapplying the recorded state, even after later edits or reopening. /// reapplying the recorded state, even after later edits or reopening.
/// Setting `state` to Closed also stores `reason` as the resolution; reopening /// Setting `state` to Closed also stores `reason` as the resolution; reopening
@@ -491,7 +491,7 @@ pub struct TicketCompletion {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TicketStateUpdate { pub struct TicketStateUpdate {
pub expected_item_revision: String, pub expected_content_digest: String,
pub expected_state: TicketWorkflowState, pub expected_state: TicketWorkflowState,
pub state: TicketWorkflowState, pub state: TicketWorkflowState,
pub operation_key: String, pub operation_key: String,
@@ -505,7 +505,7 @@ pub struct TicketStateUpdate {
impl From<TicketCompletion> for TicketStateUpdate { impl From<TicketCompletion> for TicketStateUpdate {
fn from(request: TicketCompletion) -> Self { fn from(request: TicketCompletion) -> Self {
Self { Self {
expected_item_revision: request.expected_item_revision, expected_content_digest: request.expected_content_digest,
expected_state: request.expected_state, expected_state: request.expected_state,
state: TicketWorkflowState::Done, state: TicketWorkflowState::Done,
operation_key: request.operation_key, operation_key: request.operation_key,
@@ -516,17 +516,69 @@ impl From<TicketCompletion> for TicketStateUpdate {
} }
} }
/// Canonical item revision shared by Ticket authority, item checkers, and Merge /// SHA-256 of the actual editable Ticket requirements: identity, title, body,
/// Request review records. Only persisted item_edit event indexes advance it. /// and ordered repository targets. Comments, timestamps, workflow state, and
pub fn ticket_item_revision(ticket: &Ticket) -> String { /// event order are not requirements. Restoring identical content restores its
let index = ticket /// digest; state CAS and operation receipts are independent preconditions.
.events pub fn ticket_content_digest(ticket: &Ticket) -> String {
content_digest(
&ticket.meta.id,
&ticket.meta.title,
ticket.document.body.as_str(),
&ticket.meta.targets,
)
}
fn content_digest(id: &str, title: &str, body: &str, targets: &[TicketTarget]) -> String {
let mut digest = Sha256::new();
digest.update(b"ticket.content.sha256.v1\0");
digest.update(
serde_json::to_vec(&(id, title, body, targets)).expect("Ticket content serializes"),
);
digest
.finalize()
.iter() .iter()
.filter(|event| event.kind == TicketEventKind::Other("item_edit".to_owned())) .map(|byte| format!("{byte:02x}"))
.filter_map(|event| event.attributes.get("event_sequence")?.parse::<i64>().ok()) .collect()
.max() }
.unwrap_or(0);
format!("{}:{index}", ticket.meta.id) /// Read the same content inside the caller's SQLite transaction. Review and
/// Ticket mutation authorities must use this instead of event indexes or time.
pub fn sqlite_ticket_content_digest(
connection: &Connection,
workspace_id: &str,
ticket_id: &str,
) -> Result<Option<String>> {
let item: Option<(String, String)> = connection
.query_row(
"SELECT title,body FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
params![workspace_id, ticket_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(sqlite_err)?;
let Some((title, body)) = item else {
return Ok(None);
};
let mut statement = connection.prepare(
"SELECT repository_key,ref_selector,access FROM typed_ticket_targets WHERE workspace_id=?1 AND ticket_id=?2 ORDER BY ordinal",
).map_err(sqlite_err)?;
let targets = statement
.query_map(params![workspace_id, ticket_id], |row| {
let access: String = row.get(2)?;
let access = TicketTargetAccess::parse(&access).ok_or_else(|| {
rusqlite::Error::InvalidColumnType(2, "access".into(), rusqlite::types::Type::Text)
})?;
Ok(TicketTarget {
repository_key: row.get(0)?,
ref_selector: row.get(1)?,
access,
})
})
.map_err(sqlite_err)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?;
Ok(Some(content_digest(ticket_id, &title, &body, &targets)))
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -2402,7 +2454,7 @@ impl SqliteTicketBackend {
] ]
.map(|key| self.event_attributes.get(key)); .map(|key| self.event_attributes.get(key));
let mut digest = Sha256::new(); let mut digest = Sha256::new();
digest.update(b"ticket.state-update.v1\0"); digest.update(b"ticket.state-update.v2\0");
digest.update( digest.update(
serde_json::to_vec(&(ticket_id, request, body, source)) serde_json::to_vec(&(ticket_id, request, body, source))
.map_err(|error| TicketError::Conflict(error.to_string()))?, .map_err(|error| TicketError::Conflict(error.to_string()))?,
@@ -2437,7 +2489,7 @@ impl SqliteTicketBackend {
body: MarkdownText, body: MarkdownText,
) -> Result<Ticket> { ) -> Result<Ticket> {
validate_required_event_value("operation_key", &request.operation_key)?; validate_required_event_value("operation_key", &request.operation_key)?;
validate_required_event_value("expected_item_revision", &request.expected_item_revision)?; validate_required_event_value("expected_content_digest", &request.expected_content_digest)?;
validate_state_change(&TicketStateChange { validate_state_change(&TicketStateChange {
from: request.expected_state.to_string(), from: request.expected_state.to_string(),
to: request.state.to_string(), to: request.state.to_string(),
@@ -2457,7 +2509,7 @@ impl SqliteTicketBackend {
.attributes .attributes
.get("fingerprint_version") .get("fingerprint_version")
.map(String::as_str) .map(String::as_str)
!= Some("state-update-v1") != Some("state-update-v2")
|| event.attributes.get("request_fingerprint") != Some(&fingerprint) || event.attributes.get("request_fingerprint") != Some(&fingerprint)
{ {
return Err(TicketError::OperationFingerprintMismatch { return Err(TicketError::OperationFingerprintMismatch {
@@ -2471,10 +2523,10 @@ impl SqliteTicketBackend {
// Exact recorded replay is independent of today's live role/assignment. // Exact recorded replay is independent of today's live role/assignment.
// Parent transport authenticates identity before restoring receipt context. // Parent transport authenticates identity before restoring receipt context.
self.require_active_source_assignment(conn, ticket_id)?; self.require_active_source_assignment(conn, ticket_id)?;
let actual = ticket_item_revision(&previous); let actual = ticket_content_digest(&previous);
if actual != request.expected_item_revision { if actual != request.expected_content_digest {
return Err(TicketError::StaleItemRevision { return Err(TicketError::StaleContent {
expected: request.expected_item_revision.clone(), expected: request.expected_content_digest.clone(),
actual, actual,
}); });
} }
@@ -2526,11 +2578,11 @@ impl SqliteTicketBackend {
("request_fingerprint".to_owned(), fingerprint), ("request_fingerprint".to_owned(), fingerprint),
( (
"fingerprint_version".to_owned(), "fingerprint_version".to_owned(),
"state-update-v1".to_owned(), "state-update-v2".to_owned(),
), ),
( (
"expected_item_revision".to_owned(), "expected_content_digest".to_owned(),
request.expected_item_revision.clone(), request.expected_content_digest.clone(),
), ),
]), ]),
}, },
@@ -3449,7 +3501,7 @@ impl TicketBackend for SqliteTicketBackend {
let ticket_id = self.resolve_ticket_id(conn, id)?; let ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?; let previous = self.load_ticket(conn, &ticket_id)?;
let request = TicketStateUpdate { let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&previous), expected_content_digest: ticket_content_digest(&previous),
expected_state: from, expected_state: from,
state: to, state: to,
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?,
@@ -3634,7 +3686,7 @@ impl TicketBackend for SqliteTicketBackend {
let ticket_id = self.resolve_ticket_id(conn, id)?; let ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?; let previous = self.load_ticket(conn, &ticket_id)?;
let request = TicketStateUpdate { let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&previous), expected_content_digest: ticket_content_digest(&previous),
expected_state: previous.meta.workflow_state, expected_state: previous.meta.workflow_state,
state: TicketWorkflowState::Closed, state: TicketWorkflowState::Closed,
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?,
@@ -5626,13 +5678,22 @@ mod tests {
} }
#[test] #[test]
fn item_revision_uses_canonical_max_item_edit_index_not_edit_count_or_content_hash() { fn content_digest_matches_sqlite_and_ignores_event_order() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let backend = backend(&tmp); let backend = backend(&tmp);
let (reference, ticket) = backend let (reference, ticket) = backend
.create_with_snapshot(NewTicket::new("Revision format")) .create_with_snapshot(NewTicket::new("Content digest"))
.unwrap(); .unwrap();
assert_eq!(ticket_item_revision(&ticket), format!("{}:0", reference.id)); assert_eq!(ticket_content_digest(&ticket).len(), 64);
assert_eq!(
sqlite_ticket_content_digest(
&backend.open_connection().unwrap(),
&backend.workspace_id,
&reference.id
)
.unwrap(),
Some(ticket_content_digest(&ticket))
);
backend backend
.add_event( .add_event(
reference.id.clone().into(), reference.id.clone().into(),
@@ -5648,7 +5709,10 @@ mod tests {
}, },
) )
.unwrap(); .unwrap();
assert_eq!(ticket_item_revision(&first), format!("{}:2", reference.id)); assert_ne!(
ticket_content_digest(&first),
ticket_content_digest(&ticket)
);
backend backend
.add_event( .add_event(
reference.id.clone().into(), reference.id.clone().into(),
@@ -5664,13 +5728,23 @@ mod tests {
}, },
) )
.unwrap(); .unwrap();
assert_eq!(ticket_item_revision(&second), format!("{}:4", reference.id)); assert_ne!(
ticket_content_digest(&second),
ticket_content_digest(&first)
);
let digest = ticket_content_digest(&second);
assert_eq!(
sqlite_ticket_content_digest(
&backend.open_connection().unwrap(),
&backend.workspace_id,
&reference.id
)
.unwrap(),
Some(digest.clone())
);
let mut reordered = second; let mut reordered = second;
reordered.events.reverse(); reordered.events.reverse();
assert_eq!( assert_eq!(ticket_content_digest(&reordered), digest);
ticket_item_revision(&reordered),
format!("{}:4", reference.id)
);
} }
#[test] #[test]
@@ -5983,18 +6057,18 @@ mod tests {
assert_eq!(event.from.as_deref(), Some("planning")); assert_eq!(event.from.as_deref(), Some("planning"));
assert_eq!(event.reason.as_deref(), Some("finished")); assert_eq!(event.reason.as_deref(), Some("finished"));
assert_eq!( assert_eq!(
event.attributes.get("expected_item_revision"), event.attributes.get("expected_content_digest"),
Some(&ticket_item_revision(&ticket)) Some(&ticket_content_digest(&ticket))
); );
assert_eq!( assert_eq!(
event event
.attributes .attributes
.get("fingerprint_version") .get("fingerprint_version")
.map(String::as_str), .map(String::as_str),
Some("state-update-v1") Some("state-update-v2")
); );
let request = TicketStateUpdate { let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&ticket), expected_content_digest: ticket_content_digest(&ticket),
expected_state: TicketWorkflowState::Planning, expected_state: TicketWorkflowState::Planning,
state: TicketWorkflowState::Done, state: TicketWorkflowState::Done,
operation_key: event.attributes["operation_key"].clone(), operation_key: event.attributes["operation_key"].clone(),
@@ -6023,8 +6097,8 @@ mod tests {
assert_eq!(close.body, resolution); assert_eq!(close.body, resolution);
assert_eq!(closed.resolution, Some(resolution)); assert_eq!(closed.resolution, Some(resolution));
assert_eq!( assert_eq!(
close.attributes.get("expected_item_revision"), close.attributes.get("expected_content_digest"),
Some(&ticket_item_revision(&done)) Some(&ticket_content_digest(&done))
); );
assert_ne!( assert_ne!(
close.attributes["operation_key"], close.attributes["operation_key"],
@@ -6175,7 +6249,7 @@ mod tests {
fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion { fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion {
TicketCompletion { TicketCompletion {
expected_item_revision: ticket_item_revision(ticket), expected_content_digest: ticket_content_digest(ticket),
expected_state: ticket.meta.workflow_state, expected_state: ticket.meta.workflow_state,
operation_key: key.to_owned(), operation_key: key.to_owned(),
reason: "Acceptance criteria met".to_owned(), reason: "Acceptance criteria met".to_owned(),
@@ -6184,6 +6258,52 @@ mod tests {
} }
} }
#[test]
fn frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requests() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let (reference, ticket) = backend
.create_with_snapshot(NewTicket::new("Old receipt"))
.unwrap();
let request = completion_request(&ticket, "old-operation");
backend.complete(&reference.id, request.clone()).unwrap();
let connection = backend.open_connection().unwrap();
// Frozen pre-content fixture. Its old fingerprint cannot prove a new
// content-based request, and migration must not rewrite immutable audit.
connection.execute(
"UPDATE typed_ticket_event_attributes SET value='state-update-v1' WHERE ticket_id=?1 AND key='fingerprint_version'",
[&reference.id],
).unwrap();
connection.execute(
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
[&reference.id],
).unwrap();
let before = backend.show(reference.id.clone().into()).unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
assert!(matches!(
backend.complete(&reference.id, request),
Err(TicketError::OperationFingerprintMismatch { .. }),
));
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
let fresh = backend
.complete(
&reference.id,
completion_request(&before, "fresh-operation"),
)
.unwrap();
assert_eq!(fresh.events.len(), before.events.len() + 1);
assert_eq!(
fresh
.events
.last()
.unwrap()
.attributes
.get("expected_content_digest"),
Some(&ticket_content_digest(&before))
);
}
#[test] #[test]
fn completion_from_all_states_needs_no_git_targets_or_merge_request() { fn completion_from_all_states_needs_no_git_targets_or_merge_request() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -6316,7 +6436,7 @@ mod tests {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
backend.complete(&reference.id, request), backend.complete(&reference.id, request),
Err(TicketError::StaleItemRevision { .. }) Err(TicketError::StaleContent { .. })
)); ));
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited); assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited);
let mut request = completion_request(&edited, "complete"); let mut request = completion_request(&edited, "complete");
@@ -6440,7 +6560,7 @@ mod tests {
..request.clone() ..request.clone()
}, },
TicketCompletion { TicketCompletion {
expected_item_revision: "other-revision".to_owned(), expected_content_digest: "other-content".to_owned(),
..request.clone() ..request.clone()
}, },
TicketCompletion { TicketCompletion {
@@ -6562,13 +6682,13 @@ mod tests {
} }
#[test] #[test]
fn item_revision_tracks_target_edits_and_aba_but_not_comments_or_state() { fn content_digest_tracks_targets_and_restored_content_not_comments_or_state() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let backend = backend(&tmp); let backend = backend(&tmp);
let (reference, ticket) = backend let (reference, ticket) = backend
.create_with_snapshot(NewTicket::new("Revision")) .create_with_snapshot(NewTicket::new("Content"))
.unwrap(); .unwrap();
let revision = ticket_item_revision(&ticket); let digest = ticket_content_digest(&ticket);
backend backend
.add_event( .add_event(
reference.id.clone().into(), reference.id.clone().into(),
@@ -6576,11 +6696,11 @@ mod tests {
) )
.unwrap(); .unwrap();
let commented = backend.show(reference.id.clone().into()).unwrap(); let commented = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(ticket_item_revision(&commented), revision); assert_eq!(ticket_content_digest(&commented), digest);
let completed = backend let completed = backend
.complete(&reference.id, completion_request(&ticket, "complete")) .complete(&reference.id, completion_request(&ticket, "complete"))
.unwrap(); .unwrap();
assert_eq!(ticket_item_revision(&completed), revision); assert_eq!(ticket_content_digest(&completed), digest);
backend backend
.edit_item( .edit_item(
reference.id.clone().into(), reference.id.clone().into(),
@@ -6601,7 +6721,7 @@ mod tests {
}, },
) )
.unwrap(); .unwrap();
assert_ne!(ticket_item_revision(&restored), revision); assert_eq!(ticket_content_digest(&restored), digest);
} }
#[test] #[test]
@@ -6618,7 +6738,7 @@ mod tests {
..request.clone() ..request.clone()
}, },
TicketCompletion { TicketCompletion {
expected_item_revision: "".to_owned(), expected_content_digest: "".to_owned(),
..request.clone() ..request.clone()
}, },
TicketCompletion { TicketCompletion {
@@ -7081,8 +7201,8 @@ mod tests {
assert_eq!(close.from.as_deref(), Some("planning")); assert_eq!(close.from.as_deref(), Some("planning"));
assert_eq!(close.to.as_deref(), Some("closed")); assert_eq!(close.to.as_deref(), Some("closed"));
assert_eq!( assert_eq!(
close.attributes.get("expected_item_revision"), close.attributes.get("expected_content_digest"),
Some(&format!("{}:0", record.meta.id)) Some(&ticket_content_digest(&record))
); );
assert!(close.attributes.contains_key("operation_key")); assert!(close.attributes.contains_key("operation_key"));
} }
+7
View File
@@ -421,6 +421,13 @@ fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() {
use workdir::{WorkdirError, WorkdirSessionCapability}; use workdir::{WorkdirError, WorkdirSessionCapability};
let cases = [ let cases = [
(WorkdirError::Denied("denied".into()), "checkout_denied"), (WorkdirError::Denied("denied".into()), "checkout_denied"),
(
WorkdirError::DenialContext {
reason: workdir::WorkdirDenialReason::OsPermissionDenied,
source: Box::new(WorkdirError::OutOfScope("outside".into())),
},
"checkout_denied",
),
( (
WorkdirError::OutOfScope("outside".into()), WorkdirError::OutOfScope("outside".into()),
"checkout_denied", "checkout_denied",
+81 -4
View File
@@ -38,17 +38,24 @@ pub enum ToolsError {
impl From<ToolsError> for ToolError { impl From<ToolsError> for ToolError {
fn from(err: ToolsError) -> Self { fn from(err: ToolsError) -> Self {
match &err { match &err {
ToolsError::WorkdirSession( ToolsError::WorkdirSession(error) => {
// Diagnostic context is transparent to the established normal-tool
// classification. Preserve the original display, including unknown
// outcome guidance, while classifying through nested wrappers.
match error.classification_source() {
workdir::WorkdirError::NotFound(_) workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. } | workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed | workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_) | workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_) | workdir::WorkdirError::Conflict(_)
| workdir::WorkdirError::OutcomeUnknown(_), | workdir::WorkdirError::OutcomeUnknown(_) => {
) => ToolError::ExecutionFailed(err.to_string()), ToolError::ExecutionFailed(err.to_string())
}
_ => ToolError::InvalidArgument(err.to_string()),
}
}
ToolsError::FileSystem(_) ToolsError::FileSystem(_)
| ToolsError::WorkdirSession(_)
| ToolsError::NotRead(_) | ToolsError::NotRead(_)
| ToolsError::ExternallyModified(_) | ToolsError::ExternallyModified(_)
| ToolsError::StringNotFound { .. } | ToolsError::StringNotFound { .. }
@@ -134,6 +141,76 @@ mod tests {
} }
} }
#[test]
fn transport_diagnostic_reasons_preserve_normal_tool_classification_and_display() {
use WorkdirTransportErrorCode as Code;
use workdir::{WorkdirDenialReason as Reason, WorkdirError};
for (code, execution_failure) in [
(Code::NotFound, true),
(Code::Conflict, true),
(Code::OutcomeUnknown, true),
(Code::Unsupported, false),
(Code::InvalidRequest, false),
(Code::Denied, false),
(Code::OutOfScope, false),
(Code::SymlinkOutOfScope, false),
(Code::BrokenSymlink, false),
(Code::SymlinkTargetIsDirectory, false),
(Code::ReadOnly, false),
(Code::IsDirectory, false),
(Code::SymlinkDirectoryNotTraversed, false),
(Code::UnknownCommand, false),
(Code::Unavailable, true),
(Code::Io, true),
(Code::Transport, true),
(Code::Internal, true),
] {
// Wire data may carry a reason independently of its public code.
// Neither enrichment nor nested wrappers may change normal-tool behavior.
let baseline_message = serde_json::from_value::<WorkdirTransportError>(
serde_json::json!({"code": code, "message": "safe fixture message"}),
)
.unwrap()
.into_workdir_error()
.to_string();
for depth in [0, 1, 2] {
let wire = serde_json::json!({
"code": code,
"message": "safe fixture message",
"denial_reason": (depth > 0).then_some(Reason::OsPermissionDenied),
});
let mut error = serde_json::from_value::<WorkdirTransportError>(wire)
.unwrap()
.into_workdir_error();
if depth == 2 {
error = WorkdirError::DenialContext {
reason: Reason::ReadOnlySession,
source: Box::new(error),
};
}
let expected_message = error.to_string();
assert_eq!(
expected_message, baseline_message,
"{code:?}, depth {depth}"
);
let tool_error = ToolError::from(ToolsError::from(error));
let (actual_execution_failure, actual_message) = match tool_error {
ToolError::ExecutionFailed(message) => (true, message),
ToolError::InvalidArgument(message) => (false, message),
other => panic!("unexpected classification for {code:?}: {other:?}"),
};
assert_eq!(
actual_execution_failure, execution_failure,
"{code:?}, depth {depth}"
);
assert_eq!(actual_message, expected_message, "{code:?}, depth {depth}");
if code == Code::OutcomeUnknown {
assert!(actual_message.contains("inspect before retrying"));
}
}
}
}
#[test] #[test]
fn remote_workdir_content_conflict_is_retryable_without_host_path() { fn remote_workdir_content_conflict_is_retryable_without_host_path() {
let transport = WorkdirTransportError::from_workdir_error( let transport = WorkdirTransportError::from_workdir_error(
+2 -1
View File
@@ -139,7 +139,8 @@ impl FileTarget {
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError { pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
use workdir::WorkdirError; use workdir::WorkdirError;
let code = match &error { let code = match error.classification_source() {
WorkdirError::DenialContext { .. } => unreachable!("diagnostic context unwrapped"),
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale", WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
WorkdirError::Denied { .. } WorkdirError::Denied { .. }
| WorkdirError::OutOfScope { .. } | WorkdirError::OutOfScope { .. }
+158 -1
View File
@@ -94,7 +94,9 @@ pub(crate) async fn execute_write(
.await .await
{ {
Ok(_) => Some(tracker.expected_workdir_hash(path)?), Ok(_) => Some(tracker.expected_workdir_hash(path)?),
Err(WorkdirError::NotFound(_)) => None, Err(error) if matches!(error.classification_source(), WorkdirError::NotFound(_)) => {
None
}
Err(error) => return Err(ToolsError::from(error).into()), Err(error) => return Err(ToolsError::from(error).into()),
} }
}; };
@@ -190,6 +192,161 @@ mod tests {
assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello\n"); assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello\n");
} }
/// Stat crosses the real JSON transport boundary; mutations still use the
/// checked local provider, so the assertions cover durable tool effects.
#[derive(Debug)]
struct StatErrorSession {
local: LocalWorkdirSession,
code: &'static str,
depth: u8,
stats: std::sync::atomic::AtomicUsize,
writes: std::sync::atomic::AtomicUsize,
}
#[async_trait]
impl workdir::WorkdirSession for StatErrorSession {
fn workdir(&self) -> &workdir::Workdir {
self.local.workdir()
}
fn capabilities(&self) -> workdir::WorkdirSessionCapabilities {
self.local.capabilities()
}
async fn stat(&self, _: StatRequest) -> Result<workdir::StatResult, WorkdirError> {
use workdir::{WorkdirDenialReason as Reason, http::WorkdirTransportError};
self.stats.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let wire = serde_json::json!({
"code": self.code,
"message": "safe fixture message",
"denial_reason": (self.depth > 0).then_some(Reason::OsPermissionDenied),
});
let mut error = serde_json::from_value::<WorkdirTransportError>(wire)
.unwrap()
.into_workdir_error();
if self.depth == 2 {
error = WorkdirError::DenialContext {
reason: Reason::ReadOnlySession,
source: Box::new(error),
};
}
Err(error)
}
async fn read(&self, r: workdir::ReadRequest) -> Result<workdir::ReadResult, WorkdirError> {
self.local.read(r).await
}
async fn write(
&self,
r: workdir::WriteRequest,
) -> Result<workdir::WriteResult, WorkdirError> {
self.writes
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.local.write(r).await
}
async fn edit(&self, r: workdir::EditRequest) -> Result<workdir::EditResult, WorkdirError> {
self.local.edit(r).await
}
async fn list(&self, r: workdir::ListRequest) -> Result<workdir::ListResult, WorkdirError> {
self.local.list(r).await
}
async fn glob(&self, r: workdir::GlobRequest) -> Result<workdir::GlobResult, WorkdirError> {
self.local.glob(r).await
}
async fn grep(&self, r: workdir::GrepRequest) -> Result<workdir::GrepResult, WorkdirError> {
self.local.grep(r).await
}
async fn start_command(
&self,
r: workdir::CommandRequest,
) -> Result<workdir::CommandHandle, WorkdirError> {
self.local.start_command(r).await
}
async fn command_status(
&self,
r: workdir::CommandHandle,
) -> Result<workdir::CommandStatus, WorkdirError> {
self.local.command_status(r).await
}
async fn command_output(
&self,
r: workdir::CommandOutputRequest,
) -> Result<workdir::CommandOutput, WorkdirError> {
self.local.command_output(r).await
}
async fn cancel_command(&self, r: workdir::CommandHandle) -> Result<(), WorkdirError> {
self.local.cancel_command(r).await
}
async fn close(&self) -> Result<(), WorkdirError> {
self.local.close().await
}
}
fn stat_error_session(dir: &TempDir, code: &'static str, depth: u8) -> Arc<StatErrorSession> {
Arc::new(StatErrorSession {
local: LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
dir.path().to_path_buf(),
),
code,
depth,
stats: Default::default(),
writes: Default::default(),
})
}
#[tokio::test]
async fn write_not_found_diagnostics_preserve_create_behavior() {
for depth in [0, 1, 2] {
let dir = TempDir::new().unwrap();
let session = stat_error_session(&dir, "not_found", depth);
let (_, tool) = write_tool(session.clone(), Tracker::new())();
let output = tool
.execute(
r#"{"file_path":"new.txt","content":"hello\n"}"#,
Default::default(),
)
.await
.unwrap_or_else(|error| panic!("depth {depth}: {error:?}"));
assert!(output.summary.contains("Created new.txt"), "{output:?}");
assert_eq!(
std::fs::read_to_string(dir.path().join("new.txt")).unwrap(),
"hello\n"
);
assert_eq!(session.stats.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(session.writes.load(std::sync::atomic::Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn write_stat_failures_do_not_create_or_retry_with_diagnostics() {
for code in ["denied", "read_only", "conflict", "outcome_unknown"] {
for depth in [0, 1, 2] {
let dir = TempDir::new().unwrap();
let session = stat_error_session(&dir, code, depth);
let (_, tool) = write_tool(session.clone(), Tracker::new())();
let error = tool
.execute(
r#"{"file_path":"new.txt","content":"hello"}"#,
Default::default(),
)
.await
.unwrap_err();
if matches!(code, "conflict" | "outcome_unknown") {
assert!(
matches!(error, ToolError::ExecutionFailed(_)),
"{code}, depth {depth}: {error:?}"
);
} else {
assert!(
matches!(error, ToolError::InvalidArgument(_)),
"{code}, depth {depth}: {error:?}"
);
}
assert!(!dir.path().join("new.txt").exists());
assert_eq!(session.stats.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(session.writes.load(std::sync::atomic::Ordering::SeqCst), 0);
}
}
}
#[tokio::test] #[tokio::test]
async fn write_existing_requires_prior_read() { async fn write_existing_requires_prior_read() {
let (dir, fs, tracker) = setup(); let (dir, fs, tracker) = setup();
+6 -5
View File
@@ -40,11 +40,12 @@ arguments; Backspace/Delete removes a chip atomically. Typed chips survive local
history, draft restoration, transport retry, and rewind. history, draft restoration, transport retry, and rewind.
Every completion and chip-re-edit query carries a fresh `request_id`. Replies Every completion and chip-re-edit query carries a fresh `request_id`. Replies
must echo that nonce and match the active query context, composer edit/cursor must echo that ID and match the active query context and Worker target. The first
revision, authority snapshot generation, and Worker target. Identical-prefix ABA input/cursor edit cancels the pending input watch; returning to identical text
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches cannot revive it. Authority snapshots and Worker/view switches discard pending
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even queries and visible candidates, rather than advancing another counter.
for File queries where the wire field remains optional for legacy callers. Uncorrelated replies are ignored, even for File queries where the wire field
remains optional for legacy callers.
The declared attachment adapter stages a client-local file in place, replacing The declared attachment adapter stages a client-local file in place, replacing
its staging chip with an `UploadedFile` reference when ready. Local file argument its staging chip with an `UploadedFile` reference when ready. Local file argument
+107 -138
View File
@@ -35,14 +35,22 @@ pub enum CommandCompletionApply {
/// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger /// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger
/// is invalidated (cursor moved out, whitespace landed inside the /// is invalidated (cursor moved out, whitespace landed inside the
/// token, the sigil was deleted, or the candidate was confirmed). /// token, the sigil was deleted, or the candidate was confirmed).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone)]
struct CompletionSnapshot { struct CompletionSnapshot {
input_revision: u64, input: crate::input::CompletionInputWatch,
generation: u64,
target: String, target: String,
worker_view: Option<String>, worker_view: Option<String>,
} }
impl CompletionSnapshot {
fn same_context(&self, other: &Self) -> bool {
!self.input.is_cancelled()
&& !other.input.is_cancelled()
&& self.target == other.target
&& self.worker_view == other.worker_view
}
}
struct PendingFeatureEdit { struct PendingFeatureEdit {
invocation: protocol::FeatureInvocation, invocation: protocol::FeatureInvocation,
request_id: String, request_id: String,
@@ -229,7 +237,6 @@ impl ActionbarNotice {
pub struct InternalWorkerView { pub struct InternalWorkerView {
pub worker: InternalWorkerRef, pub worker: InternalWorkerRef,
pub revision: u64,
pub app: Box<App>, pub app: Box<App>,
} }
@@ -242,7 +249,7 @@ pub struct WorkerViewTab {
pub struct App { pub struct App {
pub worker_name: String, pub worker_name: String,
pub connected: bool, pub connected: bool,
/// Latest authoritative revisioned live execution state. /// Latest authoritative live execution state.
pub worker_state: WorkerStateSnapshot, pub worker_state: WorkerStateSnapshot,
next_command_id: u64, next_command_id: u64,
/// Derived Runtime-catalog compatibility projection used by existing UI. /// Derived Runtime-catalog compatibility projection used by existing UI.
@@ -297,8 +304,9 @@ pub struct App {
/// Turn/protocol errors retained when a real `SegmentStart` replaces the /// Turn/protocol errors retained when a real `SegmentStart` replaces the
/// replayable conversation rows during segment rotation. /// replayable conversation rows during segment rotation.
run_error_messages: Vec<String>, run_error_messages: Vec<String>,
/// Current compaction identity/revision used to fence snapshot/live updates. /// Current compaction operation; terminal IDs cannot reopen on delayed events.
active_compaction: Option<(String, u64)>, active_compaction: Option<String>,
terminal_compactions: std::collections::HashSet<String>,
pub compaction_progress: Option<protocol::InFlightCompaction>, pub compaction_progress: Option<protocol::InFlightCompaction>,
/// Presentation-only Internal Worker projections keyed by session identity. /// Presentation-only Internal Worker projections keyed by session identity.
/// They are rendered in separate selectable views and never mixed into `blocks`. /// They are rendered in separate selectable views and never mixed into `blocks`.
@@ -307,7 +315,7 @@ pub struct App {
/// view; the stable session identity survives projection reordering. /// view; the stable session identity survives projection reordering.
selected_internal_worker_session_id: Option<String>, selected_internal_worker_session_id: Option<String>,
/// Terminal child-session fences, reset only by an authoritative snapshot. /// Terminal child-session fences, reset only by an authoritative snapshot.
removed_internal_workers: HashMap<String, u64>, removed_internal_workers: std::collections::HashSet<String>,
pub scroll: Scroll, pub scroll: Scroll,
pub mode: Mode, pub mode: Mode,
pub cache: FileCache, pub cache: FileCache,
@@ -323,7 +331,6 @@ pub struct App {
/// typed FeatureInvoke chip. /// typed FeatureInvoke chip.
selected_feature_invocations: HashMap<String, protocol::FeatureInvocationDescriptor>, selected_feature_invocations: HashMap<String, protocol::FeatureInvocationDescriptor>,
pending_feature_edit: Option<PendingFeatureEdit>, pending_feature_edit: Option<PendingFeatureEdit>,
completion_generation: u64,
completion_target: String, completion_target: String,
/// Dedicated main-view rewind picker state. /// Dedicated main-view rewind picker state.
pub rewind_picker: Option<RewindPickerState>, pub rewind_picker: Option<RewindPickerState>,
@@ -397,11 +404,12 @@ impl App {
shutdown_confirm: None, shutdown_confirm: None,
blocks: Vec::new(), blocks: Vec::new(),
active_compaction: None, active_compaction: None,
terminal_compactions: std::collections::HashSet::new(),
compaction_progress: None, compaction_progress: None,
run_error_messages: Vec::new(), run_error_messages: Vec::new(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
selected_internal_worker_session_id: None, selected_internal_worker_session_id: None,
removed_internal_workers: HashMap::new(), removed_internal_workers: std::collections::HashSet::new(),
scroll: Scroll::default(), scroll: Scroll::default(),
mode: Mode::Normal, mode: Mode::Normal,
cache: FileCache::new(), cache: FileCache::new(),
@@ -409,7 +417,6 @@ impl App {
completion: None, completion: None,
selected_feature_invocations: HashMap::new(), selected_feature_invocations: HashMap::new(),
pending_feature_edit: None, pending_feature_edit: None,
completion_generation: 0,
completion_target: protocol::new_submission_request_id(), completion_target: protocol::new_submission_request_id(),
rewind_picker: None, rewind_picker: None,
rewind_request_pending: false, rewind_request_pending: false,
@@ -542,7 +549,7 @@ impl App {
/// Cycle the presentation-only transcript/task view. Input and control /// Cycle the presentation-only transcript/task view. Input and control
/// methods continue to target the parent Worker regardless of selection. /// methods continue to target the parent Worker regardless of selection.
pub fn cycle_worker_view(&mut self) -> bool { pub fn cycle_worker_view(&mut self) -> bool {
self.invalidate_completion_generation(); self.cancel_completion_requests();
if self.internal_workers.is_empty() { if self.internal_workers.is_empty() {
self.selected_internal_worker_session_id = None; self.selected_internal_worker_session_id = None;
return false; return false;
@@ -604,8 +611,7 @@ impl App {
fn completion_snapshot(&self) -> CompletionSnapshot { fn completion_snapshot(&self) -> CompletionSnapshot {
CompletionSnapshot { CompletionSnapshot {
input_revision: self.input.revision(), input: self.input.watch_completion_input(),
generation: self.completion_generation,
target: self.completion_target.clone(), target: self.completion_target.clone(),
worker_view: self.selected_internal_worker_session_id.clone(), worker_view: self.selected_internal_worker_session_id.clone(),
} }
@@ -614,14 +620,13 @@ impl App {
pub(crate) fn set_completion_target(&mut self, target: String) { pub(crate) fn set_completion_target(&mut self, target: String) {
if self.completion_target != target { if self.completion_target != target {
self.completion_target = target; self.completion_target = target;
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.selected_feature_invocations.clear(); self.selected_feature_invocations.clear();
} }
} }
/// Authority/snapshot/target changes revoke in-flight queries and visible candidates. /// Authority/snapshot/target changes revoke in-flight queries and visible candidates.
fn invalidate_completion_generation(&mut self) { fn cancel_completion_requests(&mut self) {
self.completion_generation = self.completion_generation.wrapping_add(1);
self.completion = None; self.completion = None;
self.pending_feature_edit = None; self.pending_feature_edit = None;
} }
@@ -672,7 +677,7 @@ impl App {
&& state.prefix_start == start && state.prefix_start == start
&& state.prefix == prefix && state.prefix == prefix
&& state.context == context && state.context == context
&& state.snapshot == snapshot && state.snapshot.same_context(&snapshot)
}) { }) {
return None; return None;
} }
@@ -722,7 +727,7 @@ impl App {
} }
pub fn cancel_completion(&mut self) { pub fn cancel_completion(&mut self) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
} }
/// Tab path: insert the popup-selected entry's value (with a /// Tab path: insert the popup-selected entry's value (with a
@@ -736,7 +741,7 @@ impl App {
if self if self
.completion .completion
.as_ref() .as_ref()
.is_some_and(|state| state.snapshot != self.completion_snapshot()) .is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
{ {
self.cancel_completion(); self.cancel_completion();
return None; return None;
@@ -885,7 +890,7 @@ impl App {
if self if self
.completion .completion
.as_ref() .as_ref()
.is_some_and(|state| state.snapshot != self.completion_snapshot()) .is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
{ {
self.cancel_completion(); self.cancel_completion();
return false; return false;
@@ -942,7 +947,7 @@ impl App {
if self if self
.completion .completion
.as_ref() .as_ref()
.is_some_and(|state| state.snapshot != self.completion_snapshot()) .is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
{ {
self.cancel_completion(); self.cancel_completion();
return false; return false;
@@ -1248,22 +1253,23 @@ impl App {
pub fn continue_pending_method(&self) -> Option<Method> { pub fn continue_pending_method(&self) -> Option<Method> {
Some(Method::ContinuePending { Some(Method::ContinuePending {
expected_revision: self.pending_submissions.revision,
expected_head_id: self.pending_submissions.head_id.clone()?, expected_head_id: self.pending_submissions.head_id.clone()?,
}) })
} }
pub fn clear_pending_method(&self) -> Method { pub fn clear_pending_method(&self) -> Method {
Method::ClearPendingSubmissions { Method::ClearPendingSubmissions {
expected_revision: self.pending_submissions.revision, expected_submission_ids: self
.pending_submissions
.submissions
.iter()
.map(|submission| submission.submission_id.clone())
.collect(),
} }
} }
pub fn cancel_pending_method(&self, submission_id: String) -> Method { pub fn cancel_pending_method(&self, submission_id: String) -> Method {
Method::CancelPendingSubmission { Method::CancelPendingSubmission { submission_id }
submission_id,
expected_revision: self.pending_submissions.revision,
}
} }
pub fn next_queued_input_preview(&self) -> Option<&str> { pub fn next_queued_input_preview(&self) -> Option<&str> {
@@ -1802,14 +1808,13 @@ impl App {
}); });
} }
Event::CompactStart { lifecycle } => { Event::CompactStart { lifecycle } => {
let should_apply = match &self.active_compaction { let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
None => true, && self
Some((id, revision)) => { .active_compaction
id == &lifecycle.compaction_id && lifecycle.revision > *revision .as_ref()
} .is_none_or(|id| id == &lifecycle.compaction_id);
};
if should_apply { if should_apply {
self.active_compaction = Some((lifecycle.compaction_id, lifecycle.revision)); self.active_compaction = Some(lifecycle.compaction_id);
if self.last_streaming_compact_mut().is_none() { if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming { self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(), started_at: Instant::now(),
@@ -1818,15 +1823,16 @@ impl App {
} }
} }
Event::CompactDone { lifecycle } => { Event::CompactDone { lifecycle } => {
let should_apply = match &self.active_compaction { let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
None => true, && self
Some((id, revision)) => { .active_compaction
id == &lifecycle.compaction_id && lifecycle.revision > *revision .as_ref()
} .is_none_or(|id| id == &lifecycle.compaction_id);
};
if !should_apply { if !should_apply {
return None; return None;
} }
self.terminal_compactions
.insert(lifecycle.compaction_id.clone());
self.active_compaction = None; self.active_compaction = None;
let new_segment_id = lifecycle let new_segment_id = lifecycle
.new_segment_id .new_segment_id
@@ -1852,15 +1858,16 @@ impl App {
} }
} }
Event::CompactFailed { lifecycle } => { Event::CompactFailed { lifecycle } => {
let should_apply = match &self.active_compaction { let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
None => true, && self
Some((id, revision)) => { .active_compaction
id == &lifecycle.compaction_id && lifecycle.revision > *revision .as_ref()
} .is_none_or(|id| id == &lifecycle.compaction_id);
};
if !should_apply { if !should_apply {
return None; return None;
} }
self.terminal_compactions
.insert(lifecycle.compaction_id.clone());
self.active_compaction = None; self.active_compaction = None;
let error = lifecycle let error = lifecycle
.error .error
@@ -1915,14 +1922,10 @@ impl App {
self.replace_internal_worker_snapshots(internal_workers); self.replace_internal_worker_snapshots(internal_workers);
return self.refresh_completion(); return self.refresh_completion();
} }
Event::InternalWorker { Event::InternalWorker { worker, event } => {
worker, self.apply_internal_worker_event(worker, *event)
revision,
event,
} => self.apply_internal_worker_event(worker, revision, *event),
Event::InternalWorkerRemoved { worker, revision } => {
self.remove_internal_worker(worker, revision)
} }
Event::InternalWorkerRemoved { worker } => self.remove_internal_worker(worker),
Event::WorkerState { snapshot } => { Event::WorkerState { snapshot } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.apply_worker_state_snapshot(&snapshot); self.apply_worker_state_snapshot(&snapshot);
@@ -1956,7 +1959,7 @@ impl App {
&& self.pending_feature_edit.as_ref().is_some_and(|edit| { && self.pending_feature_edit.as_ref().is_some_and(|edit| {
edit.invocation.name == prefix edit.invocation.name == prefix
&& request_id.as_ref() == Some(&edit.request_id) && request_id.as_ref() == Some(&edit.request_id)
&& edit.snapshot == snapshot && edit.snapshot.same_context(&snapshot)
}) })
{ {
let invocation = self.pending_feature_edit.take().unwrap().invocation; let invocation = self.pending_feature_edit.take().unwrap().invocation;
@@ -1981,7 +1984,7 @@ impl App {
// stale argument/provider replies cannot cross invocation scope. // stale argument/provider replies cannot cross invocation scope.
if let Some(state) = self.completion.as_mut() if let Some(state) = self.completion.as_mut()
&& request_id.as_ref() == Some(&state.request_id) && request_id.as_ref() == Some(&state.request_id)
&& state.snapshot == snapshot && state.snapshot.same_context(&snapshot)
&& state.kind == kind && state.kind == kind
&& state.prefix == prefix && state.prefix == prefix
&& state.context == context && state.context == context
@@ -2100,7 +2103,7 @@ impl App {
); );
} }
Event::Shutdown => { Event::Shutdown => {
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.mark_orphan_compacts_incomplete(); self.mark_orphan_compacts_incomplete();
self.quit = true; self.quit = true;
} }
@@ -2309,7 +2312,7 @@ impl App {
} }
pub fn enter_command_mode(&mut self) { pub fn enter_command_mode(&mut self) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.input_mode = CommandInputMode::Command; self.input_mode = CommandInputMode::Command;
self.completion = None; self.completion = None;
self.command_completion_selected = None; self.command_completion_selected = None;
@@ -2317,7 +2320,7 @@ impl App {
} }
pub fn exit_command_mode(&mut self) { pub fn exit_command_mode(&mut self) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.input_mode = CommandInputMode::Composer; self.input_mode = CommandInputMode::Composer;
self.command_input.clear(); self.command_input.clear();
self.command_completion_selected = None; self.command_completion_selected = None;
@@ -2445,7 +2448,7 @@ impl App {
} }
pub fn request_rewind_picker(&mut self) -> Option<Method> { pub fn request_rewind_picker(&mut self) -> Option<Method> {
self.invalidate_completion_generation(); self.cancel_completion_requests();
// Rewind is a parent Worker control surface. Bring the parent transcript // Rewind is a parent Worker control surface. Bring the parent transcript
// back into view before presenting diagnostics or the picker. // back into view before presenting diagnostics or the picker.
self.selected_internal_worker_session_id = None; self.selected_internal_worker_session_id = None;
@@ -2791,21 +2794,12 @@ impl App {
app.replace_internal_worker_snapshots(snapshot.internal_workers); app.replace_internal_worker_snapshots(snapshot.internal_workers);
InternalWorkerView { InternalWorkerView {
worker: snapshot.worker, worker: snapshot.worker,
revision: snapshot.revision,
app: Box::new(app), app: Box::new(app),
} }
} }
fn apply_internal_worker_event( fn apply_internal_worker_event(&mut self, worker: InternalWorkerRef, event: Event) {
&mut self, if self.removed_internal_workers.contains(&worker.session_id) {
worker: InternalWorkerRef,
revision: u64,
event: Event,
) {
if self
.removed_internal_workers
.contains_key(&worker.session_id)
{
return; return;
} }
let index = self let index = self
@@ -2819,21 +2813,16 @@ impl App {
app.mode = self.mode; app.mode = self.mode;
self.internal_workers.push(InternalWorkerView { self.internal_workers.push(InternalWorkerView {
worker: worker.clone(), worker: worker.clone(),
revision: 0,
app: Box::new(app), app: Box::new(app),
}); });
self.internal_workers.last_mut().unwrap() self.internal_workers.last_mut().unwrap()
}; };
if revision <= target.revision {
return;
}
target.worker = worker; target.worker = worker;
target.revision = revision;
let _ = target.app.handle_worker_event(event); let _ = target.app.handle_worker_event(event);
} }
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) { fn remove_internal_worker(&mut self, worker: InternalWorkerRef) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
let session_id = worker.session_id; let session_id = worker.session_id;
let Some(index) = self let Some(index) = self
.internal_workers .internal_workers
@@ -2843,20 +2832,14 @@ impl App {
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) { if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None; self.selected_internal_worker_session_id = None;
} }
self.removed_internal_workers self.removed_internal_workers.insert(session_id);
.entry(session_id)
.and_modify(|current| *current = (*current).max(revision))
.or_insert(revision);
return; return;
}; };
if revision <= self.internal_workers[index].revision {
return;
}
self.internal_workers.remove(index); self.internal_workers.remove(index);
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) { if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None; self.selected_internal_worker_session_id = None;
} }
self.removed_internal_workers.insert(session_id, revision); self.removed_internal_workers.insert(session_id);
} }
fn restore_snapshot( fn restore_snapshot(
@@ -2918,7 +2901,7 @@ impl App {
session: &protocol::SessionSnapshot, session: &protocol::SessionSnapshot,
greeting: Option<protocol::Greeting>, greeting: Option<protocol::Greeting>,
) { ) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.run_error_messages.clear(); self.run_error_messages.clear();
self.turn_index = 0; self.turn_index = 0;
self.blocks.clear(); self.blocks.clear();
@@ -4102,7 +4085,6 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged { app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot { pending: protocol::PendingSubmissionsSnapshot {
revision: 3,
notification_count: 0, notification_count: 0,
notification_previews: vec![], notification_previews: vec![],
head_id: Some("submission-1".into()), head_id: Some("submission-1".into()),
@@ -4497,34 +4479,25 @@ mod completion_flow_tests {
}; };
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
revision: 2,
event: Box::new(Event::TextDelta { event: Box::new(Event::TextDelta {
text: "child output".into(), text: "child output".into(),
}), }),
}); });
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker, worker,
revision: 1,
event: Box::new(Event::TextDelta { event: Box::new(Event::TextDelta {
text: "stale".into(), text: " next".into(),
}), }),
}); });
assert!(app.blocks.is_empty()); assert!(app.blocks.is_empty());
assert_eq!(app.internal_workers.len(), 1); assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].revision, 2); assert!(app.internal_workers[0].app.blocks.iter().any(
assert!( |block| matches!(block, Block::AssistantText { text } if text == "child output next")
app.internal_workers[0].app.blocks.iter().any( ));
|block| matches!(block, Block::AssistantText { text } if text == "child output")
)
);
} }
fn test_internal_worker_snapshot( fn test_internal_worker_snapshot(session_id: &str, name: &str) -> InternalWorkerSnapshot {
session_id: &str,
name: &str,
revision: u64,
) -> InternalWorkerSnapshot {
InternalWorkerSnapshot { InternalWorkerSnapshot {
worker: InternalWorkerRef { worker: InternalWorkerRef {
session_id: session_id.into(), session_id: session_id.into(),
@@ -4532,7 +4505,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent".into()), parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision,
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
greeting: None, greeting: None,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
@@ -4548,7 +4520,7 @@ mod completion_flow_tests {
#[test] #[test]
fn internal_worker_snapshot_uses_child_greeting_metadata() { fn internal_worker_snapshot_uses_child_greeting_metadata() {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
let mut child = test_internal_worker_snapshot("child", "research", 1); let mut child = test_internal_worker_snapshot("child", "research");
let mut greeting = test_greeting(); let mut greeting = test_greeting();
greeting.worker_name = "research".into(); greeting.worker_name = "research".into();
greeting.model = "child-model".into(); greeting.model = "child-model".into();
@@ -4570,7 +4542,7 @@ mod completion_flow_tests {
#[test] #[test]
fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() { fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
let worker = test_internal_worker_snapshot("child", "research", 1).worker; let worker = test_internal_worker_snapshot("child", "research").worker;
let mut greeting = test_greeting(); let mut greeting = test_greeting();
greeting.worker_name = "research".into(); greeting.worker_name = "research".into();
greeting.model = "child-model".into(); greeting.model = "child-model".into();
@@ -4582,7 +4554,6 @@ mod completion_flow_tests {
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
revision: 1,
event: Box::new(Event::Snapshot { event: Box::new(Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
@@ -4596,7 +4567,6 @@ mod completion_flow_tests {
}); });
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker, worker,
revision: 2,
event: Box::new(Event::ContextUsage { event: Box::new(Event::ContextUsage {
usage: Some(protocol::ContextUsage { usage: Some(protocol::ContextUsage {
tokens: 14_000, tokens: 14_000,
@@ -4626,7 +4596,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent".into()), parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
app: Box::new(App::new(name.into())), app: Box::new(App::new(name.into())),
}); });
} }
@@ -4664,7 +4633,7 @@ mod completion_flow_tests {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child", 1, "child", "child",
)]); )]);
select_first_row(&mut app, "parent selection"); select_first_row(&mut app, "parent selection");
select_first_row(app.internal_workers[0].app.as_mut(), "child selection"); select_first_row(app.internal_workers[0].app.as_mut(), "child selection");
@@ -4687,7 +4656,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent".into()), parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
app: Box::new(App::new("old".into())), app: Box::new(App::new("old".into())),
}); });
app.cycle_worker_view(); app.cycle_worker_view();
@@ -4711,7 +4679,7 @@ mod completion_flow_tests {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child", 1, "child", "child",
)]); )]);
let child = app.internal_workers[0].app.as_mut(); let child = app.internal_workers[0].app.as_mut();
child.scroll.follow_tail = false; child.scroll.follow_tail = false;
@@ -4733,11 +4701,9 @@ mod completion_flow_tests {
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child",
"renamed-child", "renamed-child",
2,
)]); )]);
let view = &app.internal_workers[0]; let view = &app.internal_workers[0];
assert_eq!(view.revision, 2);
assert_eq!(view.app.worker_name, "renamed-child"); assert_eq!(view.app.worker_name, "renamed-child");
assert!(!view.app.scroll.follow_tail); assert!(!view.app.scroll.follow_tail);
assert_eq!(view.app.scroll.top_offset, 7); assert_eq!(view.app.scroll.top_offset, 7);
@@ -4749,8 +4715,8 @@ mod completion_flow_tests {
fn task_pane_scroll_is_local_to_selected_worker_view() { fn task_pane_scroll_is_local_to_selected_worker_view() {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![ app.replace_internal_worker_snapshots(vec![
test_internal_worker_snapshot("child-a", "alpha", 1), test_internal_worker_snapshot("child-a", "alpha"),
test_internal_worker_snapshot("child-b", "beta", 1), test_internal_worker_snapshot("child-b", "beta"),
]); ]);
app.task_pane_scroll = 3; app.task_pane_scroll = 3;
@@ -4793,10 +4759,8 @@ mod completion_flow_tests {
}; };
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
revision: 2,
event: Box::new(Event::InternalWorker { event: Box::new(Event::InternalWorker {
worker: nested, worker: nested,
revision: 1,
event: Box::new(Event::TextDone { event: Box::new(Event::TextDone {
text: "nested".into(), text: "nested".into(),
}), }),
@@ -4809,11 +4773,9 @@ mod completion_flow_tests {
app.handle_worker_event(Event::InternalWorkerRemoved { app.handle_worker_event(Event::InternalWorkerRemoved {
worker: worker.clone(), worker: worker.clone(),
revision: 3,
}); });
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker, worker,
revision: 4,
event: Box::new(Event::TextDone { event: Box::new(Event::TextDone {
text: "late".into(), text: "late".into(),
}), }),
@@ -4836,7 +4798,7 @@ mod completion_flow_tests {
} }
#[test] #[test]
fn stale_internal_worker_removal_keeps_newer_projection() { fn internal_worker_removal_applies_in_stream_order() {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
let worker = InternalWorkerRef { let worker = InternalWorkerRef {
session_id: "child-session".into(), session_id: "child-session".into(),
@@ -4846,18 +4808,13 @@ mod completion_flow_tests {
}; };
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
revision: 4,
event: Box::new(Event::TextDone { event: Box::new(Event::TextDone {
text: "current".into(), text: "current".into(),
}), }),
}); });
app.handle_worker_event(Event::InternalWorkerRemoved { app.handle_worker_event(Event::InternalWorkerRemoved { worker });
worker,
revision: 3,
});
assert_eq!(app.internal_workers.len(), 1); assert!(app.internal_workers.is_empty());
assert_eq!(app.internal_workers[0].revision, 4);
} }
#[test] #[test]
@@ -4870,7 +4827,6 @@ mod completion_flow_tests {
parent_session_id: None, parent_session_id: None,
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
app: Box::new(App::new("old".into())), app: Box::new(App::new("old".into())),
}); });
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
@@ -4888,7 +4844,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 4,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
@@ -4903,7 +4858,6 @@ mod completion_flow_tests {
assert_eq!(app.internal_workers.len(), 1); assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].worker.session_id, "replacement"); assert_eq!(app.internal_workers[0].worker.session_id, "replacement");
assert_eq!(app.internal_workers[0].revision, 4);
assert_eq!( assert_eq!(
app.internal_workers[0].app.worker_status, app.internal_workers[0].app.worker_status,
WorkerStatus::Running WorkerStatus::Running
@@ -4955,7 +4909,6 @@ mod completion_flow_tests {
protocol::CompactionLifecycle { protocol::CompactionLifecycle {
schema_version: 2, schema_version: 2,
compaction_id: "compaction-test".into(), compaction_id: "compaction-test".into(),
revision: 1,
internal_worker: None, internal_worker: None,
state, state,
started_at_ms: 1, started_at_ms: 1,
@@ -4975,7 +4928,6 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
lifecycle.revision = 2;
lifecycle.new_segment_id = Some(id.to_string()); lifecycle.new_segment_id = Some(id.to_string());
app.handle_worker_event(Event::CompactDone { lifecycle }); app.handle_worker_event(Event::CompactDone { lifecycle });
@@ -4989,6 +4941,25 @@ mod completion_flow_tests {
)); ));
} }
#[test]
fn terminal_compaction_identity_rejects_duplicate_and_delayed_start() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::CompactStart {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
app.handle_worker_event(Event::CompactDone {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Done),
});
app.handle_worker_event(Event::CompactStart {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
app.handle_worker_event(Event::CompactDone {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Done),
});
assert_eq!(compact_block_count(&app), 1);
assert!(app.active_compaction.is_none());
}
#[test] #[test]
fn compact_failed_replaces_live_block() { fn compact_failed_replaces_live_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -4997,7 +4968,6 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
lifecycle.revision = 2;
lifecycle.error = Some("provider 429".into()); lifecycle.error = Some("provider 429".into());
app.handle_worker_event(Event::CompactFailed { lifecycle }); app.handle_worker_event(Event::CompactFailed { lifecycle });
@@ -5662,7 +5632,7 @@ mod completion_correlation_tests {
let first = app.refresh_completion().unwrap(); let first = app.refresh_completion().unwrap();
app.input.insert_char('x'); app.input.insert_char('x');
app.input.delete_before(); app.input.delete_before();
// Prefix, context, token location, and cursor are identical; semantic revision is not. // Returning to the same text cannot revive a request cancelled by the edit.
app.handle_worker_event(reply(&first, "old")); app.handle_worker_event(reply(&first, "old"));
assert!(app.completion.as_ref().unwrap().entries.is_empty()); assert!(app.completion.as_ref().unwrap().entries.is_empty());
let second = app.refresh_completion().unwrap(); let second = app.refresh_completion().unwrap();
@@ -5734,7 +5704,7 @@ mod completion_correlation_tests {
} }
#[test] #[test]
fn permission_snapshot_generation_aba_revokes_queries_even_when_scope_returns_to_original() { fn permission_snapshot_aba_revokes_queries_even_when_scope_returns_to_original() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(authority_snapshot("Writable: /tmp")); app.handle_worker_event(authority_snapshot("Writable: /tmp"));
crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run"); crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run");
@@ -5748,8 +5718,8 @@ mod completion_correlation_tests {
.unwrap(); .unwrap();
assert_ne!(request_id(&first), request_id(&denied)); assert_ne!(request_id(&first), request_id(&denied));
assert_ne!(request_id(&first), request_id(&current)); assert_ne!(request_id(&first), request_id(&current));
app.handle_worker_event(reply(&first, "old permission generation")); app.handle_worker_event(reply(&first, "old permission context"));
app.handle_worker_event(reply(&denied, "intermediate generation")); app.handle_worker_event(reply(&denied, "intermediate context"));
assert!(app.completion.as_ref().unwrap().entries.is_empty()); assert!(app.completion.as_ref().unwrap().entries.is_empty());
app.handle_worker_event(reply(&current, "資料/current")); app.handle_worker_event(reply(&current, "資料/current"));
assert_eq!( assert_eq!(
@@ -5789,7 +5759,6 @@ mod completion_correlation_tests {
parent_session_id: Some("parent".into()), parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle), snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle),
}), }),
+3 -4
View File
@@ -674,7 +674,7 @@ mod tests {
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary { fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
BackendWorkerSummary { BackendWorkerSummary {
restore_observation_token: Some("observed-worker-generation".into()), restore_observation_token: Some("observed-worker-state".into()),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(), worker_id: worker_id.to_string(),
resource_key: "W-1".to_string(), resource_key: "W-1".to_string(),
@@ -953,7 +953,6 @@ mod tests {
workspace_api: None, workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".into(), workspace_id: "local".into(),
settings_revision: 1,
language: "English".into(), language: "English".into(),
}), }),
subjektiv_attached: false, subjektiv_attached: false,
@@ -1107,7 +1106,7 @@ mod tests {
target, target,
BackendWorkerPickerIntent::Resume, BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest { Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(), expected_observation_token: "observed-worker-state".into(),
request_id: "resume-operation".into(), request_id: "resume-operation".into(),
}), }),
|_| Ok(false), |_| Ok(false),
@@ -1142,7 +1141,7 @@ mod tests {
target, target,
BackendWorkerPickerIntent::Resume, BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest { Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(), expected_observation_token: "observed-worker-state".into(),
request_id: "resume-operation".into(), request_id: "resume-operation".into(),
}), }),
|_| Ok(false), |_| Ok(false),
+2 -10
View File
@@ -2625,7 +2625,6 @@ mod tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged { app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot { pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0, notification_count: 0,
notification_previews: vec![], notification_previews: vec![],
head_id: Some("submission-1".into()), head_id: Some("submission-1".into()),
@@ -2646,7 +2645,6 @@ mod tests {
assert!(matches!( assert!(matches!(
continue_next, continue_next,
Some(Method::ContinuePending { Some(Method::ContinuePending {
expected_revision: 2,
ref expected_head_id, ref expected_head_id,
}) if expected_head_id == "submission-1" }) if expected_head_id == "submission-1"
)); ));
@@ -2659,7 +2657,6 @@ mod tests {
assert!(matches!( assert!(matches!(
cancel, cancel,
Some(Method::CancelPendingSubmission { Some(Method::CancelPendingSubmission {
expected_revision: 2,
ref submission_id, ref submission_id,
}) if submission_id == "submission-1" }) if submission_id == "submission-1"
)); ));
@@ -2671,8 +2668,8 @@ mod tests {
assert!(matches!( assert!(matches!(
clear, clear,
Some(Method::ClearPendingSubmissions { Some(Method::ClearPendingSubmissions {
expected_revision: 2 ref expected_submission_ids
}) }) if expected_submission_ids == &["submission-1"]
)); ));
assert_eq!(app.queued_input_count(), 1); assert_eq!(app.queued_input_count(), 1);
} }
@@ -2682,7 +2679,6 @@ mod tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged { app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot { pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0, notification_count: 0,
notification_previews: vec![], notification_previews: vec![],
head_id: Some("submission-1".into()), head_id: Some("submission-1".into()),
@@ -3355,7 +3351,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(), snapshot: WorkerStatus::Running.into(),
}), }),
@@ -3471,7 +3466,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(), snapshot: WorkerStatus::Running.into(),
}), }),
@@ -3497,7 +3491,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(), snapshot: WorkerStatus::Running.into(),
}), }),
@@ -3537,7 +3530,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(), snapshot: WorkerStatus::Running.into(),
}), }),
+56 -26
View File
@@ -11,6 +11,14 @@
//! back to their original captured content so the Worker sees the full //! back to their original captured content so the Worker sees the full
//! pasted text (without the placeholder label). //! pasted text (without the placeholder label).
use std::{
cell::RefCell,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
@@ -281,9 +289,19 @@ struct SelectedInvocation {
invocation_id: String, invocation_id: String,
} }
/// A pending completion is cancelled by the first subsequent input/cursor edit.
/// Returning to identical text does not revive that request.
#[derive(Debug, Clone)]
pub(crate) struct CompletionInputWatch(Arc<AtomicBool>);
impl CompletionInputWatch {
pub(crate) fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
pub struct InputBuffer { pub struct InputBuffer {
/// Monotonic semantic edit/cursor revision, including ABA changes. pending_completion: RefCell<Option<Arc<AtomicBool>>>,
revision: u64,
atoms: Vec<Atom>, atoms: Vec<Atom>,
selected_invocations: Vec<SelectedInvocation>, selected_invocations: Vec<SelectedInvocation>,
removed_attachment_stages: Vec<String>, removed_attachment_stages: Vec<String>,
@@ -300,7 +318,7 @@ pub struct InputBuffer {
impl Default for InputBuffer { impl Default for InputBuffer {
fn default() -> Self { fn default() -> Self {
Self { Self {
revision: 0, pending_completion: RefCell::new(None),
atoms: Vec::new(), atoms: Vec::new(),
selected_invocations: Vec::new(), selected_invocations: Vec::new(),
removed_attachment_stages: Vec::new(), removed_attachment_stages: Vec::new(),
@@ -312,13 +330,25 @@ impl Default for InputBuffer {
} }
} }
impl InputBuffer { impl Drop for InputBuffer {
pub fn revision(&self) -> u64 { fn drop(&mut self) {
self.revision self.cancel_completion_on_edit();
}
} }
fn bump_revision(&mut self) { impl InputBuffer {
self.revision = self.revision.wrapping_add(1); pub(crate) fn watch_completion_input(&self) -> CompletionInputWatch {
CompletionInputWatch(Arc::clone(
self.pending_completion
.borrow_mut()
.get_or_insert_with(|| Arc::new(AtomicBool::new(false))),
))
}
fn cancel_completion_on_edit(&mut self) {
if let Some(pending) = self.pending_completion.get_mut().take() {
pending.store(true, Ordering::Relaxed);
}
} }
pub fn new() -> Self { pub fn new() -> Self {
@@ -326,7 +356,7 @@ impl InputBuffer {
} }
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
self.selected_invocations.clear(); self.selected_invocations.clear();
self.atoms.clear(); self.atoms.clear();
self.cursor = 0; self.cursor = 0;
@@ -349,7 +379,7 @@ impl InputBuffer {
/// by [`submit_segments`](Self::submit_segments), preserving typed chips /// by [`submit_segments`](Self::submit_segments), preserving typed chips
/// and placing the cursor at the end of the restored input. /// and placing the cursor at the end of the restored input.
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) { pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
self.bump_revision(); self.cancel_completion_on_edit();
self.selected_invocations.clear(); self.selected_invocations.clear();
self.atoms.clear(); self.atoms.clear();
for segment in segments { for segment in segments {
@@ -400,7 +430,7 @@ impl InputBuffer {
} }
pub fn insert_char(&mut self, c: char) { pub fn insert_char(&mut self, c: char) {
self.bump_revision(); self.cancel_completion_on_edit();
self.adjust_selection(self.cursor, self.cursor, 1); self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::Char(c)); self.atoms.insert(self.cursor, Atom::Char(c));
self.cursor += 1; self.cursor += 1;
@@ -445,7 +475,7 @@ impl InputBuffer {
} }
pub fn insert_paste(&mut self, content: String) { pub fn insert_paste(&mut self, content: String) {
self.bump_revision(); self.cancel_completion_on_edit();
let measurement = measure_paste(&content); let measurement = measure_paste(&content);
if measurement.presentation() == PastePresentation::Text { if measurement.presentation() == PastePresentation::Text {
self.insert_str(&content); self.insert_str(&content);
@@ -469,7 +499,7 @@ impl InputBuffer {
#[cfg(test)] #[cfg(test)]
pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) { pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) {
self.bump_revision(); self.cancel_completion_on_edit();
self.adjust_selection(self.cursor, self.cursor, 1); self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::UploadedFile(file)); self.atoms.insert(self.cursor, Atom::UploadedFile(file));
self.cursor += 1; self.cursor += 1;
@@ -493,7 +523,7 @@ impl InputBuffer {
start: usize, start: usize,
descriptor: protocol::FeatureInvocationDescriptor, descriptor: protocol::FeatureInvocationDescriptor,
) { ) {
self.bump_revision(); self.cancel_completion_on_edit();
self.selected_invocations self.selected_invocations
.retain(|selection| selection.start != start); .retain(|selection| selection.start != start);
self.selected_invocations.push(SelectedInvocation { self.selected_invocations.push(SelectedInvocation {
@@ -508,7 +538,7 @@ impl InputBuffer {
} }
pub fn retry_attachment_stage(&mut self, id: &str) { pub fn retry_attachment_stage(&mut self, id: &str) {
self.bump_revision(); self.cancel_completion_on_edit();
for atom in &mut self.atoms { for atom in &mut self.atoms {
if let Atom::AttachmentStage { if let Atom::AttachmentStage {
id: stage_id, id: stage_id,
@@ -563,7 +593,7 @@ impl InputBuffer {
} }
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) { fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
self.bump_revision(); self.cancel_completion_on_edit();
let inserted = atoms.len(); let inserted = atoms.len();
self.adjust_selection(start, end, inserted); self.adjust_selection(start, end, inserted);
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>(); let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
@@ -669,7 +699,7 @@ impl InputBuffer {
id: &str, id: &str,
file: Option<protocol::UploadedFileRef>, file: Option<protocol::UploadedFileRef>,
) -> bool { ) -> bool {
self.bump_revision(); self.cancel_completion_on_edit();
let Some(index) = self.atoms.iter().position( let Some(index) = self.atoms.iter().position(
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id), |atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
) else { ) else {
@@ -965,12 +995,12 @@ impl InputBuffer {
} }
pub fn move_left(&mut self) { pub fn move_left(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
self.cursor = self.cursor.saturating_sub(1); self.cursor = self.cursor.saturating_sub(1);
} }
pub fn move_right(&mut self) { pub fn move_right(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
self.cursor = (self.cursor + 1).min(self.atoms.len()); self.cursor = (self.cursor + 1).min(self.atoms.len());
} }
@@ -978,7 +1008,7 @@ impl InputBuffer {
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to /// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block. /// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
pub fn move_word_left(&mut self) { pub fn move_word_left(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep { while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
self.cursor -= 1; self.cursor -= 1;
} }
@@ -993,7 +1023,7 @@ impl InputBuffer {
/// Move forward by one word. Mirror of [`move_word_left`]. /// Move forward by one word. Mirror of [`move_word_left`].
pub fn move_word_right(&mut self) { pub fn move_word_right(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
while self.cursor < self.atoms.len() while self.cursor < self.atoms.len()
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep && atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
{ {
@@ -1009,7 +1039,7 @@ impl InputBuffer {
} }
pub fn move_start(&mut self) { pub fn move_start(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
self.cursor = 0; self.cursor = 0;
} }
@@ -1058,13 +1088,13 @@ impl InputBuffer {
} }
pub fn move_home(&mut self) { pub fn move_home(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
let (ranges, line, _) = self.logical_line_and_col(); let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].0; self.cursor = ranges[line].0;
} }
pub fn move_end(&mut self) { pub fn move_end(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
let (ranges, line, _) = self.logical_line_and_col(); let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].1; self.cursor = ranges[line].1;
} }
@@ -1072,7 +1102,7 @@ impl InputBuffer {
/// Move one logical line up, preserving column (atom count from /// Move one logical line up, preserving column (atom count from
/// current line start). No-op if already on the first line. /// current line start). No-op if already on the first line.
pub fn move_up(&mut self) { pub fn move_up(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
let (ranges, line, col) = self.logical_line_and_col(); let (ranges, line, col) = self.logical_line_and_col();
if line == 0 { if line == 0 {
return; return;
@@ -1083,7 +1113,7 @@ impl InputBuffer {
/// Move one logical line down, preserving column. /// Move one logical line down, preserving column.
pub fn move_down(&mut self) { pub fn move_down(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
let (ranges, line, col) = self.logical_line_and_col(); let (ranges, line, col) = self.logical_line_and_col();
let Some(&(start, end)) = ranges.get(line + 1) else { let Some(&(start, end)) = ranges.get(line + 1) else {
return; return;
-2
View File
@@ -2187,7 +2187,6 @@ mod tests {
fn set_pending_submission(app: &mut App, id: &str) { fn set_pending_submission(app: &mut App, id: &str) {
app.handle_worker_event(Event::PendingSubmissionsChanged { app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot { pending: protocol::PendingSubmissionsSnapshot {
revision: 1,
notification_count: 0, notification_count: 0,
notification_previews: vec![], notification_previews: vec![],
head_id: Some(id.into()), head_id: Some(id.into()),
@@ -2320,7 +2319,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(protocol::Event::TextDelta { event: Box::new(protocol::Event::TextDelta {
text: "child transcript".into(), text: "child transcript".into(),
}), }),
+3 -2
View File
@@ -148,8 +148,9 @@ impl CheckoutSearchRequest {
if !std::path::Path::new(self.operation.path().as_str()) if !std::path::Path::new(self.operation.path().as_str())
.starts_with(self.output_root.as_str()) .starts_with(self.output_root.as_str())
{ {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
"checkout search must stay beneath its output root".into(), crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
"checkout search must stay beneath its output root",
)); ));
} }
if self.scope_layers.len() > 16 if self.scope_layers.len() > 16
+14 -6
View File
@@ -212,17 +212,17 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
}; };
assert_eq!(result.entries[0].path, path("keep/yes.txt")); assert_eq!(result.entries[0].path, path("keep/yes.txt"));
// A second actual scoped wrapper has its own cwd and nonrecursive layer. // A second actual scoped wrapper has its own cwd and nonrecursive layer.
// Logical rules avoid requesting resolved authorization from a wrapper // Default resolved authorization must survive every wrapper.
// that conservatively does not expose host-side resolution authority.
std::fs::create_dir(root.path().join("child/keep/deep")).unwrap(); std::fs::create_dir(root.path().join("child/keep/deep")).unwrap();
std::fs::write( std::fs::write(
root.path().join("child/keep/deep/secret"), root.path().join("child/keep/deep/secret"),
"needle hidden nested", "needle hidden nested",
) )
.unwrap(); .unwrap();
let mut own_rule = rule("keep", false); let second = stacked
own_rule.symlink_policy = manifest::SymlinkPolicy::Logical; .scope(scope(vec![rule("keep", false)], "keep"))
let second = stacked.scope(scope(vec![own_rule], "keep")).await.unwrap(); .await
.unwrap();
let result = second let result = second
.tool_session() .tool_session()
.grep(grep(GrepOutputMode::Content)) .grep(grep(GrepOutputMode::Content))
@@ -751,7 +751,15 @@ async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_e
))) )))
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(error, WorkdirError::OutOfScope(_))); let classification = match &error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert!(matches!(classification, WorkdirError::OutOfScope(_)));
assert_ne!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
} }
for (relative, hidden) in [ for (relative, hidden) in [
(".ignore", "root-ignore-visible.txt"), (".ignore", "root-ignore-visible.txt"),
+44 -3
View File
@@ -497,18 +497,24 @@ impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ExternalWorkdirOperationError { pub struct ExternalWorkdirOperationError {
code: crate::http::WorkdirTransportErrorCode, code: crate::http::WorkdirTransportErrorCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
denial_reason: Option<crate::WorkdirDenialReason>,
} }
impl ExternalWorkdirOperationError { impl ExternalWorkdirOperationError {
/// Collapse provider failures to a closed error code. Provider-authored /// Keep only closed error codes and typed refusal reasons. Provider-authored
/// diagnostics never cross the External Workdir trust boundary. /// text never crosses the External Workdir trust boundary.
pub fn from_transport_error(error: WorkdirTransportError) -> Self { pub fn from_transport_error(error: WorkdirTransportError) -> Self {
Self { code: error.code } Self {
code: error.code,
denial_reason: error.denial_reason,
}
} }
pub fn into_transport_error(self) -> WorkdirTransportError { pub fn into_transport_error(self) -> WorkdirTransportError {
WorkdirTransportError { WorkdirTransportError {
code: self.code, code: self.code,
denial_reason: self.denial_reason,
message: format!("External Workdir provider reported {}", self.code.as_str()), message: format!("External Workdir provider reported {}", self.code.as_str()),
} }
} }
@@ -719,10 +725,45 @@ mod tests {
} }
} }
#[test]
fn external_error_wire_forwards_allowlisted_reasons_without_provider_text() {
use crate::WorkdirDenialReason as Reason;
use crate::http::WorkdirTransportErrorCode as Code;
for reason in [
Some(Reason::ExternalScopeSymlink),
Some(Reason::OsPermissionDenied),
None,
] {
let external =
ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
code: Code::Denied,
message: "/home/operator/private provider-secret".repeat(4096),
denial_reason: reason,
});
let wire = serde_json::to_string(&external).unwrap();
assert!(wire.len() < 128);
assert_no_provider_authority(&serde_json::from_str(&wire).unwrap());
let decoded: ExternalWorkdirOperationError = serde_json::from_str(&wire).unwrap();
let error = decoded.into_transport_error().into_workdir_error();
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(forwarded.code, Code::Denied);
assert_eq!(forwarded.denial_reason, reason);
assert_eq!(forwarded.message, "Workdir operation was denied");
}
let old: ExternalWorkdirOperationError =
serde_json::from_str(r#"{"code":"denied"}"#).unwrap();
assert_eq!(old.into_transport_error().denial_reason, None);
for reason in ["/home/operator/private", &"provider-secret".repeat(4096)] {
let input = serde_json::json!({"code":"denied", "denial_reason":reason});
assert!(serde_json::from_value::<ExternalWorkdirOperationError>(input).is_err());
}
}
#[test] #[test]
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() { fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError { let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
code: crate::http::WorkdirTransportErrorCode::Internal, code: crate::http::WorkdirTransportErrorCode::Internal,
denial_reason: None,
message: "/home/operator/private provider-secret\ncontrol".to_string(), message: "/home/operator/private provider-secret\ncontrol".to_string(),
}); });
let frame = ExternalWorkdirProviderFrame::current( let frame = ExternalWorkdirProviderFrame::current(
+31 -3
View File
@@ -357,9 +357,17 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
) )
.await .await
.unwrap_err(); .unwrap_err();
let classification = match &error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert_ne!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
assert!( assert!(
matches!( matches!(
error, classification,
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_) WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
), ),
"unexpected error for {path}: {error:?}" "unexpected error for {path}: {error:?}"
@@ -375,6 +383,10 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
BoundedReadLimits::new(4096, 1024).unwrap(), BoundedReadLimits::new(4096, 1024).unwrap(),
) )
.unwrap_err(); .unwrap_err();
assert_eq!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::ExternalRootSymlink)
);
assert!(matches!(error, WorkdirError::Denied(_))); assert!(matches!(error, WorkdirError::Denied(_)));
} }
@@ -408,9 +420,17 @@ async fn external_read_write_provider_rejects_symlink_mutation_escape() {
) )
.await .await
.unwrap_err(); .unwrap_err();
let classification = match &error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert_ne!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
assert!( assert!(
matches!( matches!(
error, classification,
WorkdirError::SymlinkOutOfScope { .. } WorkdirError::SymlinkOutOfScope { .. }
| WorkdirError::OutOfScope(_) | WorkdirError::OutOfScope(_)
| WorkdirError::Denied(_) | WorkdirError::Denied(_)
@@ -559,7 +579,15 @@ async fn external_grep_honors_gitignore_above_a_nested_search_root() {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(symlink_error, WorkdirError::OutOfScope(_))); let classification = match &symlink_error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert!(matches!(classification, WorkdirError::OutOfScope(_)));
assert_ne!(
symlink_error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+44 -5
View File
@@ -269,6 +269,9 @@ impl WorkdirTransportErrorCode {
pub struct WorkdirTransportError { pub struct WorkdirTransportError {
pub code: WorkdirTransportErrorCode, pub code: WorkdirTransportErrorCode,
pub message: String, pub message: String,
/// Internal provider diagnostics. Public response boundaries may omit this.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denial_reason: Option<crate::WorkdirDenialReason>,
} }
impl WorkdirTransportError { impl WorkdirTransportError {
@@ -276,6 +279,11 @@ impl WorkdirTransportError {
pub fn from_workdir_error(error: &WorkdirError) -> Self { pub fn from_workdir_error(error: &WorkdirError) -> Self {
use WorkdirTransportErrorCode as Code; use WorkdirTransportErrorCode as Code;
let (code, message) = match error { let (code, message) = match error {
WorkdirError::DenialContext { reason, source } => {
let mut transport = Self::from_workdir_error(source);
transport.denial_reason = Some(*reason);
return transport;
}
WorkdirError::OutcomeUnknown(_) => ( WorkdirError::OutcomeUnknown(_) => (
Code::OutcomeUnknown, Code::OutcomeUnknown,
"Workdir operation may have effects; inspect before retrying", "Workdir operation may have effects; inspect before retrying",
@@ -289,6 +297,7 @@ impl WorkdirTransportError {
return Self { return Self {
code: Code::Unsupported, code: Code::Unsupported,
message: format!("Workdir capability {capability:?} is not available"), message: format!("Workdir capability {capability:?} is not available"),
denial_reason: None,
}; };
} }
WorkdirError::UnsupportedOperation(_) => { WorkdirError::UnsupportedOperation(_) => {
@@ -346,12 +355,13 @@ impl WorkdirTransportError {
Self { Self {
code, code,
message: message.to_string(), message: message.to_string(),
denial_reason: error.denial_reason(),
} }
} }
pub fn into_workdir_error(self) -> WorkdirError { pub fn into_workdir_error(self) -> WorkdirError {
use WorkdirTransportErrorCode as Code; use WorkdirTransportErrorCode as Code;
match self.code { let error = match self.code {
Code::NotFound => WorkdirError::NotFound("<remote>".into()), Code::NotFound => WorkdirError::NotFound("<remote>".into()),
Code::Conflict => WorkdirError::Conflict(self.message), Code::Conflict => WorkdirError::Conflict(self.message),
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown( Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
@@ -359,7 +369,11 @@ impl WorkdirTransportError {
), ),
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message), Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message), Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
Code::Denied => WorkdirError::Denied(self.message), Code::Denied => WorkdirError::Denied(crate::WorkdirDenial {
reason: self.denial_reason,
// Provider-authored text is not trusted diagnostic data.
message: "Workdir operation was denied".into(),
}),
Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()), Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()),
Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope { Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
path: "<remote>".into(), path: "<remote>".into(),
@@ -390,8 +404,19 @@ impl WorkdirTransportError {
}, },
Code::Transport => WorkdirError::Transport(self.message), Code::Transport => WorkdirError::Transport(self.message),
Code::Internal => WorkdirError::OperationFailed, Code::Internal => WorkdirError::OperationFailed,
};
// Preserve any supplied reason independently of the public classification.
// Without a reason, retain the existing classification's deliberate fallback.
if let Some(reason) = self.denial_reason {
if error.denial_reason() != Some(reason) {
return WorkdirError::DenialContext {
reason,
source: Box::new(error),
};
} }
} }
error
}
} }
#[cfg(feature = "http-client")] #[cfg(feature = "http-client")]
@@ -691,10 +716,23 @@ mod client {
"mismatched response after checkout mutation".into(), "mismatched response after checkout mutation".into(),
)), )),
Ok(_) => Err(Self::mismatch("checkout_execute")), Ok(_) => Err(Self::mismatch("checkout_execute")),
Err(WorkdirError::Unavailable(_) | WorkdirError::Transport(_)) if mutation => { Err(error)
Err(WorkdirError::OutcomeUnknown( if mutation
&& matches!(
error.classification_source(),
WorkdirError::Unavailable(_) | WorkdirError::Transport(_)
) =>
{
let outcome = WorkdirError::OutcomeUnknown(
"checkout transport failed; effects may have occurred".into(), "checkout transport failed; effects may have occurred".into(),
)) );
Err(match error.denial_reason() {
Some(reason) => WorkdirError::DenialContext {
reason,
source: Box::new(outcome),
},
None => outcome,
})
} }
Err(error) => Err(error), Err(error) => Err(error),
} }
@@ -949,6 +987,7 @@ mod tests {
] { ] {
let transport = WorkdirTransportError { let transport = WorkdirTransportError {
code, code,
denial_reason: None,
message: if code == WorkdirTransportErrorCode::Conflict { message: if code == WorkdirTransportErrorCode::Conflict {
"The target file's content or existence changed since it was last observed; read the file again before retrying" "The target file's content or existence changed since it was last observed; read the file again before retrying"
.to_string() .to_string()
+154 -3
View File
@@ -211,7 +211,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
}) { }) {
Ok(()) Ok(())
} else { } else {
Err(WorkdirError::Denied( Err(WorkdirError::denied(
WorkdirDenialReason::ScopeResolutionUnavailable,
"Workdir provider cannot establish resolved scope authority".to_string(), "Workdir provider cannot establish resolved scope authority".to_string(),
)) ))
} }
@@ -221,7 +222,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
&self, &self,
_request: WorkdirScopeOverlapRequest, _request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> { ) -> Result<bool, WorkdirError> {
Err(WorkdirError::Denied( Err(WorkdirError::denied(
WorkdirDenialReason::ScopeComparisonUnavailable,
"Workdir provider cannot compare resolved scope authority".to_string(), "Workdir provider cannot compare resolved scope authority".to_string(),
)) ))
} }
@@ -292,10 +294,125 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>; pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>;
/// Closed diagnostic vocabulary: no request text, paths or secrets.
/// A reason describes a refusing branch, not an authorization grant.
#[derive(
Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum WorkdirDenialReason {
ScopeResolutionUnavailable,
ScopeComparisonUnavailable,
ScopedCapabilityDenied,
LogicalScopeExceeded,
InvalidScopedPath,
ChildWriteLeaseConflict,
EmptyScope,
ParentCapabilityDenied,
CommandRequiresWritableScope,
ParentScopeExceeded,
CwdOutsideReadableScope,
ReadOnlySession,
ExternalRootSymlink,
ExternalRootChanged,
ExternalRootMoved,
ExternalScopeSymlink,
AttachmentScopeExceeded,
DelegatedScopeExceeded,
CheckoutOutputRootExceeded,
ProviderRootExceeded,
ProviderSymlinkDenied,
CheckoutTargetDenied,
CheckoutCreateParentDenied,
CheckoutSearchPathDenied,
CheckoutEnumerationDenied,
PathOutOfScope,
SymlinkTargetOutOfScope,
PathReadOnly,
OsPermissionDenied,
/// PermissionDenied without a typed provider origin or an OS error code.
/// In particular, do not mislabel lower-layer synthetic refusals as OS errors.
UnclassifiedPermissionDenied,
}
impl WorkdirDenialReason {
/// Stable allowlisted label for internal diagnostics, matching the wire value.
pub const fn as_str(self) -> &'static str {
match self {
Self::ScopeResolutionUnavailable => "scope_resolution_unavailable",
Self::ScopeComparisonUnavailable => "scope_comparison_unavailable",
Self::ScopedCapabilityDenied => "scoped_capability_denied",
Self::LogicalScopeExceeded => "logical_scope_exceeded",
Self::InvalidScopedPath => "invalid_scoped_path",
Self::ChildWriteLeaseConflict => "child_write_lease_conflict",
Self::EmptyScope => "empty_scope",
Self::ParentCapabilityDenied => "parent_capability_denied",
Self::CommandRequiresWritableScope => "command_requires_writable_scope",
Self::ParentScopeExceeded => "parent_scope_exceeded",
Self::CwdOutsideReadableScope => "cwd_outside_readable_scope",
Self::ReadOnlySession => "read_only_session",
Self::ExternalRootSymlink => "external_root_symlink",
Self::ExternalRootChanged => "external_root_changed",
Self::ExternalRootMoved => "external_root_moved",
Self::ExternalScopeSymlink => "external_scope_symlink",
Self::AttachmentScopeExceeded => "attachment_scope_exceeded",
Self::DelegatedScopeExceeded => "delegated_scope_exceeded",
Self::CheckoutOutputRootExceeded => "checkout_output_root_exceeded",
Self::ProviderRootExceeded => "provider_root_exceeded",
Self::ProviderSymlinkDenied => "provider_symlink_denied",
Self::CheckoutTargetDenied => "checkout_target_denied",
Self::CheckoutCreateParentDenied => "checkout_create_parent_denied",
Self::CheckoutSearchPathDenied => "checkout_search_path_denied",
Self::CheckoutEnumerationDenied => "checkout_enumeration_denied",
Self::PathOutOfScope => "path_out_of_scope",
Self::SymlinkTargetOutOfScope => "symlink_target_out_of_scope",
Self::PathReadOnly => "path_read_only",
Self::OsPermissionDenied => "os_permission_denied",
Self::UnclassifiedPermissionDenied => "unclassified_permission_denied",
}
}
}
/// Original local messages remain available, but are not diagnostic wire data.
/// Missing reasons from older providers remain explicitly unknown.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkdirDenial {
pub reason: Option<WorkdirDenialReason>,
pub message: String,
}
impl std::fmt::Display for WorkdirDenial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl From<String> for WorkdirDenial {
fn from(message: String) -> Self {
Self {
reason: None,
message,
}
}
}
impl From<&str> for WorkdirDenial {
fn from(message: &str) -> Self {
message.to_string().into()
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum WorkdirError { pub enum WorkdirError {
#[error("Workdir operation denied: {0}")] #[error("Workdir operation denied: {0}")]
Denied(String), Denied(WorkdirDenial),
/// Retain the source's existing classification and display while attaching
/// a reason (including checkout's historical PermissionDenied mapping).
#[error("{source}")]
DenialContext {
reason: WorkdirDenialReason,
#[source]
source: Box<WorkdirError>,
},
#[error("Workdir session is closed")] #[error("Workdir session is closed")]
SessionClosed, SessionClosed,
@@ -398,6 +515,40 @@ pub enum WorkdirError {
} }
impl WorkdirError { impl WorkdirError {
/// The error used for operational classification, ignoring only diagnostic
/// context (including nested context). Keep the original error for display
/// and `denial_reason`: enrichment must not alter behavior or retry policy.
pub fn classification_source(&self) -> &Self {
let mut error = self;
while let Self::DenialContext { source, .. } = error {
error = source;
}
error
}
pub fn denied(reason: WorkdirDenialReason, message: impl Into<String>) -> Self {
Self::Denied(WorkdirDenial {
reason: Some(reason),
message: message.into(),
})
}
/// Safe diagnostic data only; never infer a reason from arbitrary messages.
pub fn denial_reason(&self) -> Option<WorkdirDenialReason> {
use WorkdirDenialReason as Reason;
match self {
Self::Denied(denial) => denial.reason,
Self::DenialContext { reason, .. } => Some(*reason),
Self::OutOfScope(_) => Some(Reason::PathOutOfScope),
Self::SymlinkOutOfScope { .. } => Some(Reason::SymlinkTargetOutOfScope),
Self::ReadOnly(_) => Some(Reason::PathReadOnly),
Self::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied => {
Some(local::io_denial_reason(source))
}
_ => None,
}
}
pub(crate) fn io(path: &Path, source: std::io::Error) -> Self { pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
Self::Io { Self::Io {
path: path.to_path_buf(), path: path.to_path_buf(),
+308 -21
View File
@@ -213,6 +213,51 @@ struct ScopeAccess {
operation_guard: Option<OperationGuard>, operation_guard: Option<OperationGuard>,
} }
/// Policy refusals carried through FsAccessPolicy's I/O interface are not OS
/// PermissionDenied. This marker contains only fixed text and a closed reason.
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
struct PolicyPermissionDenied {
reason: crate::WorkdirDenialReason,
message: &'static str,
}
fn policy_permission_denied(
reason: crate::WorkdirDenialReason,
message: &'static str,
) -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
PolicyPermissionDenied { reason, message },
)
}
pub(crate) fn io_denial_reason(error: &std::io::Error) -> crate::WorkdirDenialReason {
use crate::WorkdirDenialReason as Reason;
if let Some(source) = error.get_ref() {
if let Some(denial) = source.downcast_ref::<PolicyPermissionDenied>() {
return denial.reason;
}
if let Some(denial) = source.downcast_ref::<fs_operation::FsDenialReason>() {
return match denial {
fs_operation::FsDenialReason::ProviderRootExceeded => Reason::ProviderRootExceeded,
fs_operation::FsDenialReason::ProviderSymlinkDenied => {
Reason::ProviderSymlinkDenied
}
fs_operation::FsDenialReason::CheckoutTargetDenied => Reason::CheckoutTargetDenied,
fs_operation::FsDenialReason::CheckoutCreateParentDenied => {
Reason::CheckoutCreateParentDenied
}
};
}
}
if error.raw_os_error().is_some() {
Reason::OsPermissionDenied
} else {
Reason::UnclassifiedPermissionDenied
}
}
impl ScopeAccess { impl ScopeAccess {
fn new( fn new(
scope: Arc<Scope>, scope: Arc<Scope>,
@@ -238,8 +283,8 @@ impl ScopeAccess {
) )
})?; })?;
let relative = path.strip_prefix(&self.root).map_err(|_| { let relative = path.strip_prefix(&self.root).map_err(|_| {
std::io::Error::new( policy_permission_denied(
std::io::ErrorKind::PermissionDenied, crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root", "path is outside provider root",
) )
})?; })?;
@@ -288,8 +333,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
if logical.starts_with(&self.root) { if logical.starts_with(&self.root) {
Ok(logical.to_path_buf()) Ok(logical.to_path_buf())
} else { } else {
Err(std::io::Error::new( Err(policy_permission_denied(
std::io::ErrorKind::PermissionDenied, crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root", "path is outside provider root",
)) ))
} }
@@ -394,8 +439,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
) )
})?; })?;
let relative = resolved.strip_prefix(&self.root).map_err(|_| { let relative = resolved.strip_prefix(&self.root).map_err(|_| {
std::io::Error::new( policy_permission_denied(
std::io::ErrorKind::PermissionDenied, crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root", "path is outside provider root",
) )
})?; })?;
@@ -491,8 +536,8 @@ impl SearchAccess {
if self.read_allowed(logical, resolved) { if self.read_allowed(logical, resolved) {
Ok(()) Ok(())
} else { } else {
Err(std::io::Error::new( Err(policy_permission_denied(
std::io::ErrorKind::PermissionDenied, crate::WorkdirDenialReason::CheckoutSearchPathDenied,
"checkout search path denied", "checkout search path denied",
)) ))
} }
@@ -545,8 +590,8 @@ impl fs_operation::FsAccessPolicy for SearchAccess {
} }
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> { fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
if !self.enumeration_allowed(logical, resolved) { if !self.enumeration_allowed(logical, resolved) {
return Err(std::io::Error::new( return Err(policy_permission_denied(
std::io::ErrorKind::PermissionDenied, crate::WorkdirDenialReason::CheckoutEnumerationDenied,
"checkout directory enumeration denied", "checkout directory enumeration denied",
)); ));
} }
@@ -648,7 +693,8 @@ impl ExternalWorkdirRoot {
let selected_metadata = std::fs::symlink_metadata(directory) let selected_metadata = std::fs::symlink_metadata(directory)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?; .map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if selected_metadata.file_type().is_symlink() { if selected_metadata.file_type().is_symlink() {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ExternalRootSymlink,
"External Workdir root must not be a symbolic link".to_string(), "External Workdir root must not be a symbolic link".to_string(),
)); ));
} }
@@ -678,7 +724,8 @@ impl ExternalWorkdirRoot {
if (selected_metadata.dev(), selected_metadata.ino()) if (selected_metadata.dev(), selected_metadata.ino())
!= (opened_metadata.dev(), opened_metadata.ino()) != (opened_metadata.dev(), opened_metadata.ino())
{ {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ExternalRootChanged,
"External Workdir root changed while it was being pinned".to_string(), "External Workdir root changed while it was being pinned".to_string(),
)); ));
} }
@@ -691,7 +738,8 @@ impl ExternalWorkdirRoot {
let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd())) let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?; .map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if !path.is_absolute() || path.to_string_lossy().ends_with(" (deleted)") { if !path.is_absolute() || path.to_string_lossy().ends_with(" (deleted)") {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ExternalRootMoved,
"External Workdir root moved while it was being pinned".to_string(), "External Workdir root moved while it was being pinned".to_string(),
)); ));
} }
@@ -1334,7 +1382,8 @@ impl WorkdirSession for LocalWorkdirSession {
self.validate_operation_path(&request.path)?; self.validate_operation_path(&request.path)?;
let logical = self.inner.root.join(request.path.as_str()); let logical = self.inner.root.join(request.path.as_str());
if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() { if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ExternalScopeSymlink,
"External Workdir scope cannot traverse symbolic links".to_string(), "External Workdir scope cannot traverse symbolic links".to_string(),
)); ));
} }
@@ -1353,10 +1402,13 @@ impl WorkdirSession for LocalWorkdirSession {
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write), WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
}; };
if !parent_allows { if !parent_allows {
return Err(WorkdirError::Denied(format!( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::AttachmentScopeExceeded,
format!(
"Workdir path `{}` exceeds the provider attachment scope", "Workdir path `{}` exceeds the provider attachment scope",
request.path request.path
))); ),
));
} }
let allowed = request.rules.iter().any(|rule| { let allowed = request.rules.iter().any(|rule| {
if request.permission == WorkdirToolScopePermission::Write if request.permission == WorkdirToolScopePermission::Write
@@ -1383,10 +1435,13 @@ impl WorkdirSession for LocalWorkdirSession {
if allowed { if allowed {
Ok(()) Ok(())
} else { } else {
Err(WorkdirError::Denied(format!( Err(WorkdirError::denied(
crate::WorkdirDenialReason::DelegatedScopeExceeded,
format!(
"Workdir path `{}` is outside the provider-resolved delegated scope", "Workdir path `{}` is outside the provider-resolved delegated scope",
request.path request.path
))) ),
))
} }
} }
@@ -1432,7 +1487,12 @@ impl WorkdirSession for LocalWorkdirSession {
let logical = request.operation.path().clone(); let logical = request.operation.path().clone();
let relative = Path::new(logical.as_str()) let relative = Path::new(logical.as_str())
.strip_prefix(request.output_root.as_str()) .strip_prefix(request.output_root.as_str())
.map_err(|_| WorkdirError::Denied("checkout search escaped output root".into()))?; .map_err(|_| {
WorkdirError::denied(
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
"checkout search escaped output root",
)
})?;
*request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| { *request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| {
WorkdirError::InvalidPath("checkout search path is not UTF8".into()) WorkdirError::InvalidPath("checkout search path is not UTF8".into())
})?)?; })?)?;
@@ -1770,6 +1830,9 @@ impl WorkdirSession for LocalWorkdirSession {
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> { async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Command)?; self.ensure_capability(WorkdirSessionCapability::Command)?;
// Command admission, resource capture and registration must be atomic
// with terminal close, which drains commands and releases resources.
let _admission = self.inner.close_lock.lock().await;
self.ensure_open()?; self.ensure_open()?;
if let Some(spill_dir) = request.spill_dir.as_deref() if let Some(spill_dir) = request.spill_dir.as_deref()
&& !self && !self
@@ -1794,6 +1857,15 @@ impl WorkdirSession for LocalWorkdirSession {
let telemetry = self.inner.command_telemetry.clone(); let telemetry = self.inner.command_telemetry.clone();
let command_environment = self.inner.command_environment.clone(); let command_environment = self.inner.command_environment.clone();
let (cancel, cancel_rx) = watch::channel(false); let (cancel, cancel_rx) = watch::channel(false);
// A session drop only schedules command cancellation. Keep its Runtime
// resources (including cleanup exclusion) alive until the task actually
// finishes or its future is dropped, not just until the handle disappears.
let resources = self
.inner
.resources
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let output = run_command( let output = run_command(
cwd, cwd,
@@ -1802,6 +1874,7 @@ impl WorkdirSession for LocalWorkdirSession {
telemetry, telemetry,
command_environment, command_environment,
cancel_rx, cancel_rx,
resources,
) )
.await; .await;
let _ = completion_tx.send(true); let _ = completion_tx.send(true);
@@ -2075,7 +2148,10 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
WorkdirError::Io { source, .. } WorkdirError::Io { source, .. }
if source.kind() == std::io::ErrorKind::PermissionDenied => if source.kind() == std::io::ErrorKind::PermissionDenied =>
{ {
WorkdirError::OutOfScope(path) WorkdirError::DenialContext {
reason: io_denial_reason(&source),
source: Box::new(WorkdirError::OutOfScope(path)),
}
} }
WorkdirError::Io { source, .. } => WorkdirError::Unavailable(format!( WorkdirError::Io { source, .. } => WorkdirError::Unavailable(format!(
"I/O operation failed for {logical}: {}", "I/O operation failed for {logical}: {}",
@@ -2131,6 +2207,7 @@ async fn run_command(
telemetry: CommandTelemetry, telemetry: CommandTelemetry,
command_environment: BTreeMap<String, String>, command_environment: BTreeMap<String, String>,
mut cancel: watch::Receiver<bool>, mut cancel: watch::Receiver<bool>,
resources: Vec<Arc<dyn WorkdirSessionResource>>,
) -> Result<CommandOutput, WorkdirError> { ) -> Result<CommandOutput, WorkdirError> {
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
let stderr = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; let stderr = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
@@ -2250,7 +2327,7 @@ async fn run_command(
let stdout_path = stdout_path.to_path_buf(); let stdout_path = stdout_path.to_path_buf();
let stderr_path = stderr_path.to_path_buf(); let stderr_path = stderr_path.to_path_buf();
Some( Some(
tokio::task::spawn_blocking(move || { spawn_resource_owned_blocking(resources.clone(), move || {
persist_command_output(&stdout_path, &stderr_path, &spill_dir) persist_command_output(&stdout_path, &stderr_path, &spill_dir)
}) })
.await .await
@@ -2272,6 +2349,18 @@ async fn run_command(
}) })
} }
// Blocking spills cannot be aborted with their async waiter. Transfer resource
// ownership into the actual filesystem task so cleanup waits for its completion.
fn spawn_resource_owned_blocking<T: Send + 'static>(
resources: Vec<Arc<dyn WorkdirSessionResource>>,
effect: impl FnOnce() -> T + Send + 'static,
) -> JoinHandle<T> {
tokio::task::spawn_blocking(move || {
let _resources = resources;
effect()
})
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct CommandOutputDecoder { struct CommandOutputDecoder {
read_offset: u64, read_offset: u64,
@@ -2561,6 +2650,58 @@ mod tests {
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
#[test]
fn checkout_permission_mapping_distinguishes_provider_policy_from_os_without_reclassifying() {
use crate::WorkdirDenialReason as Reason;
use crate::http::{WorkdirTransportError, WorkdirTransportErrorCode as Code};
use fs_operation::FsAccessPolicy;
let root = tempfile::tempdir().unwrap();
let access = ScopeAccess::new(
Arc::new(Scope::writable(root.path()).unwrap()),
root.path(),
None,
true,
None,
);
// Exercise the actual policy boundary, not a string-based stand-in.
let policy = access
.resolve_access_path(Path::new("/outside/private-secret"))
.unwrap_err();
let os = std::io::Error::from_raw_os_error(libc::EACCES);
for (source, reason) in [
(policy, Reason::ProviderRootExceeded),
(os, Reason::OsPermissionDenied),
] {
let error = sanitize_error(
WorkdirError::io(Path::new("/host/private-secret"), source),
&WorkdirPath::new("file").unwrap(),
);
assert_eq!(error.denial_reason(), Some(reason));
assert_eq!(error.to_string(), "path is outside allowed scope: file");
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, Code::OutOfScope);
assert_eq!(transport.message, "Workdir path is out of scope");
assert_eq!(transport.denial_reason, Some(reason));
assert_eq!(
WorkdirTransportError::from_workdir_error(&transport.clone().into_workdir_error()),
transport
);
assert!(
!serde_json::to_string(&transport)
.unwrap()
.contains("private-secret")
);
}
// Ordinary OS I/O remains the existing generic denied/403 classification.
let error = WorkdirError::io(
Path::new("/host/private-secret"),
std::io::Error::from_raw_os_error(libc::EACCES),
);
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, Code::Denied);
assert_eq!(transport.denial_reason, Some(Reason::OsPermissionDenied));
}
#[test] #[test]
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() { fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
let path = WorkdirPath::new("missing").unwrap(); let path = WorkdirPath::new("missing").unwrap();
@@ -4065,6 +4206,152 @@ mod tests {
assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None))); assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None)));
} }
#[tokio::test(flavor = "current_thread")]
async fn command_admission_serializes_with_close_before_capturing_resources() {
let dir = TempDir::new().unwrap();
let session = Arc::new(make_fs(&dir));
let held = session.inner.close_lock.lock().await;
let (closing, close_entered) = tokio::sync::oneshot::channel();
let close_session = session.clone();
let close = tokio::spawn(async move {
closing.send(()).unwrap();
WorkdirSession::close(close_session.as_ref()).await
});
close_entered.await.unwrap();
let (starting, start_entered) = tokio::sync::oneshot::channel();
let start_session = session.clone();
let start = tokio::spawn(async move {
starting.send(()).unwrap();
WorkdirSession::start_command(
start_session.as_ref(),
CommandRequest {
command: "true".into(),
timeout_secs: 5,
output_limit: 1024,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
},
)
.await
});
start_entered.await.unwrap();
assert!(
!start.is_finished(),
"command started outside terminal-close admission"
);
drop(held);
tokio::time::timeout(Duration::from_secs(5), close)
.await
.unwrap()
.unwrap()
.unwrap();
assert!(matches!(
tokio::time::timeout(Duration::from_secs(5), start)
.await
.unwrap()
.unwrap(),
Err(WorkdirError::Unavailable(message)) if message.contains("is closed")
));
assert!(
session.command_snapshot().is_empty(),
"closed admission launched a command"
);
}
#[tokio::test]
async fn blocking_output_spill_retains_resources_after_waiter_is_cancelled() {
#[derive(Debug)]
struct Resource(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for Resource {
fn drop(&mut self) {
let _ = self.0.take().unwrap().send(());
}
}
let (released, mut release) = tokio::sync::oneshot::channel();
let resources: Vec<Arc<dyn WorkdirSessionResource>> =
vec![Arc::new(Resource(Some(released)))];
let (entered, started) = tokio::sync::oneshot::channel();
let (finish, wait) = std::sync::mpsc::channel();
// Exercise the resource-transfer policy at the same blocking boundary
// used by output persistence, without filesystem timing assumptions.
let waiter = tokio::spawn(async move {
spawn_resource_owned_blocking(resources, move || {
entered.send(()).unwrap();
wait.recv_timeout(Duration::from_secs(5)).unwrap();
})
.await
.unwrap();
});
tokio::time::timeout(Duration::from_secs(5), started)
.await
.unwrap()
.unwrap();
waiter.abort();
assert!(waiter.await.unwrap_err().is_cancelled());
assert!(
matches!(
release.try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
),
"blocking spill lost its resources when the waiter was cancelled"
);
finish.send(()).unwrap();
tokio::time::timeout(Duration::from_secs(5), release)
.await
.unwrap()
.unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn session_drop_retains_resources_until_command_task_cancellation_completes() {
#[derive(Debug)]
struct Resource(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for Resource {
fn drop(&mut self) {
let _ = self.0.take().unwrap().send(());
}
}
let dir = TempDir::new().unwrap();
let (released, mut release) = tokio::sync::oneshot::channel();
let session = LocalWorkdirSession::materialized_bound_with_environment(
Workdir::new("command-resource-session"),
dir.path().to_path_buf(),
dir.path().to_path_buf(),
SharedScope::new(Scope::writable(dir.path()).unwrap()),
WorkdirSessionCapabilities::ALL,
BTreeMap::new(),
vec![Arc::new(Resource(Some(released)))],
);
WorkdirSession::start_command(
&session,
CommandRequest {
command: "true".into(),
timeout_secs: 5,
output_limit: 1024,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
},
)
.await
.unwrap();
// The sole executor has not polled the command task, so dropping the
// session schedules its abort but cannot yet complete cancellation.
drop(session);
assert!(
matches!(
release.try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
),
"resources released before the aborted command future was dropped"
);
tokio::time::timeout(Duration::from_secs(5), release)
.await
.unwrap()
.unwrap();
}
#[tokio::test] #[tokio::test]
async fn closing_session_releases_runtime_resources() { async fn closing_session_releases_runtime_resources() {
#[derive(Debug)] #[derive(Debug)]
+6 -1
View File
@@ -166,7 +166,12 @@ impl RoutedSessionState {
for handle in handles { for handle in handles {
match self.session.command_status(handle.clone()).await { match self.session.command_status(handle.clone()).await {
Ok(status) => self.mark_terminal(&handle, status), Ok(status) => self.mark_terminal(&handle, status),
Err(WorkdirError::UnknownCommand(_)) => { Err(error)
if matches!(
error.classification_source(),
WorkdirError::UnknownCommand(_)
) =>
{
self.active_commands self.active_commands
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
+143 -39
View File
@@ -240,8 +240,18 @@ impl WorkdirScopeLease {
.expect("scoped command set mutex poisoned") .expect("scoped command set mutex poisoned")
.remove(&command_id); .remove(&command_id);
} }
(Ok(()), Err(WorkdirError::UnknownCommand(_))) (cancel, Err(error))
| (Err(WorkdirError::UnknownCommand(_)), Err(WorkdirError::UnknownCommand(_))) => { if matches!(
error.classification_source(),
WorkdirError::UnknownCommand(_)
) && match &cancel {
Ok(()) => true,
Err(error) => matches!(
error.classification_source(),
WorkdirError::UnknownCommand(_)
),
} =>
{
self.broker.authority.publish_terminal_if_missing( self.broker.authority.publish_terminal_if_missing(
&command_id, &command_id,
CommandStatus::Cancelled, CommandStatus::Cancelled,
@@ -595,9 +605,10 @@ impl ScopedWorkdirSession {
if self.capabilities.supports(required) { if self.capabilities.supports(required) {
Ok(()) Ok(())
} else { } else {
Err(WorkdirError::Denied(format!( Err(WorkdirError::denied(
"scoped Workdir tools do not permit {operation}" crate::WorkdirDenialReason::ScopedCapabilityDenied,
))) format!("scoped Workdir tools do not permit {operation}"),
))
} }
} }
@@ -612,9 +623,12 @@ impl ScopedWorkdirSession {
.iter() .iter()
.any(|rule| rule_allows_path(rule, path, permission)) .any(|rule| rule_allows_path(rule, path, permission))
{ {
return Err(WorkdirError::Denied(format!( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::LogicalScopeExceeded,
format!(
"logical workdir path `{path}` is outside the scoped {permission:?} scope" "logical workdir path `{path}` is outside the scoped {permission:?} scope"
))); ),
));
} }
} }
Ok(()) Ok(())
@@ -626,9 +640,17 @@ impl ScopedWorkdirSession {
} }
let joined = Path::new(self.cwd.as_str()).join(path.as_str()); let joined = Path::new(self.cwd.as_str()).join(path.as_str());
let joined = joined.to_str().ok_or_else(|| { let joined = joined.to_str().ok_or_else(|| {
WorkdirError::Denied("logical Workdir path is not valid UTF-8".into()) WorkdirError::denied(
crate::WorkdirDenialReason::InvalidScopedPath,
"logical Workdir path is not valid UTF-8",
)
})?; })?;
FsPath::new(joined).map_err(|error| WorkdirError::Denied(error.to_string())) FsPath::new(joined).map_err(|error| {
WorkdirError::denied(
crate::WorkdirDenialReason::InvalidScopedPath,
error.to_string(),
)
})
} }
fn ensure_read( fn ensure_read(
@@ -739,9 +761,10 @@ impl ScopedWorkdirSession {
}) })
.await? .await?
{ {
return Err(WorkdirError::Denied(format!( return Err(WorkdirError::denied(
"path `{path}` is leased to child Workdir tools" crate::WorkdirDenialReason::ChildWriteLeaseConflict,
))); format!("path `{path}` is leased to child Workdir tools"),
));
} }
} }
Ok(()) Ok(())
@@ -792,8 +815,9 @@ impl ScopedWorkdirSession {
) -> Result<WorkdirSessionCapabilities, WorkdirError> { ) -> Result<WorkdirSessionCapabilities, WorkdirError> {
self.ensure_active()?; self.ensure_active()?;
if rules.is_empty() { if rules.is_empty() {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
"workdir tool scope requires at least one logical scope rule".into(), crate::WorkdirDenialReason::EmptyScope,
"workdir tool scope requires at least one logical scope rule",
)); ));
} }
let writable = rules let writable = rules
@@ -804,22 +828,25 @@ impl ScopedWorkdirSession {
&& (!self.capabilities.supports(WorkdirSessionCapability::Write) && (!self.capabilities.supports(WorkdirSessionCapability::Write)
|| !self.capabilities.supports(WorkdirSessionCapability::Edit))) || !self.capabilities.supports(WorkdirSessionCapability::Edit)))
{ {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
"parent Workdir session cannot scope the requested capabilities".into(), crate::WorkdirDenialReason::ParentCapabilityDenied,
"parent Workdir session cannot scope the requested capabilities",
)); ));
} }
if command { if command {
if !writable { if !writable {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
"command execution requires a writable scoped path".into(), crate::WorkdirDenialReason::CommandRequiresWritableScope,
"command execution requires a writable scoped path",
)); ));
} }
if !self if !self
.capabilities .capabilities
.supports(WorkdirSessionCapability::Command) .supports(WorkdirSessionCapability::Command)
{ {
return Err(WorkdirError::Denied( return Err(WorkdirError::denied(
"parent Workdir session does not support Command".into(), crate::WorkdirDenialReason::ParentCapabilityDenied,
"parent Workdir session does not support Command",
)); ));
} }
} }
@@ -829,10 +856,13 @@ impl ScopedWorkdirSession {
.iter() .iter()
.any(|parent| rule_contains_rule(parent, requested)) .any(|parent| rule_contains_rule(parent, requested))
{ {
return Err(WorkdirError::Denied(format!( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ParentScopeExceeded,
format!(
"logical workdir scope `{}` exceeds the parent tool scope", "logical workdir scope `{}` exceeds the parent tool scope",
requested.target requested.target
))); ),
));
} }
} }
} }
@@ -866,10 +896,13 @@ impl ScopedWorkdirSession {
.iter() .iter()
.any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read)) .any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read))
{ {
return Err(WorkdirError::Denied(format!( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::CwdOutsideReadableScope,
format!(
"scoped tool cwd `{}` is outside the readable scope", "scoped tool cwd `{}` is outside the readable scope",
request.cwd request.cwd
))); ),
));
} }
self.ensure_scope_targets_are_authorized(&request.rules) self.ensure_scope_targets_are_authorized(&request.rules)
.await?; .await?;
@@ -913,10 +946,13 @@ impl ScopedWorkdirSession {
}) })
.await? .await?
{ {
return Err(WorkdirError::Denied(format!( return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
format!(
"scoped write path `{}` overlaps an active child scope after provider resolution", "scoped write path `{}` overlaps an active child scope after provider resolution",
requested.target requested.target
))); ),
));
} }
} }
} }
@@ -994,6 +1030,51 @@ impl WorkdirSession for ScopedWorkdirSession {
self.capabilities self.capabilities
} }
async fn authorize_scope_path(
&self,
mut request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
// Like filesystem operations, requests use this session's coordinates.
// Keep every wrapper's attenuation before asking the provider to resolve
// symlinks; forwarding the caller's rules alone would discard our scope.
for rule in &mut request.rules {
rule.target = self.resolve_path(&rule.target)?;
}
self.validate_scope(&request.rules, false)?;
request.path = self
.resolve_operation_path(&request.path, request.permission)
.await?;
match request.permission {
WorkdirToolScopePermission::Read => {
self.ensure_read(&request.path, WorkdirSessionCapability::Read)?;
}
WorkdirToolScopePermission::Write => {
self.ensure_write(&request.path, WorkdirSessionCapability::Write)?;
}
}
self.source.authorize_scope_path(request).await?;
self.ensure_active()
}
async fn scope_rules_overlap(
&self,
mut request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
// Comparing write rules is a read-only query, not a write authorization.
// In particular it must work while those paths are leased to children.
for rule in [&mut request.left, &mut request.right] {
rule.target = self
.resolve_operation_path(&rule.target, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&rule.target, WorkdirSessionCapability::Read)?;
}
let overlaps = self.source.scope_rules_overlap(request).await?;
self.ensure_active()?;
Ok(overlaps)
}
async fn checkout_search( async fn checkout_search(
&self, &self,
mut request: crate::CheckoutSearchRequest, mut request: crate::CheckoutSearchRequest,
@@ -1446,7 +1527,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
request: WorkdirScopeAuthorizationRequest, request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> { ) -> Result<(), WorkdirError> {
if request.permission == WorkdirToolScopePermission::Write { if request.permission == WorkdirToolScopePermission::Write {
return Err(WorkdirError::Denied("read-only workdir session".into())); return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
));
} }
self.inner.authorize_scope_path(request).await self.inner.authorize_scope_path(request).await
} }
@@ -1509,11 +1593,17 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
} }
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into())) Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
} }
async fn edit(&self, _request: EditRequest) -> Result<EditResult, WorkdirError> { async fn edit(&self, _request: EditRequest) -> Result<EditResult, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into())) Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
} }
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> { async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
@@ -1529,22 +1619,34 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
} }
async fn start_command(&self, _request: CommandRequest) -> Result<CommandHandle, WorkdirError> { async fn start_command(&self, _request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into())) Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
} }
async fn command_status(&self, _handle: CommandHandle) -> Result<CommandStatus, WorkdirError> { async fn command_status(&self, _handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into())) Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
} }
async fn command_output( async fn command_output(
&self, &self,
_request: CommandOutputRequest, _request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> { ) -> Result<CommandOutput, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into())) Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
} }
async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> { async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into())) Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
} }
async fn close(&self) -> Result<(), WorkdirError> { async fn close(&self) -> Result<(), WorkdirError> {
@@ -1712,6 +1814,8 @@ mod tests {
use super::*; use super::*;
use crate::LocalWorkdirSession; use crate::LocalWorkdirSession;
include!("scope_delegation_tests.rs");
fn fs_path(path: &str) -> FsPath { fn fs_path(path: &str) -> FsPath {
FsPath::new(path).unwrap() FsPath::new(path).unwrap()
} }
@@ -2226,7 +2330,7 @@ mod tests {
assert!(matches!( assert!(matches!(
child.read(read("link")).await, child.read(read("link")).await,
Err(WorkdirError::Denied(message)) Err(WorkdirError::Denied(message))
if message.contains("provider-resolved delegated scope") if message.message.contains("provider-resolved delegated scope")
)); ));
} }
@@ -2252,7 +2356,7 @@ mod tests {
assert!(matches!( assert!(matches!(
child.read(read("escape")).await, child.read(read("escape")).await,
Err(WorkdirError::Denied(message)) Err(WorkdirError::Denied(message))
if message.contains("provider-resolved delegated scope") if message.message.contains("provider-resolved delegated scope")
)); ));
} }
@@ -2353,7 +2457,7 @@ mod tests {
.scope(request("alias-b", WorkdirToolScopePermission::Write)) .scope(request("alias-b", WorkdirToolScopePermission::Write))
.await, .await,
Err(WorkdirError::Denied(message)) Err(WorkdirError::Denied(message))
if message.contains("overlaps an active child scope after provider resolution") if message.message.contains("overlaps an active child scope after provider resolution")
)); ));
assert!(matches!( assert!(matches!(
parent parent
@@ -2364,7 +2468,7 @@ mod tests {
}) })
.await, .await,
Err(WorkdirError::Denied(message)) Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools") if message.message.contains("leased to child Workdir tools")
)); ));
} }
@@ -2383,7 +2487,7 @@ mod tests {
assert!(matches!( assert!(matches!(
child.scope(expanded).await, child.scope(expanded).await,
Err(WorkdirError::Denied(message)) Err(WorkdirError::Denied(message))
if message.contains("exceeds the parent tool scope") if message.message.contains("exceeds the parent tool scope")
)); ));
} }
@@ -2452,7 +2556,7 @@ mod tests {
.write(write("secret/parent", "must-be-blocked")) .write(write("secret/parent", "must-be-blocked"))
.await, .await,
Err(WorkdirError::Denied(message)) Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools") if message.message.contains("leased to child Workdir tools")
)); ));
assert_eq!( assert_eq!(
fs::read_to_string(root.path().join("secret/from-child")).unwrap(), fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
@@ -0,0 +1,334 @@
// Included in scope::tests; exercise real provider resolution through stacked brokers.
fn filesystem_scope(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope {
let mut scope = request(path, permission);
scope.command = false;
scope
}
fn authorization(
path: &str,
permission: WorkdirToolScopePermission,
) -> WorkdirScopeAuthorizationRequest {
WorkdirScopeAuthorizationRequest {
rules: filesystem_scope("", permission).rules,
path: fs_path(path),
permission,
}
}
#[tokio::test]
async fn stacked_brokers_delegate_all_filesystem_provider_kinds() {
use crate::{BoundedReadLimits, ExternalWorkdirRoot};
for kind in [
"repository",
"read-only-wrapper",
"external-read",
"external-write",
"external-read-command",
"external-all",
] {
let root = TempDir::new().unwrap();
fs::create_dir(root.path().join("child")).unwrap();
fs::create_dir(root.path().join("other")).unwrap();
fs::write(root.path().join("child/input"), "visible").unwrap();
fs::write(root.path().join("other/secret"), "hidden").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("../other", root.path().join("child/escape")).unwrap();
let capabilities = match kind {
"read-only-wrapper" | "external-read" => WorkdirSessionCapabilities::READ_ONLY,
"external-write" => WorkdirSessionCapabilities::READ_WRITE,
"external-read-command" => WorkdirSessionCapabilities::READ_ONLY
.union(WorkdirSessionCapabilities::COMMAND_ONLY),
_ => WorkdirSessionCapabilities::ALL,
};
let source: WorkdirSessionHandle = if kind.starts_with("external-") {
Arc::new(
LocalWorkdirSession::external_with_capabilities_pinned(
Workdir::new(kind),
ExternalWorkdirRoot::pin(root.path()).unwrap(),
BoundedReadLimits::new(4096, 1024).unwrap(),
capabilities,
)
.unwrap(),
)
} else {
let source: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
));
if kind == "read-only-wrapper" {
Arc::new(ReadOnlyWorkdirSession::new(source))
} else {
source
}
};
// Backend capability wrapper, attachment routing, then the Worker broker.
let backend = WorkdirToolBroker::with_capabilities(source, capabilities);
let router = crate::WorkdirSessionRouter::new();
let routed = router
.attach(
crate::WorkdirAttachmentAlias::new("selected").unwrap(),
backend.tool_session(),
)
.unwrap();
let worker = WorkdirToolBroker::new(routed);
let child = worker
.scope(filesystem_scope("child", WorkdirToolScopePermission::Read))
.await
.unwrap();
assert_eq!(
child.read(read("input")).await.unwrap().bytes,
b"visible",
"{kind}"
);
assert!(child.write(write("denied", "no")).await.is_err(), "{kind}");
#[cfg(unix)]
assert!(child.read(read("escape/secret")).await.is_err(), "{kind}");
child.close().await.unwrap();
let writable = worker
.scope(filesystem_scope("child", WorkdirToolScopePermission::Write))
.await;
if !capabilities.supports(WorkdirSessionCapability::Write) {
assert!(writable.is_err(), "{kind}");
continue;
}
let writable = writable.unwrap();
writable.write(write("created", "child")).await.unwrap();
assert!(
worker.write(write("child/blocked", "no")).await.is_err(),
"{kind}"
);
assert!(
worker
.scope(filesystem_scope("child", WorkdirToolScopePermission::Write))
.await
.is_err(),
"{kind}"
);
// A non-overlapping write lease requires the second missing provider method.
let sibling = worker
.scope(filesystem_scope("other", WorkdirToolScopePermission::Write))
.await
.unwrap();
sibling.write(write("created", "sibling")).await.unwrap();
assert!(
!writable
.capabilities
.supports(WorkdirSessionCapability::Command)
);
writable.close().await.unwrap();
worker
.write(write("child/released", "parent"))
.await
.unwrap();
sibling.close().await.unwrap();
let command_child = worker
.scope(request("child", WorkdirToolScopePermission::Write))
.await;
if capabilities.supports(WorkdirSessionCapability::Command) {
let command_child = command_child.unwrap();
assert_eq!(
run_command(&command_child.tool_session(), "printf delegated", kind)
.await
.content,
"delegated"
);
command_child.close().await.unwrap();
} else {
assert!(command_child.is_err());
}
}
}
#[tokio::test]
async fn stacked_authorization_preserves_ceiling_scope_and_revocation() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("allowed/deep")).unwrap();
let backend = session(root.path());
let read_only = WorkdirToolBroker::with_capabilities(
backend.tool_session(),
WorkdirSessionCapabilities::READ_ONLY,
);
read_only
.authorize_scope_path(authorization("allowed", WorkdirToolScopePermission::Read))
.await
.unwrap();
assert!(
read_only
.authorize_scope_path(authorization("allowed", WorkdirToolScopePermission::Write))
.await
.is_err()
);
let command_only = WorkdirToolBroker::with_capabilities(
backend.tool_session(),
WorkdirSessionCapabilities::COMMAND_ONLY,
);
assert!(
command_only
.authorize_scope_path(authorization("allowed", WorkdirToolScopePermission::Read))
.await
.is_err()
);
assert!(
command_only
.scope(filesystem_scope(
"allowed",
WorkdirToolScopePermission::Read
))
.await
.is_err()
);
let mut limited = filesystem_scope("allowed", WorkdirToolScopePermission::Read);
limited.rules[0].recursive = false;
let parent = backend.scope(limited).await.unwrap();
let stacked = WorkdirToolBroker::new(parent.tool_session());
let mut valid = filesystem_scope("", WorkdirToolScopePermission::Read);
valid.rules[0].recursive = false;
let child = stacked.scope(valid.clone()).await.unwrap();
assert!(
stacked
.scope(filesystem_scope("", WorkdirToolScopePermission::Read))
.await
.is_err()
);
let mut logical = valid.clone();
logical.rules[0].symlink_policy = SymlinkPolicy::Logical;
assert!(stacked.scope(logical).await.is_err());
assert!(
stacked
.authorize_scope_path(authorization("deep/file", WorkdirToolScopePermission::Read))
.await
.is_err()
);
parent.close().await.unwrap();
assert!(stacked.scope(valid).await.is_err());
assert!(child.read(read("deep/file")).await.is_err());
assert!(
stacked
.scope_rules_overlap(WorkdirScopeOverlapRequest {
left: filesystem_scope("", WorkdirToolScopePermission::Read)
.rules
.remove(0),
right: filesystem_scope("", WorkdirToolScopePermission::Read)
.rules
.remove(0),
})
.await
.is_err()
);
}
#[tokio::test]
async fn stacked_scope_rebases_rules_paths_and_overlap_once() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("base/inner/one")).unwrap();
fs::create_dir_all(root.path().join("base/inner/two")).unwrap();
fs::write(root.path().join("base/inner/one/input"), "nested").unwrap();
let backend = session(root.path());
let first = backend
.scope(filesystem_scope("base", WorkdirToolScopePermission::Write))
.await
.unwrap();
let worker = WorkdirToolBroker::new(first.tool_session());
let second = worker
.scope(filesystem_scope("inner", WorkdirToolScopePermission::Write))
.await
.unwrap();
let nested = WorkdirToolBroker::new(second.tool_session());
let leaf = nested
.scope(filesystem_scope("one", WorkdirToolScopePermission::Write))
.await
.unwrap();
assert_eq!(leaf.read(read("input")).await.unwrap().bytes, b"nested");
leaf.write(write("output", "yes")).await.unwrap();
assert_eq!(
fs::read(root.path().join("base/inner/one/output")).unwrap(),
b"yes"
);
assert!(nested.write(write("one/blocked", "no")).await.is_err());
let sibling = nested
.scope(filesystem_scope("two", WorkdirToolScopePermission::Write))
.await
.unwrap();
sibling.write(write("output", "sibling")).await.unwrap();
assert!(
nested
.scope(filesystem_scope("one", WorkdirToolScopePermission::Write))
.await
.is_err()
);
leaf.close().await.unwrap();
nested.write(write("one/released", "yes")).await.unwrap();
sibling.close().await.unwrap();
first.close().await.unwrap();
assert!(
nested
.authorize_scope_path(authorization("one", WorkdirToolScopePermission::Read))
.await
.is_err()
);
}
#[cfg(unix)]
#[tokio::test]
async fn stacked_scope_keeps_resolved_symlink_and_lease_checks() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("base/target")).unwrap();
fs::create_dir(root.path().join("secret")).unwrap();
fs::write(root.path().join("secret/key"), "hidden").unwrap();
symlink("target", root.path().join("base/alias-a")).unwrap();
symlink("target", root.path().join("base/alias-b")).unwrap();
symlink("../../secret", root.path().join("base/target/escape")).unwrap();
let backend = session(root.path());
let parent = backend
.scope(filesystem_scope("base", WorkdirToolScopePermission::Write))
.await
.unwrap();
let worker = WorkdirToolBroker::new(parent.tool_session());
let first = worker
.scope(filesystem_scope(
"alias-a",
WorkdirToolScopePermission::Write,
))
.await
.unwrap();
first.write(write("ok", "allowed")).await.unwrap();
assert!(first.read(read("escape/key")).await.is_err());
assert!(
worker
.scope(filesystem_scope(
"alias-a/escape",
WorkdirToolScopePermission::Read
))
.await
.is_err()
);
assert!(
worker
.scope(filesystem_scope(
"alias-b",
WorkdirToolScopePermission::Write
))
.await
.is_err()
);
assert!(worker.write(write("target/blocked", "no")).await.is_err());
// Direct authorization through the wrapper must not bypass its existing write lease.
assert!(
worker
.authorize_scope_path(authorization(
"target/blocked",
WorkdirToolScopePermission::Write
))
.await
.is_err()
);
first.close().await.unwrap();
worker
.write(write("alias-b/released", "yes"))
.await
.unwrap();
}
@@ -0,0 +1,185 @@
//! Wire diagnostics must not change command disappearance or lease release.
//! Fake command handles only; no OS process is started, polled, or cancelled.
use async_trait::async_trait;
use std::{path::Path, sync::Arc};
use tempfile::TempDir;
use workdir::*;
#[derive(Debug)]
struct CommandFailureSession {
local: LocalWorkdirSession,
code: &'static str,
depth: u8,
cancel_ok: bool,
}
impl CommandFailureSession {
fn new(root: &Path, code: &'static str, depth: u8, cancel_ok: bool) -> Arc<Self> {
Arc::new(Self {
local: LocalWorkdirSession::new(
manifest::Scope::writable(root).unwrap(),
root.to_path_buf(),
),
code,
depth,
cancel_ok,
})
}
fn failure(&self) -> WorkdirError {
let mut error = serde_json::from_value::<http::WorkdirTransportError>(serde_json::json!({
"code": self.code, "message": "safe fixture error",
"denial_reason": (self.depth > 0).then_some(WorkdirDenialReason::ReadOnlySession),
}))
.unwrap()
.into_workdir_error();
if self.depth == 2 {
error = WorkdirError::DenialContext {
reason: WorkdirDenialReason::OsPermissionDenied,
source: Box::new(error),
};
}
error
}
}
#[async_trait]
impl WorkdirSession for CommandFailureSession {
fn workdir(&self) -> &Workdir {
self.local.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.local.capabilities()
}
async fn authorize_scope_path(
&self,
r: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
self.local.authorize_scope_path(r).await
}
async fn scope_rules_overlap(
&self,
r: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.local.scope_rules_overlap(r).await
}
async fn stat(&self, r: StatRequest) -> Result<StatResult, WorkdirError> {
self.local.stat(r).await
}
async fn read(&self, r: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.local.read(r).await
}
async fn write(&self, r: WriteRequest) -> Result<WriteResult, WorkdirError> {
self.local.write(r).await
}
async fn edit(&self, r: EditRequest) -> Result<EditResult, WorkdirError> {
self.local.edit(r).await
}
async fn list(&self, r: ListRequest) -> Result<ListResult, WorkdirError> {
self.local.list(r).await
}
async fn glob(&self, r: GlobRequest) -> Result<GlobResult, WorkdirError> {
self.local.glob(r).await
}
async fn grep(&self, r: GrepRequest) -> Result<GrepResult, WorkdirError> {
self.local.grep(r).await
}
async fn start_command(&self, _: CommandRequest) -> Result<CommandHandle, WorkdirError> {
Ok(CommandHandle("fake-command".into()))
}
async fn command_status(&self, _: CommandHandle) -> Result<CommandStatus, WorkdirError> {
Err(self.failure())
}
async fn command_output(&self, _: CommandOutputRequest) -> Result<CommandOutput, WorkdirError> {
Err(self.failure())
}
async fn cancel_command(&self, _: CommandHandle) -> Result<(), WorkdirError> {
if self.cancel_ok {
Ok(())
} else {
Err(self.failure())
}
}
async fn close(&self) -> Result<(), WorkdirError> {
self.local.close().await
}
}
fn command() -> CommandRequest {
CommandRequest {
command: "fake command, never executed".into(),
timeout_secs: 1,
output_limit: 100,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
}
}
fn scope() -> WorkdirToolScope {
WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {
target: WorkdirPath::root(),
permission: WorkdirToolScopePermission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
command: true,
cwd: WorkdirPath::root(),
}
}
#[tokio::test]
async fn command_diagnostics_preserve_detach_disappearance_and_busy_guard() {
for code in ["unknown_command", "unavailable"] {
for depth in [0, 1, 2] {
let dir = TempDir::new().unwrap();
let router = WorkdirSessionRouter::new();
let alias = WorkdirAttachmentAlias::new("fixture").unwrap();
router
.attach(
alias.clone(),
CommandFailureSession::new(dir.path(), code, depth, false),
)
.unwrap();
let session = router.resolve(Some("fixture")).unwrap().session;
session.start_command(command()).await.unwrap();
let result = router.detach(&alias).await;
if code == "unknown_command" {
result.unwrap_or_else(|error| panic!("depth {depth}: {error:?}"));
assert!(router.resolve(Some("fixture")).is_err());
} else {
assert!(matches!(result, Err(WorkdirError::Conflict(_))));
assert!(router.resolve(Some("fixture")).is_ok());
}
}
}
}
#[tokio::test]
async fn command_diagnostics_preserve_scope_close_and_write_lease_guard() {
for code in ["unknown_command", "unavailable"] {
for depth in [0, 1, 2] {
for cancel_ok in [false, true] {
let dir = TempDir::new().unwrap();
let broker = WorkdirToolBroker::new(CommandFailureSession::new(
dir.path(),
code,
depth,
cancel_ok,
));
let child = broker.scope(scope()).await.unwrap();
child.start_command(command()).await.unwrap();
let result = child.close().await;
if code == "unknown_command" {
result.unwrap_or_else(|error| {
panic!("depth {depth}, cancel_ok={cancel_ok}: {error:?}")
});
assert!(!child.is_active());
broker.scope(scope()).await.unwrap().close().await.unwrap();
} else {
assert!(result.is_err());
// Closing revokes new work immediately, but failed cleanup
// must retain the write lease until ownership is resolved.
assert!(!child.is_active());
assert!(broker.scope(scope()).await.is_err());
}
}
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Refusal reasons are selected by providers, not parsed from their messages.
use std::sync::Arc;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
use workdir::{
LocalWorkdirSession, ReadOnlyWorkdirSession, StatRequest, Workdir,
WorkdirDenialReason as Reason, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirToolBroker, WorkdirToolScope,
WorkdirToolScopePermission as ToolPermission, WorkdirToolScopeRule, WriteRequest,
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
};
fn path(value: &str) -> WorkdirPath {
WorkdirPath::new(value).unwrap()
}
fn rule(target: &str, permission: ToolPermission) -> WorkdirToolScopeRule {
WorkdirToolScopeRule {
target: path(target),
permission,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}
}
fn scoped(target: &str, permission: ToolPermission) -> WorkdirToolScope {
WorkdirToolScope {
rules: vec![rule(target, permission)],
cwd: path(target),
command: false,
}
}
fn assert_reason(error: workdir::WorkdirError, reason: Reason) {
assert_eq!(error.denial_reason(), Some(reason), "{error}");
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code.http_status(), 403);
assert_eq!(transport.denial_reason, Some(reason));
let wire = serde_json::to_string(&transport).unwrap();
assert!(!wire.contains("private-input"));
let decoded: WorkdirTransportError = serde_json::from_str(&wire).unwrap();
assert_eq!(
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
transport
);
}
#[tokio::test]
async fn local_authorize_scope_refusals_distinguish_attachment_and_delegation() {
let root = tempfile::tempdir().unwrap();
let session = LocalWorkdirSession::materialized_bound(
Workdir::new("denials"),
root.path().to_owned(),
root.path().to_owned(),
SharedScope::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().join("allowed"),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}],
deny: vec![],
})
.unwrap(),
),
WorkdirSessionCapabilities::ALL,
);
for (rules, requested, reason) in [
(
vec![rule("", ToolPermission::Read)],
"private-input",
Reason::AttachmentScopeExceeded,
),
(
vec![rule("allowed/child", ToolPermission::Read)],
"allowed/private-input",
Reason::DelegatedScopeExceeded,
),
] {
let error = session
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules,
path: path(requested),
permission: ToolPermission::Read,
})
.await
.unwrap_err();
assert_reason(error, reason);
}
}
#[tokio::test]
async fn broker_scope_refusal_matrix_preserves_distinct_reasons() {
let root = tempfile::tempdir().unwrap();
let local = Arc::new(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let parent = WorkdirToolBroker::new(local);
let mut empty = scoped("", ToolPermission::Read);
empty.rules.clear();
let mut command = scoped("", ToolPermission::Read);
command.command = true;
let mut cwd = scoped("allowed", ToolPermission::Read);
cwd.cwd = path("private-input");
for (request, reason) in [
(empty, Reason::EmptyScope),
(command, Reason::CommandRequiresWritableScope),
(cwd, Reason::CwdOutsideReadableScope),
] {
assert_reason(parent.scope(request).await.unwrap_err(), reason);
}
let mut child_request = scoped("allowed", ToolPermission::Write);
child_request.rules[0].recursive = false;
let child = parent.scope(child_request).await.unwrap();
assert_reason(
child
.scope(scoped("private-input", ToolPermission::Read))
.await
.unwrap_err(),
Reason::ParentScopeExceeded,
);
assert_reason(
parent
.scope(scoped("allowed", ToolPermission::Write))
.await
.unwrap_err(),
Reason::ChildWriteLeaseConflict,
);
assert_reason(
parent
.write(WriteRequest {
path: path("allowed/private-input"),
content: "unused".into(),
expected_hash: None,
})
.await
.unwrap_err(),
Reason::ChildWriteLeaseConflict,
);
assert_reason(
child
.stat(StatRequest {
path: path("private-input/nested"),
})
.await
.unwrap_err(),
Reason::DelegatedScopeExceeded,
);
}
#[tokio::test]
async fn read_only_and_scoped_capability_refusals_do_not_collapse() {
let root = tempfile::tempdir().unwrap();
let local = Arc::new(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let parent = WorkdirToolBroker::new(local.clone());
let child = parent
.scope(scoped("", ToolPermission::Read))
.await
.unwrap();
let request = WriteRequest {
path: path("private-input"),
content: "unused".into(),
expected_hash: None,
};
assert_reason(
child
.start_command(workdir::CommandRequest {
command: "must-not-run".into(),
timeout_secs: 1,
output_limit: 1,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await
.unwrap_err(),
Reason::ScopedCapabilityDenied,
);
let readonly = ReadOnlyWorkdirSession::new(local);
assert_reason(
readonly.write(request).await.unwrap_err(),
Reason::ReadOnlySession,
);
let error = parent
.checkout_search(workdir::CheckoutSearchRequest {
operation: workdir::CheckoutSearchOperation::List(workdir::ListRequest {
path: path("private-input"),
limit: 10,
after: None,
}),
output_root: path("allowed"),
scope_layers: vec![],
})
.await
.unwrap_err();
assert_reason(error, Reason::CheckoutOutputRootExceeded);
}
#[test]
fn denial_wire_is_allowlisted_and_old_missing_reason_remains_unknown() {
let old: WorkdirTransportError =
serde_json::from_str(r#"{"code":"denied","message":"private-input secret"}"#).unwrap();
let error = old.into_workdir_error();
assert_eq!(error.denial_reason(), None);
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(forwarded.code, Code::Denied);
assert_eq!(forwarded.message, "Workdir operation was denied");
assert_eq!(
serde_json::to_value(forwarded).unwrap(),
serde_json::json!({"code":"denied", "message":"Workdir operation was denied"})
);
for reason in ["/host/private-input", "secret", &"x".repeat(8192)] {
let input =
serde_json::json!({"code":"denied", "message":"ignored", "denial_reason":reason});
assert!(serde_json::from_value::<WorkdirTransportError>(input).is_err());
}
}
// A provider with filesystem methods, but no provider-side scope resolver.
#[derive(Debug)]
struct UnresolvedProvider(LocalWorkdirSession);
#[async_trait::async_trait]
impl WorkdirSession for UnresolvedProvider {
fn workdir(&self) -> &Workdir {
self.0.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.0.capabilities()
}
async fn stat(&self, r: StatRequest) -> Result<workdir::StatResult, workdir::WorkdirError> {
self.0.stat(r).await
}
async fn read(
&self,
r: workdir::ReadRequest,
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
self.0.read(r).await
}
async fn write(&self, r: WriteRequest) -> Result<workdir::WriteResult, workdir::WorkdirError> {
self.0.write(r).await
}
async fn edit(
&self,
r: workdir::EditRequest,
) -> Result<workdir::EditResult, workdir::WorkdirError> {
self.0.edit(r).await
}
async fn list(
&self,
r: workdir::ListRequest,
) -> Result<workdir::ListResult, workdir::WorkdirError> {
self.0.list(r).await
}
async fn glob(
&self,
r: workdir::GlobRequest,
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
self.0.glob(r).await
}
async fn grep(
&self,
r: workdir::GrepRequest,
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
self.0.grep(r).await
}
async fn start_command(
&self,
r: workdir::CommandRequest,
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
self.0.start_command(r).await
}
async fn command_status(
&self,
h: workdir::CommandHandle,
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
self.0.command_status(h).await
}
async fn command_output(
&self,
r: workdir::CommandOutputRequest,
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
self.0.command_output(r).await
}
async fn cancel_command(&self, h: workdir::CommandHandle) -> Result<(), workdir::WorkdirError> {
self.0.cancel_command(h).await
}
async fn close(&self) -> Result<(), workdir::WorkdirError> {
self.0.close().await
}
}
#[tokio::test]
async fn providers_without_scope_resolution_fail_closed_with_specific_reasons() {
let root = tempfile::tempdir().unwrap();
let provider = UnresolvedProvider(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let rules = vec![rule("", ToolPermission::Read)];
assert_reason(
provider
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules: rules.clone(),
path: path("private-input"),
permission: ToolPermission::Read,
})
.await
.unwrap_err(),
Reason::ScopeResolutionUnavailable,
);
assert_reason(
provider
.scope_rules_overlap(workdir::WorkdirScopeOverlapRequest {
left: rules[0].clone(),
right: rules[0].clone(),
})
.await
.unwrap_err(),
Reason::ScopeComparisonUnavailable,
);
let mut logical = rules;
logical[0].symlink_policy = SymlinkPolicy::Logical;
provider
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules: logical,
path: path("private-input"),
permission: ToolPermission::Read,
})
.await
.unwrap();
}
+288
View File
@@ -0,0 +1,288 @@
use std::path::Path;
use fs_operation::{AtomicWriteMode, CheckedTarget, FsAccessPolicy, FsDenialReason, FsError};
use workdir::{
WorkdirDenialReason as Reason, WorkdirError,
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
};
fn assert_origin(source: std::io::Error, reason: Reason, message: &str) {
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
assert_eq!(source.raw_os_error(), None);
assert_eq!(source.to_string(), message);
assert_transport_reason(source, reason);
}
fn assert_transport_reason(source: std::io::Error, reason: Reason) {
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
assert!(matches!(&error, WorkdirError::Io { .. }));
assert_eq!(error.denial_reason(), Some(reason));
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, Code::Denied);
assert_eq!(transport.code.http_status(), 403);
assert_eq!(transport.message, "Workdir operation was denied");
let wire = serde_json::to_value(&transport).unwrap();
assert_eq!(wire["denial_reason"], reason.as_str());
assert!(!wire.to_string().contains("private-input"));
assert!(!wire.to_string().contains("secret"));
let decoded: WorkdirTransportError = serde_json::from_value(wire).unwrap();
assert_eq!(
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
transport
);
}
#[test]
fn synthetic_fs_origins_survive_fs_workdir_and_transport_conversion() {
for (origin, reason, message) in [
(
FsDenialReason::ProviderRootExceeded,
Reason::ProviderRootExceeded,
"path is outside provider root",
),
(
FsDenialReason::ProviderSymlinkDenied,
Reason::ProviderSymlinkDenied,
"symbolic links are not permitted by this provider",
),
(
FsDenialReason::CheckoutTargetDenied,
Reason::CheckoutTargetDenied,
"checkout target denied",
),
(
FsDenialReason::CheckoutCreateParentDenied,
Reason::CheckoutCreateParentDenied,
"Create parent denied",
),
] {
assert_origin(origin.into_io_error(), reason, message);
}
}
#[test]
fn custom_permission_denied_is_not_guessed_from_kind_or_message() {
for message in [
"Permission denied (os error 13)",
"checkout target denied",
"/private-input/secret",
] {
assert_transport_reason(
std::io::Error::new(std::io::ErrorKind::PermissionDenied, message),
Reason::UnclassifiedPermissionDenied,
);
}
}
#[cfg(unix)]
#[test]
fn raw_os_permission_denied_retains_errno_and_os_origin() {
for errno in [libc::EACCES, libc::EPERM] {
let source = std::io::Error::from_raw_os_error(errno);
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
let WorkdirError::Io { source, .. } = &error else {
panic!("classification changed")
};
assert_eq!(source.raw_os_error(), Some(errno));
assert_transport_reason(
std::io::Error::from_raw_os_error(errno),
Reason::OsPermissionDenied,
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn actual_confined_open_and_write_refusals_keep_distinct_origins() {
use fs_operation::{
atomic_write_beneath_no_symlinks_at, open_beneath_no_symlinks, open_beneath_no_symlinks_at,
open_root_no_symlinks,
};
let root = tempfile::tempdir().unwrap();
let directory = open_root_no_symlinks(root.path()).unwrap();
let outside = root.path().parent().unwrap().join("private-input");
for source in [
open_beneath_no_symlinks_at(&directory, &outside).unwrap_err(),
open_beneath_no_symlinks(root.path(), &outside).unwrap_err(),
atomic_write_beneath_no_symlinks_at(
&directory,
&outside,
b"unused",
AtomicWriteMode::CreateNew,
|| Ok(()),
)
.unwrap_err(),
atomic_write_beneath_no_symlinks_at(
&directory,
Path::new("../private-input"),
b"unused",
AtomicWriteMode::CreateNew,
|| Ok(()),
)
.unwrap_err(),
] {
assert_origin(
source,
Reason::ProviderRootExceeded,
"path is outside provider root",
);
}
std::fs::write(root.path().join("file"), "secret").unwrap();
std::os::unix::fs::symlink("file", root.path().join("link")).unwrap();
assert_origin(
open_beneath_no_symlinks_at(&directory, Path::new("link")).unwrap_err(),
Reason::ProviderSymlinkDenied,
"symbolic links are not permitted by this provider",
);
}
struct ExactWrite<'a>(&'a Path);
impl FsAccessPolicy for ExactWrite<'_> {
fn is_readable(&self, _: &Path) -> bool {
true
}
fn is_writable(&self, path: &Path) -> bool {
path == self.0
}
}
#[cfg(target_os = "linux")]
#[test]
fn actual_checked_target_and_create_parent_refusals_keep_distinct_origins() {
let root = tempfile::tempdir().unwrap();
let directory = fs_operation::open_root_no_symlinks(root.path()).unwrap();
let destination = root.path().join("parent/file");
let policy = ExactWrite(&destination);
let source = CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, true)
.err()
.expect("target policy must reject");
assert_origin(
source,
Reason::CheckoutTargetDenied,
"checkout target denied",
);
let target =
CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, false).unwrap();
let create = target.create_access("parent/file".into(), destination.clone());
let error = fs_operation::run_write(
root.path(),
fs_operation::WriteRequest {
path: fs_operation::FsPath::new("parent/file").unwrap(),
content: "unused".into(),
expected_hash: None,
},
&create,
)
.unwrap_err();
let FsError::Io { source, .. } = error else {
panic!("expected parent policy denial")
};
assert_origin(
source,
Reason::CheckoutCreateParentDenied,
"Create parent denied",
);
assert!(!root.path().join("parent").exists());
}
#[test]
fn every_transport_code_forwards_supplied_reason_without_reclassification() {
// JSON is the provider boundary. An optional reason is independent of code/status.
for code in [
"not_found",
"conflict",
"outcome_unknown",
"unsupported",
"invalid_request",
"denied",
"out_of_scope",
"symlink_out_of_scope",
"broken_symlink",
"symlink_target_is_directory",
"read_only",
"is_directory",
"symlink_directory_not_traversed",
"unknown_command",
"unavailable",
"io",
"transport",
"internal",
] {
let decode = |reason| {
serde_json::from_value::<WorkdirTransportError>(serde_json::json!({
"code": code, "message": "private-input secret", "denial_reason": reason,
}))
.unwrap()
.into_workdir_error()
};
let baseline_error = decode(None::<Reason>);
let baseline = WorkdirTransportError::from_workdir_error(&baseline_error);
let error = decode(Some(Reason::CheckoutTargetDenied));
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(
error.denial_reason(),
Some(Reason::CheckoutTargetDenied),
"{code}"
);
assert_eq!(forwarded.code, baseline.code, "{code}");
assert_eq!(
forwarded.code.http_status(),
baseline.code.http_status(),
"{code}"
);
assert_eq!(forwarded.message, baseline.message, "{code}");
assert_eq!(error.to_string(), baseline_error.to_string(), "{code}");
// Missing provider reasons retain the deliberate existing fallback: canonical
// path classifications supply their known reason; all other codes stay unknown.
assert_eq!(
baseline_error.denial_reason(),
match code {
"out_of_scope" => Some(Reason::PathOutOfScope),
"symlink_out_of_scope" => Some(Reason::SymlinkTargetOutOfScope),
"read_only" => Some(Reason::PathReadOnly),
_ => None,
},
"{code}"
);
}
}
#[test]
fn diagnostic_labels_match_allowlisted_wire_values() {
for label in [
"scope_resolution_unavailable",
"scope_comparison_unavailable",
"scoped_capability_denied",
"logical_scope_exceeded",
"invalid_scoped_path",
"child_write_lease_conflict",
"empty_scope",
"parent_capability_denied",
"command_requires_writable_scope",
"parent_scope_exceeded",
"cwd_outside_readable_scope",
"read_only_session",
"external_root_symlink",
"external_root_changed",
"external_root_moved",
"external_scope_symlink",
"attachment_scope_exceeded",
"delegated_scope_exceeded",
"checkout_output_root_exceeded",
"provider_root_exceeded",
"provider_symlink_denied",
"checkout_target_denied",
"checkout_create_parent_denied",
"checkout_search_path_denied",
"checkout_enumeration_denied",
"path_out_of_scope",
"symlink_target_out_of_scope",
"path_read_only",
"os_permission_denied",
"unclassified_permission_denied",
] {
let reason: Reason = serde_json::from_value(serde_json::json!(label)).unwrap();
assert_eq!(reason.as_str(), label);
assert_eq!(serde_json::to_value(reason).unwrap(), label);
}
}
@@ -0,0 +1,218 @@
#![cfg(feature = "http-client")]
//! Loopback fake at the HTTP provider boundary; no product process or live service.
use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
thread,
time::{Duration, Instant},
};
use workdir::{
StatRequest, Workdir, WorkdirDenialReason as Reason, WorkdirPath, WorkdirSession,
http::{
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirTransportError,
WorkdirTransportErrorCode as Code,
},
};
fn reply(listener: &TcpListener, status: &str, body: &str) {
listener.set_nonblocking(true).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let (mut stream, _) = loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(Instant::now() < deadline, "fake provider accept timed out");
thread::yield_now();
}
Err(error) => panic!("fake provider accept failed: {error}"),
}
};
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
read_request(&mut stream);
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
}
fn read_request(stream: &mut TcpStream) {
let mut bytes = Vec::new();
let mut byte = [0];
while !bytes.ends_with(b"\r\n\r\n") {
stream.read_exact(&mut byte).unwrap();
bytes.push(byte[0]);
assert!(bytes.len() < 16384);
}
let headers = String::from_utf8(bytes).unwrap();
let length: usize = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().unwrap())
})
.unwrap_or(0);
assert!(length < 16384);
stream.read_exact(&mut vec![0; length]).unwrap();
}
#[tokio::test]
async fn remote_http_denials_forward_only_typed_reasons_and_old_missing_reason() {
for (code, reason) in [
(Code::Denied, Some(Reason::ReadOnlySession)),
(Code::Denied, Some(Reason::OsPermissionDenied)),
(Code::OutOfScope, Some(Reason::OsPermissionDenied)),
(Code::Denied, None),
] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
// Host paths and secrets in provider-authored text must not become diagnostics.
let mut error = serde_json::json!({"code":code, "message":"/secret/host/root bearer-secret command-secret"});
if let Some(reason) = reason {
error["denial_reason"] = serde_json::to_value(reason).unwrap();
}
let server = thread::spawn(move || {
reply(
&listener,
"200 OK",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
reply(&listener, "403 Forbidden", &error.to_string());
});
let remote = RemoteWorkdirSession::open(
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
format!("http://{address}").parse().unwrap(),
"request-secret",
Workdir::new("w").id().clone(),
OpenWorkdirSessionRequest::default(),
)
.await
.unwrap();
let denied = remote
.stat(StatRequest {
path: WorkdirPath::new("file").unwrap(),
})
.await
.unwrap_err();
assert_eq!(denied.denial_reason(), reason);
assert!(!denied.to_string().contains("secret"));
let forwarded = WorkdirTransportError::from_workdir_error(&denied);
assert_eq!(forwarded.denial_reason, reason);
assert_eq!(forwarded.code, code);
assert_eq!(forwarded.code.http_status(), 403);
assert_eq!(
forwarded.message,
if code == Code::Denied {
"Workdir operation was denied"
} else {
"Workdir path is out of scope"
}
);
let wire = serde_json::to_string(&forwarded).unwrap();
assert!(!wire.contains("secret"));
assert!(wire.len() < 256);
server.join().unwrap();
}
}
#[tokio::test]
async fn remote_checkout_diagnostics_preserve_mutation_outcome_and_read_failures() {
use workdir::{CheckoutOperation as Op, CheckoutRequest, WorkdirSessionCapability};
for code in [
Code::Unavailable,
Code::Transport,
Code::Denied,
Code::Conflict,
Code::OutcomeUnknown,
] {
for reason in [None, Some(Reason::ReadOnlySession)] {
for operation in [
Op::Read {
offset: 0,
limit: 1,
max_bytes: 10,
},
Op::Create {
path: WorkdirPath::new("new.txt").unwrap(),
content: b"fixture".to_vec(),
},
Op::Write {
content: b"fixture".to_vec(),
expected_hash: [0; 32],
},
Op::Edit {
old_string: "old".into(),
new_string: "new".into(),
replace_all: false,
expected_hash: [0; 32],
},
] {
let mutation = operation.capability() != WorkdirSessionCapability::Read;
let expected_code =
if mutation && matches!(code, Code::Unavailable | Code::Transport) {
Code::OutcomeUnknown
} else {
code
};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let error = serde_json::json!({"code":code, "message":"/secret/host provider-secret", "denial_reason":reason});
let server = thread::spawn(move || {
reply(
&listener,
"200 OK",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
reply(
&listener,
&format!("{} Fixture failure", code.http_status()),
&error.to_string(),
);
});
let remote = RemoteWorkdirSession::open(
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
format!("http://{address}").parse().unwrap(),
"request-secret",
Workdir::new("w").id().clone(),
OpenWorkdirSessionRequest::default(),
)
.await
.unwrap();
let error = remote
.checkout_execute(CheckoutRequest {
target: WorkdirPath::root(),
validator: vec![1],
operation,
})
.await
.unwrap_err();
server.join().unwrap();
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(
forwarded.code, expected_code,
"{code:?}, {reason:?}, mutation={mutation}: {error:?}"
);
assert_eq!(error.denial_reason(), reason);
assert_eq!(forwarded.denial_reason, reason);
assert_eq!(forwarded.code.http_status(), expected_code.http_status());
if expected_code == Code::OutcomeUnknown {
assert!(forwarded.message.contains("inspect before retrying"));
}
assert!(
!serde_json::to_string(&forwarded)
.unwrap()
.contains("secret")
);
}
}
}
}
+3 -6
View File
@@ -75,7 +75,6 @@ pub struct WorkingDirectoryRepository {
pub id: String, pub id: String,
pub provider: String, pub provider: String,
pub source: server_api::RepositorySource, pub source: server_api::RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String, pub source_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>, pub selector: Option<RepositorySelector>,
@@ -121,7 +120,7 @@ impl std::fmt::Debug for SensitiveString {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshCredentialCandidate { pub struct RepositorySshCredentialCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, pub public_key_fingerprint: String,
#[serde(skip, default)] #[serde(skip, default)]
pub private_key: SensitiveString, pub private_key: SensitiveString,
} }
@@ -130,7 +129,7 @@ pub struct RepositorySshCredentialCandidate {
pub struct RepositorySshMaterializationAccess { pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>, pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String, pub host_trust_id: String,
pub host_trust_revision: u64, pub host_key_fingerprint: String,
pub access: server_api::RepositoryAccessMode, pub access: server_api::RepositoryAccessMode,
pub expires_at_epoch_seconds: u64, pub expires_at_epoch_seconds: u64,
pub repository_id: String, pub repository_id: String,
@@ -146,7 +145,6 @@ pub struct RepositoryMaterializationContext {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
pub operation_id: String, pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String, pub config_projection_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>, pub ssh: Option<RepositorySshMaterializationAccess>,
@@ -191,10 +189,9 @@ pub struct RepositoryRefObservationRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryRefObservation { pub struct RepositoryRefObservation {
pub repository_id: String, pub repository_id: String,
pub source_revision: u64,
pub source_fingerprint: String, pub source_fingerprint: String,
pub selector: String, pub selector: String,
pub revision_ref: String, pub resolved_ref: String,
pub observed_at_epoch_seconds: u64, pub observed_at_epoch_seconds: u64,
} }
+3 -22
View File
@@ -43,7 +43,6 @@ impl ConfigBundle {
pub fn computed_digest(&self) -> String { pub fn computed_digest(&self) -> String {
let mut lines = Vec::new(); let mut lines = Vec::new();
lines.push(format!("id\0{}", self.metadata.id)); lines.push(format!("id\0{}", self.metadata.id));
lines.push(format!("revision\0{}", self.metadata.revision));
lines.push(format!("workspace_id\0{}", self.metadata.workspace_id)); lines.push(format!("workspace_id\0{}", self.metadata.workspace_id));
lines.push(format!("created_at\0{}", self.metadata.created_at)); lines.push(format!("created_at\0{}", self.metadata.created_at));
lines.push(format!( lines.push(format!(
@@ -82,7 +81,7 @@ impl ConfigBundle {
if let Some(prompt_catalog) = &self.prompt_catalog { if let Some(prompt_catalog) = &self.prompt_catalog {
lines.push(format!( lines.push(format!(
"prompt_catalog\0{}\0{}\0{}\0{}", "prompt_catalog\0{}\0{}\0{}\0{}",
prompt_catalog.config_revision, prompt_catalog.source_digest,
prompt_catalog.schema_fingerprint, prompt_catalog.schema_fingerprint,
prompt_catalog.toolchain_fingerprint, prompt_catalog.toolchain_fingerprint,
prompt_catalog.catalog_digest prompt_catalog.catalog_digest
@@ -100,12 +99,8 @@ impl ConfigBundle {
} }
if let Some(handle) = &self.profile_source_archive_handle { if let Some(handle) = &self.profile_source_archive_handle {
lines.push(format!( lines.push(format!(
"profile_archive_handle\0{}\0{}\0{}\0{}\0{}", "profile_archive_handle\0{}\0{}\0{}\0{}",
handle.workspace_id, handle.workspace_id, handle.resource_id, handle.digest, handle.max_bytes
handle.resource_id,
handle.digest,
handle.revision,
handle.max_bytes
)); ));
for (selector, path) in handle for (selector, path) in handle
.profile_source_graph .profile_source_graph
@@ -140,7 +135,6 @@ impl ConfigBundle {
id: self.metadata.id.clone(), id: self.metadata.id.clone(),
digest: self.metadata.digest.clone(), digest: self.metadata.digest.clone(),
digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(), digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(),
revision: self.metadata.revision.clone(),
workspace_id: self.metadata.workspace_id.clone(), workspace_id: self.metadata.workspace_id.clone(),
created_at: self.metadata.created_at.clone(), created_at: self.metadata.created_at.clone(),
provenance: self.metadata.provenance.clone(), provenance: self.metadata.provenance.clone(),
@@ -182,7 +176,6 @@ impl From<ConfigBundle> for server_api::WorkspaceRuntimeConfigResponse {
prompt_catalog: prompt_catalog.map(|catalog| { prompt_catalog: prompt_catalog.map(|catalog| {
server_api::WorkspaceRuntimePromptCatalog { server_api::WorkspaceRuntimePromptCatalog {
templates: catalog.templates, templates: catalog.templates,
config_revision: catalog.config_revision,
source_digest: catalog.source_digest, source_digest: catalog.source_digest,
schema_fingerprint: catalog.schema_fingerprint, schema_fingerprint: catalog.schema_fingerprint,
toolchain_fingerprint: catalog.toolchain_fingerprint, toolchain_fingerprint: catalog.toolchain_fingerprint,
@@ -200,7 +193,6 @@ impl From<ConfigBundleMetadata> for server_api::WorkspaceRuntimeConfigMetadata {
Self { Self {
id: metadata.id, id: metadata.id,
digest: metadata.digest, digest: metadata.digest,
revision: metadata.revision,
workspace_id: metadata.workspace_id, workspace_id: metadata.workspace_id,
created_at: metadata.created_at, created_at: metadata.created_at,
provenance: metadata.provenance.into(), provenance: metadata.provenance.into(),
@@ -303,8 +295,6 @@ impl From<BackendResourceHandle> for server_api::WorkspaceRuntimeResourceHandle
operation: handle.operation.into(), operation: handle.operation.into(),
expires_at_unix_seconds: handle.expires_at_unix_seconds, expires_at_unix_seconds: handle.expires_at_unix_seconds,
nonce: handle.nonce, nonce: handle.nonce,
revision: handle.revision,
generation: handle.generation,
max_bytes: handle.max_bytes, max_bytes: handle.max_bytes,
content_type: handle.content_type, content_type: handle.content_type,
redaction: handle.redaction.into(), redaction: handle.redaction.into(),
@@ -344,7 +334,6 @@ impl From<ResourceRedactionPolicy> for server_api::WorkspaceRuntimeResourceRedac
pub struct ConfigBundleMetadata { pub struct ConfigBundleMetadata {
pub id: String, pub id: String,
pub digest: String, pub digest: String,
pub revision: String,
pub workspace_id: String, pub workspace_id: String,
pub created_at: String, pub created_at: String,
pub provenance: ConfigBundleProvenance, pub provenance: ConfigBundleProvenance,
@@ -402,7 +391,6 @@ pub struct ConfigBundleSummary {
pub id: String, pub id: String,
pub digest: String, pub digest: String,
pub digest_algorithm: String, pub digest_algorithm: String,
pub revision: String,
pub workspace_id: String, pub workspace_id: String,
pub created_at: String, pub created_at: String,
pub provenance: ConfigBundleProvenance, pub provenance: ConfigBundleProvenance,
@@ -422,7 +410,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
validate_config_bundle_id(&bundle.metadata.id)?; validate_config_bundle_id(&bundle.metadata.id)?;
validate_non_empty("config bundle digest", &bundle.metadata.digest)?; validate_non_empty("config bundle digest", &bundle.metadata.digest)?;
validate_digest("config bundle digest", &bundle.metadata.digest)?; validate_digest("config bundle digest", &bundle.metadata.digest)?;
validate_non_empty("config bundle revision", &bundle.metadata.revision)?;
validate_non_empty("config bundle workspace id", &bundle.metadata.workspace_id)?; validate_non_empty("config bundle workspace id", &bundle.metadata.workspace_id)?;
validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?; validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?;
validate_non_empty( validate_non_empty(
@@ -430,7 +417,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
&bundle.metadata.provenance.source, &bundle.metadata.provenance.source,
)?; )?;
validate_boundary_text("config bundle id", &bundle.metadata.id)?; validate_boundary_text("config bundle id", &bundle.metadata.id)?;
validate_boundary_text("config bundle revision", &bundle.metadata.revision)?;
validate_boundary_text("config bundle workspace id", &bundle.metadata.workspace_id)?; validate_boundary_text("config bundle workspace id", &bundle.metadata.workspace_id)?;
validate_boundary_text( validate_boundary_text(
"config bundle provenance source", "config bundle provenance source",
@@ -489,7 +475,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
("resource handle resource id", handle.resource_id.as_str()), ("resource handle resource id", handle.resource_id.as_str()),
("resource handle digest", handle.digest.as_str()), ("resource handle digest", handle.digest.as_str()),
("resource handle nonce", handle.nonce.as_str()), ("resource handle nonce", handle.nonce.as_str()),
("resource handle revision", handle.revision.as_str()),
("resource handle content type", handle.content_type.as_str()), ("resource handle content type", handle.content_type.as_str()),
( (
"resource handle audit correlation id", "resource handle audit correlation id",
@@ -766,7 +751,6 @@ mod tests {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(), id: "bundle-1".to_string(),
digest: String::new(), digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(), created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance { provenance: ConfigBundleProvenance {
@@ -823,7 +807,6 @@ mod tests {
bundle.prompt_catalog = Some( bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new( worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]), std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]),
7,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -866,8 +849,6 @@ mod tests {
operation: crate::resource::BackendResourceOperation::FetchArchive, operation: crate::resource::BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: 4_102_444_800, expires_at_unix_seconds: 4_102_444_800,
nonce: "nonce-not-for-browser".to_string(), nonce: "nonce-not-for-browser".to_string(),
revision: "rev-1".to_string(),
generation: Some(1),
max_bytes: 128, max_bytes: 128,
content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
+1 -1
View File
@@ -693,7 +693,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
} }
/// Observe a newer immutable Workspace Prompt projection. Profile-backed /// Observe a newer immutable Workspace Prompt projection. Profile-backed
/// execution uses this as a revision notification; other backends may /// execution uses this as a content projection notification; other backends may
/// safely ignore it. /// safely ignore it.
fn observe_workspace_prompt_projection( fn observe_workspace_prompt_projection(
&self, &self,
+284 -19
View File
@@ -21,7 +21,9 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 9; const SCHEMA_VERSION: u32 = 10;
// Frozen source schemas: do not move their meaning when adding a migration.
const AGGREGATE_SCHEMA_VERSION: u32 = 9;
const PREVIOUS_SCHEMA_VERSION: u32 = 8; const PREVIOUS_SCHEMA_VERSION: u32 = 8;
const LEGACY_SCHEMA_VERSION: u32 = 7; const LEGACY_SCHEMA_VERSION: u32 = 7;
const OLDEST_SCHEMA_VERSION: u32 = 6; const OLDEST_SCHEMA_VERSION: u32 = 6;
@@ -609,8 +611,8 @@ fn plan_runtime_store_migration(
format!("Runtime store schema version {schema_version} is out of range"), format!("Runtime store schema version {schema_version} is out of range"),
) )
})?; })?;
let staging = migration_sibling(root, "schema-v9-staging")?; let staging = migration_sibling(root, "schema-v10-staging")?;
let backup = migration_sibling(root, "pre-schema-v9-backup")?; let backup = migration_sibling(root, "pre-schema-v10-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -639,12 +641,15 @@ fn plan_runtime_store_migration(
} }
if !matches!( if !matches!(
current_schema_version, current_schema_version,
OLDEST_SCHEMA_VERSION | LEGACY_SCHEMA_VERSION | PREVIOUS_SCHEMA_VERSION OLDEST_SCHEMA_VERSION
| LEGACY_SCHEMA_VERSION
| PREVIOUS_SCHEMA_VERSION
| AGGREGATE_SCHEMA_VERSION
) { ) {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
&runtime_path, &runtime_path,
format!( format!(
"unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, or {SCHEMA_VERSION}" "unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, {AGGREGATE_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
), ),
)); ));
} }
@@ -969,11 +974,30 @@ fn validate_migrated_worker_documents(
} }
fn migrate_worker_record( fn migrate_worker_record(
identity_document: serde_json::Value, mut identity_document: serde_json::Value,
source_schema_version: u32, source_schema_version: u32,
mapping: Option<&LegacyWorkerIdentityMapping>, mapping: Option<&LegacyWorkerIdentityMapping>,
identity_path: &Path, identity_path: &Path,
) -> Result<WorkerAggregateRecord, RuntimeError> { ) -> Result<WorkerAggregateRecord, RuntimeError> {
if source_schema_version == AGGREGATE_SCHEMA_VERSION {
migrate_v9_restore_guard(&mut identity_document, identity_path)?;
remove_legacy_job_input_counter(&mut identity_document);
migrate_legacy_request_evidence(&mut identity_document)
.map_err(|message| runtime_store_corrupt(identity_path, message.into()))?;
identity_document["schema_version"] = serde_json::Value::from(SCHEMA_VERSION);
let aggregate: WorkerAggregateRecord =
serde_json::from_value(identity_document).map_err(|error| {
runtime_store_corrupt(
identity_path,
format!("decode migrated schema-v9 Worker aggregate: {error}"),
)
})?;
aggregate.clone().validate(identity_path)?;
return Ok(aggregate);
}
remove_legacy_job_input_counter(&mut identity_document);
migrate_legacy_request_evidence(&mut identity_document)
.map_err(|message| runtime_store_corrupt(identity_path, message.into()))?;
if source_schema_version == PREVIOUS_SCHEMA_VERSION { if source_schema_version == PREVIOUS_SCHEMA_VERSION {
let identity: WorkerIdentityRecord = let identity: WorkerIdentityRecord =
serde_json::from_value(identity_document).map_err(|error| { serde_json::from_value(identity_document).map_err(|error| {
@@ -984,8 +1008,18 @@ fn migrate_worker_record(
})?; })?;
identity.validate_for_schema(identity_path, PREVIOUS_SCHEMA_VERSION)?; identity.validate_for_schema(identity_path, PREVIOUS_SCHEMA_VERSION)?;
let execution_path = identity_path.with_file_name(WORKER_EXECUTION_FILE); let execution_path = identity_path.with_file_name(WORKER_EXECUTION_FILE);
let execution: WorkerExecutionRecord = let mut execution_document: serde_json::Value =
read_bounded_json(&execution_path, "read schema-v8 Worker execution record")?; read_bounded_json(&execution_path, "read schema-v8 Worker execution record")?;
remove_legacy_job_input_counter(&mut execution_document);
migrate_legacy_request_evidence(&mut execution_document)
.map_err(|message| runtime_store_corrupt(&execution_path, message.into()))?;
let execution: WorkerExecutionRecord =
serde_json::from_value(execution_document).map_err(|error| {
runtime_store_corrupt(
&execution_path,
format!("decode migrated schema-v8 execution: {error}"),
)
})?;
execution.validate_for_schema(&identity, &execution_path, PREVIOUS_SCHEMA_VERSION)?; execution.validate_for_schema(&identity, &execution_path, PREVIOUS_SCHEMA_VERSION)?;
return Ok(WorkerAggregateRecord::from_v8(identity, execution)); return Ok(WorkerAggregateRecord::from_v8(identity, execution));
} }
@@ -1016,6 +1050,114 @@ fn migrate_worker_record(
Ok(WorkerAggregateRecord::from_v8(identity, execution)) Ok(WorkerAggregateRecord::from_v8(identity, execution))
} }
// Frozen schema-v9 conversion. Preserve the exact token and all Restore owners
// and receipts; generating a fresh token here would lose retry/admission authority.
fn migrate_v9_restore_guard(
document: &mut serde_json::Value,
path: &Path,
) -> Result<(), RuntimeError> {
let Some(guard) = document.get_mut("restore_guard") else {
return Ok(());
};
let guard = guard.as_object_mut().ok_or_else(|| {
runtime_store_corrupt(path, "schema-v9 restore_guard must be an object".into())
})?;
let token = guard.remove("generation").ok_or_else(|| {
runtime_store_corrupt(path, "schema-v9 restore_guard is missing generation".into())
})?;
if !token.as_str().is_some_and(|token| !token.is_empty())
|| guard.contains_key("observation_token")
{
return Err(runtime_store_corrupt(
path,
"schema-v9 Restore observation token is invalid or ambiguous".into(),
));
}
guard.insert("observation_token".into(), token);
Ok(())
}
// Legacy Runtime Job bindings never held input JSON, so the old caller-selected
// counter cannot become a content digest. Retain Job/attempt identity and leave
// content identity unknown until the host supplies its immutable intent.
fn remove_legacy_job_input_counter(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
if let Some(job) = object
.get_mut("backend_job")
.and_then(serde_json::Value::as_object_mut)
{
job.remove("input_revision");
}
for value in object.values_mut() {
remove_legacy_job_input_counter(value);
}
}
serde_json::Value::Array(values) => {
for value in values {
remove_legacy_job_input_counter(value);
}
}
_ => {}
}
}
// Frozen schema 6..9 request migration. Preserve concrete source/config digests,
// contents, operation ownership and fingerprints. Unknown legacy SSH key identity
// stays empty and cannot pass the materializer's live-authority validation.
fn migrate_legacy_request_evidence(value: &mut serde_json::Value) -> Result<(), &'static str> {
match value {
serde_json::Value::Object(object) => {
if object.contains_key("credential_id") && object.contains_key("credential_revision") {
let counter = object.remove("credential_revision").expect("checked field");
if !counter.as_u64().is_some_and(|value| value > 0) {
return Err("invalid legacy SSH credential evidence");
}
object
.entry("public_key_fingerprint")
.or_insert_with(|| serde_json::json!(""));
}
if object.contains_key("host_trust_id") && object.contains_key("host_trust_revision") {
let counter = object.remove("host_trust_revision").expect("checked field");
if !counter.as_u64().is_some_and(|value| value > 0) {
return Err("invalid legacy SSH host trust evidence");
}
object
.entry("host_key_fingerprint")
.or_insert_with(|| serde_json::json!(""));
}
if let Some(settings) = object
.get_mut("memory_settings")
.and_then(serde_json::Value::as_object_mut)
&& let Some(counter) = settings.remove("settings_revision")
&& !counter.as_u64().is_some_and(|value| value > 0)
{
return Err("invalid legacy Memory settings evidence");
}
if object.contains_key("source_fingerprint") {
object.remove("source_revision");
}
if object.contains_key("config_projection_digest") {
object.remove("config_revision");
}
if object.contains_key("resource_id") && object.contains_key("nonce") {
object.remove("revision");
object.remove("generation");
}
for value in object.values_mut() {
migrate_legacy_request_evidence(value)?;
}
}
serde_json::Value::Array(values) => {
for value in values {
migrate_legacy_request_evidence(value)?;
}
}
_ => {}
}
Ok(())
}
fn runtime_worker_name(worker_id: WorkerId) -> String { fn runtime_worker_name(worker_id: WorkerId) -> String {
format!("worker-runtime-{worker_id}") format!("worker-runtime-{worker_id}")
} }
@@ -1216,6 +1358,9 @@ fn migrate_runtime_document(
serde_json::Value::from(SCHEMA_VERSION), serde_json::Value::from(SCHEMA_VERSION),
); );
object.remove("workers"); object.remove("workers");
// This legacy cache is not Runtime lifecycle or restore authority. Bundles
// are reacquired from Workspace content authority, never renumbered/rehash-pinned here.
object.remove("config_bundles");
object.remove("next_worker_sequence"); object.remove("next_worker_sequence");
let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| { let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
runtime_store_corrupt( runtime_store_corrupt(
@@ -1325,8 +1470,14 @@ fn remove_runtime_migration_directory(path: &Path) -> Result<(), RuntimeError> {
} }
fn recover_runtime_store_migration(root: &Path) -> Result<(), RuntimeError> { fn recover_runtime_store_migration(root: &Path) -> Result<(), RuntimeError> {
let staging = migration_sibling(root, "schema-v9-staging")?; for suffix in ["schema-v9-staging", "pre-schema-v9-backup"] {
let backup = migration_sibling(root, "pre-schema-v9-backup")?; let earlier = migration_sibling(root, suffix)?;
if earlier.exists() {
return Err(runtime_store_corrupt(root, "unfinished schema-v9 migration requires explicit recovery before schema-v10 startup".into()));
}
}
let staging = migration_sibling(root, "schema-v10-staging")?;
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
let root_exists = migration_directory_exists(root)?; let root_exists = migration_directory_exists(root)?;
let staging_exists = migration_directory_exists(&staging)?; let staging_exists = migration_directory_exists(&staging)?;
let backup_exists = migration_directory_exists(&backup)?; let backup_exists = migration_directory_exists(&backup)?;
@@ -1528,8 +1679,8 @@ fn migrate_runtime_store(
if !plan.migration_required { if !plan.migration_required {
return Ok(plan); return Ok(plan);
} }
let staging = migration_sibling(root, "schema-v9-staging")?; let staging = migration_sibling(root, "schema-v10-staging")?;
let backup = migration_sibling(root, "pre-schema-v9-backup")?; let backup = migration_sibling(root, "pre-schema-v10-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -2875,7 +3026,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v9-staging").unwrap(); let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
fs::create_dir_all(&staging).unwrap(); fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("partial"), b"partial").unwrap(); fs::write(staging.join("partial"), b"partial").unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2890,7 +3041,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
fs::rename(&root, &backup).unwrap(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2904,10 +3055,10 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v9-staging").unwrap(); let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
copy_runtime_tree(&root, &root, &staging).unwrap(); copy_runtime_tree(&root, &root, &staging).unwrap();
migrate_runtime_store_in_place(&staging, "test-runtime").unwrap(); migrate_runtime_store_in_place(&staging, "test-runtime").unwrap();
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
fs::rename(&root, &backup).unwrap(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2922,10 +3073,10 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v9-staging").unwrap(); let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
fs::create_dir_all(&staging).unwrap(); fs::create_dir_all(&staging).unwrap();
fs::write(staging.join(RUNTIME_FILE), b"{\"schema_version\":8}").unwrap(); fs::write(staging.join(RUNTIME_FILE), b"{\"schema_version\":8}").unwrap();
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
fs::rename(&root, &backup).unwrap(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2942,7 +3093,7 @@ mod tests {
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
migrate_runtime_store_in_place(&root, "test-runtime").unwrap(); migrate_runtime_store_in_place(&root, "test-runtime").unwrap();
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
write_empty_schema_v8_store(&backup); write_empty_schema_v8_store(&backup);
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2950,6 +3101,120 @@ mod tests {
assert!(!backup.exists()); assert!(!backup.exists());
} }
#[test]
fn earlier_migration_backup_cannot_be_mistaken_for_a_fresh_runtime() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store");
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
write_empty_schema_v8_store(&backup);
let error = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap_err();
assert!(error.to_string().contains("explicit recovery"));
assert!(!root.exists());
assert!(backup.join(RUNTIME_FILE).exists());
}
#[test]
fn legacy_request_migration_preserves_content_and_never_invents_key_identity() {
let mut document = serde_json::json!({"request": {
"memory_settings": {"workspace_id":"workspace-a", "language":"English", "settings_revision":2},
"repository": {"source_revision":1, "source_fingerprint":"source-content"},
"materialization": {"operation_id":"op-a", "config_revision":2, "config_projection_digest":"projection-content", "ssh": {
"host_trust_id":"host-a", "host_trust_revision":3,
"credential_candidates":[{"credential_id":"credential-a", "credential_revision":4}]
}}
}});
migrate_legacy_request_evidence(&mut document).unwrap();
let request = &document["request"];
assert_eq!(request["memory_settings"]["language"], "English");
assert!(
request["memory_settings"]
.get("settings_revision")
.is_none()
);
assert_eq!(
request["repository"]["source_fingerprint"],
"source-content"
);
assert_eq!(
request["materialization"]["config_projection_digest"],
"projection-content"
);
assert_eq!(
request["materialization"]["ssh"]["host_key_fingerprint"],
""
);
assert_eq!(
request["materialization"]["ssh"]["credential_candidates"][0]["public_key_fingerprint"],
""
);
}
#[test]
fn schema_v9_restore_token_migrates_durably_without_rotating_admission_identity() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root);
let runtime_path = root.join(RUNTIME_FILE);
let mut runtime: serde_json::Value = read_json(&runtime_path, "read test Runtime").unwrap();
runtime["schema_version"] = serde_json::json!(AGGREGATE_SCHEMA_VERSION);
fs::write(&runtime_path, serde_json::to_vec(&runtime).unwrap()).unwrap();
let worker_id = WorkerId::now_v7();
let path = root
.join(WORKERS_DIR)
.join(worker_id.to_string())
.join(WORKER_FILE);
fs::create_dir_all(path.parent().unwrap()).unwrap();
let aggregate = migrate_worker_record(
schema_v7_worker_document(worker_id),
LEGACY_SCHEMA_VERSION,
None,
&path,
)
.unwrap();
let mut legacy = serde_json::to_value(&aggregate).unwrap();
let token = legacy["restore_guard"]["observation_token"].take();
legacy["restore_guard"]
.as_object_mut()
.unwrap()
.remove("observation_token");
legacy["restore_guard"]["generation"] = token.clone();
legacy["schema_version"] = serde_json::json!(AGGREGATE_SCHEMA_VERSION);
fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
store.store.load_runtime_state().unwrap();
drop(store);
let migrated: serde_json::Value = read_json(&path, "read migrated Worker").unwrap();
assert_eq!(migrated["schema_version"], SCHEMA_VERSION);
assert_eq!(migrated["restore_guard"]["observation_token"], token);
assert!(migrated["restore_guard"].get("generation").is_none());
let store = FsRuntimeStore::open_or_create(root, "test-runtime").unwrap();
store.store.load_runtime_state().unwrap();
assert_eq!(
read_json::<serde_json::Value>(&path, "read restarted Worker").unwrap(),
migrated
);
}
#[test]
fn frozen_restore_guard_conversion_preserves_receipts_and_rejects_ambiguous_identity() {
let path = Path::new("worker.json");
let mut document = serde_json::json!({"restore_guard": {
"generation": "opaque-admission-token", "active_request_id": "restore-a",
"owners": {"restore-a": {"fingerprint": "intent", "receipt": {"accepted": true}}}
}});
let owners = document["restore_guard"]["owners"].clone();
migrate_v9_restore_guard(&mut document, path).unwrap();
assert_eq!(document["restore_guard"]["owners"], owners);
assert_eq!(document["restore_guard"]["active_request_id"], "restore-a");
assert_eq!(
document["restore_guard"]["observation_token"],
"opaque-admission-token"
);
let mut ambiguous =
serde_json::json!({"restore_guard": {"generation": "old", "observation_token": "new"}});
assert!(migrate_v9_restore_guard(&mut ambiguous, path).is_err());
}
#[test] #[test]
fn bounded_worker_record_read_rejects_oversize_before_parse() { fn bounded_worker_record_read_rejects_oversize_before_parse() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
@@ -3047,7 +3312,7 @@ mod tests {
)); ));
assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists()); assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists());
let aggregate: WorkerAggregateRecord = let aggregate: WorkerAggregateRecord =
read_bounded_json(&identity_path, "read schema-v9 aggregate").unwrap(); read_bounded_json(&identity_path, "read schema-v10 aggregate").unwrap();
assert_eq!(aggregate.schema_version, SCHEMA_VERSION); assert_eq!(aggregate.schema_version, SCHEMA_VERSION);
} }
+585 -78
View File
@@ -413,7 +413,8 @@ pub struct RuntimeHttpWorkspacePromptProjectionRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkspacePromptProjectionResponse { pub struct RuntimeHttpWorkspacePromptProjectionResponse {
pub workspace_id: String, pub workspace_id: String,
pub config_revision: u64, pub source_digest: String,
pub projection_digest: String,
} }
/// Config bundle availability response used by sync/check endpoints. /// Config bundle availability response used by sync/check endpoints.
@@ -576,10 +577,10 @@ async fn post_workspace_verification_challenge(
})?; })?;
if challenge.workspace_id != verified.workspace_id if challenge.workspace_id != verified.workspace_id
|| challenge.runtime_id != verified.runtime_id || challenge.runtime_id != verified.runtime_id
|| challenge.binding_revision != verified.binding_revision || challenge.binding_id != verified.binding_id
|| challenge.workspace_key_id != verified.issuer_key_id || challenge.workspace_key_id != verified.issuer_key_id
|| challenge.workspace_identity_revision != verified.issuer_identity_revision || challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint
|| challenge.workspace_trust_generation != verified.trust_generation || challenge.workspace_trust_id != verified.trust_id
|| challenge.runtime_id != auth.signer.runtime_id() || challenge.runtime_id != auth.signer.runtime_id()
|| challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint() || challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
{ {
@@ -599,6 +600,13 @@ async fn post_workspace_verification_challenge(
error.to_string(), error.to_string(),
) )
})?; })?;
auth.verifications.begin(challenge).map_err(|error| {
RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"workspace_runtime_verification_unavailable",
error.to_string(),
)
})?;
Ok(Json(response)) Ok(Json(response))
} }
@@ -616,17 +624,17 @@ async fn post_workspace_verification_acknowledgement(
})?; })?;
if acknowledgement.workspace_id != verified.workspace_id if acknowledgement.workspace_id != verified.workspace_id
|| acknowledgement.runtime_id != verified.runtime_id || acknowledgement.runtime_id != verified.runtime_id
|| acknowledgement.binding_revision != verified.binding_revision || acknowledgement.binding_id != verified.binding_id
|| acknowledgement.workspace_key_id != verified.issuer_key_id || acknowledgement.workspace_key_id != verified.issuer_key_id
|| acknowledgement.workspace_identity_revision != verified.issuer_identity_revision || acknowledgement.workspace_public_key_fingerprint
|| acknowledgement.workspace_trust_generation != verified.trust_generation != verified.issuer_public_key_fingerprint
|| acknowledgement.workspace_trust_id != verified.trust_id
|| acknowledgement.runtime_id != auth.signer.runtime_id() || acknowledgement.runtime_id != auth.signer.runtime_id()
|| acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint() || acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|| acknowledgement.expires_at <= unix_now_i64() || acknowledgement.expires_at <= unix_now_i64()
|| acknowledgement.binding_revision == 0 || acknowledgement.binding_id.is_empty()
|| acknowledgement.runtime_identity_revision == 0 || acknowledgement.workspace_public_key_fingerprint.is_empty()
|| acknowledgement.workspace_identity_revision == 0 || acknowledgement.workspace_trust_id.is_empty()
|| acknowledgement.workspace_trust_generation == 0
|| acknowledgement.workspace_nonce.is_empty() || acknowledgement.workspace_nonce.is_empty()
|| acknowledgement.runtime_nonce.is_empty() || acknowledgement.runtime_nonce.is_empty()
|| acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len() || acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len()
@@ -641,12 +649,11 @@ async fn post_workspace_verification_acknowledgement(
challenge_id: acknowledgement.challenge_id.clone(), challenge_id: acknowledgement.challenge_id.clone(),
workspace_id: acknowledgement.workspace_id.clone(), workspace_id: acknowledgement.workspace_id.clone(),
runtime_id: acknowledgement.runtime_id.clone(), runtime_id: acknowledgement.runtime_id.clone(),
binding_revision: acknowledgement.binding_revision, binding_id: acknowledgement.binding_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(), workspace_key_id: acknowledgement.workspace_key_id.clone(),
workspace_identity_revision: acknowledgement.workspace_identity_revision, workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(),
workspace_trust_generation: acknowledgement.workspace_trust_generation, workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(), runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
runtime_identity_revision: acknowledgement.runtime_identity_revision,
workspace_nonce: acknowledgement.workspace_nonce.clone(), workspace_nonce: acknowledgement.workspace_nonce.clone(),
expires_at: acknowledgement.expires_at, expires_at: acknowledgement.expires_at,
}; };
@@ -689,17 +696,23 @@ async fn post_workspace_verification_acknowledgement(
) )
})?; })?;
auth.verifications auth.verifications
.record(WorkspaceRuntimeVerificationRecord { .acknowledge(
&acknowledgement.challenge_id,
WorkspaceRuntimeVerificationRecord {
workspace_id: acknowledgement.workspace_id.clone(), workspace_id: acknowledgement.workspace_id.clone(),
runtime_id: acknowledgement.runtime_id.clone(), runtime_id: acknowledgement.runtime_id.clone(),
binding_revision: acknowledgement.binding_revision, binding_id: acknowledgement.binding_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(), workspace_key_id: acknowledgement.workspace_key_id.clone(),
workspace_identity_revision: acknowledgement.workspace_identity_revision, workspace_public_key_fingerprint: acknowledgement
workspace_trust_generation: acknowledgement.workspace_trust_generation, .workspace_public_key_fingerprint
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(), .clone(),
runtime_identity_revision: acknowledgement.runtime_identity_revision, workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement
.runtime_public_key_fingerprint
.clone(),
verified_at: unix_now_i64(), verified_at: unix_now_i64(),
}) },
)
.map_err(|error| { .map_err(|error| {
RuntimeHttpRestError::new( RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
@@ -711,7 +724,7 @@ async fn post_workspace_verification_acknowledgement(
challenge_id: acknowledgement.challenge_id, challenge_id: acknowledgement.challenge_id,
workspace_id: acknowledgement.workspace_id, workspace_id: acknowledgement.workspace_id,
runtime_id: acknowledgement.runtime_id, runtime_id: acknowledgement.runtime_id,
binding_revision: acknowledgement.binding_revision, binding_id: acknowledgement.binding_id.clone(),
accepted_at: unix_now_i64(), accepted_at: unix_now_i64(),
})) }))
} }
@@ -810,14 +823,16 @@ async fn observe_workspace_prompt_projection(
)); ));
} }
let workspace_id = request.projection.workspace_id.clone(); let workspace_id = request.projection.workspace_id.clone();
let config_revision = request.projection.config_revision; let source_digest = request.projection.source_digest.clone();
let projection_digest = request.projection.projection_digest.clone();
state state
.runtime .runtime
.observe_workspace_prompt_projection(request.projection) .observe_workspace_prompt_projection(request.projection)
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse { Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse {
workspace_id, workspace_id,
config_revision, source_digest,
projection_digest,
})) }))
} }
@@ -951,9 +966,11 @@ async fn observe_repository_ref(
async fn list_working_directories( async fn list_working_directories(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> { ) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
let working_directories = state // Inventory providers may inspect the filesystem even for read-only requests.
.runtime let working_directories =
.list_working_directories() tokio::task::spawn_blocking(move || state.runtime.list_working_directories())
.await
.map_err(RuntimeHttpRestError::working_directory_join)?
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkingDirectoriesResponse { Ok(Json(RuntimeHttpWorkingDirectoriesResponse {
working_directories, working_directories,
@@ -989,9 +1006,10 @@ async fn get_working_directory(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
Path(working_directory_id): Path<String>, Path(working_directory_id): Path<String>,
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> { ) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
let working_directory = state let working_directory =
.runtime tokio::task::spawn_blocking(move || state.runtime.working_directory(&working_directory_id))
.working_directory(&working_directory_id) .await
.map_err(RuntimeHttpRestError::working_directory_join)?
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkingDirectoryResponse { Ok(Json(RuntimeHttpWorkingDirectoryResponse {
working_directory, working_directory,
@@ -1130,9 +1148,15 @@ async fn cleanup_working_directory(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
Path(working_directory_id): Path<String>, Path(working_directory_id): Path<String>,
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> { ) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
let working_directory = state // Keep Runtime's occupancy check and deletion together, but do not run
// synchronous filesystem/Git work on the HTTP executor.
let working_directory = tokio::task::spawn_blocking(move || {
state
.runtime .runtime
.cleanup_working_directory(&working_directory_id) .cleanup_working_directory(&working_directory_id)
})
.await
.map_err(RuntimeHttpRestError::working_directory_join)?
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkingDirectoryResponse { Ok(Json(RuntimeHttpWorkingDirectoryResponse {
working_directory, working_directory,
@@ -2099,7 +2123,7 @@ async fn require_runtime_auth(
let body_digest = workspace_request_body_digest(&body); let body_digest = workspace_request_body_digest(&body);
let expected = WorkspaceCapabilityExpectation { let expected = WorkspaceCapabilityExpectation {
workspace_id: &claims.issuer_workspace_id, workspace_id: &claims.issuer_workspace_id,
binding_revision: claims.binding_revision, binding_id: &claims.binding_id,
runtime_id: workspace_auth.signer.runtime_id(), runtime_id: workspace_auth.signer.runtime_id(),
worker_id: expected_worker_id.as_deref(), worker_id: expected_worker_id.as_deref(),
operation: required_permission, operation: required_permission,
@@ -2136,13 +2160,13 @@ async fn require_runtime_auth(
.into_response(); .into_response();
} }
}; };
if record.binding_revision != verified.binding_revision if record.binding_id != verified.binding_id
|| record.workspace_key_id != verified.issuer_key_id || record.workspace_key_id != verified.issuer_key_id
|| record.workspace_identity_revision != verified.issuer_identity_revision || record.workspace_public_key_fingerprint
|| record.workspace_trust_generation != verified.trust_generation != verified.issuer_public_key_fingerprint
|| record.workspace_trust_id != verified.trust_id
|| record.runtime_public_key_fingerprint || record.runtime_public_key_fingerprint
!= workspace_auth.signer.public_key_fingerprint() != workspace_auth.signer.public_key_fingerprint()
|| record.runtime_identity_revision == 0
{ {
return RuntimeHttpRestError::new( return RuntimeHttpRestError::new(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
@@ -2350,6 +2374,7 @@ impl RuntimeHttpWorkdirError {
Self { Self {
status, status,
payload: WorkdirTransportError { payload: WorkdirTransportError {
denial_reason: None,
code, code,
message: message.into(), message: message.into(),
}, },
@@ -2443,6 +2468,16 @@ impl RuntimeHttpRestError {
Self::new(status, code, error.to_string()) Self::new(status, code, error.to_string())
} }
fn working_directory_join(_error: tokio::task::JoinError) -> Self {
// A panic payload can contain paths or credentials. A missing result
// also does not prove that cleanup made no changes; do not claim rollback.
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"working_directory_task_failed",
"Working directory operation failed to return a result",
)
}
fn json_rejection(error: JsonRejection) -> Self { fn json_rejection(error: JsonRejection) -> Self {
Self::new( Self::new(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@@ -2762,9 +2797,8 @@ mod tests {
key_id: "workspace-key".to_string(), key_id: "workspace-key".to_string(),
algorithm: "ed25519".to_string(), algorithm: "ed25519".to_string(),
public_key: workspace_identity.public_key.clone(), public_key: workspace_identity.public_key.clone(),
public_key_fingerprint: workspace_fingerprint, public_key_fingerprint: workspace_fingerprint.clone(),
identity_revision: 1, trust_id: "trust-test".into(),
trust_generation: 1,
state: WorkspaceIssuerTrustState::Active, state: WorkspaceIssuerTrustState::Active,
registered_at_unix: 1, registered_at_unix: 1,
updated_at_unix: 1, updated_at_unix: 1,
@@ -2785,12 +2819,11 @@ mod tests {
challenge_id: "challenge-1".to_string(), challenge_id: "challenge-1".to_string(),
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-test".to_string(), runtime_id: "runtime-test".to_string(),
binding_revision: 4, binding_id: "binding-test".into(),
workspace_key_id: "workspace-key".to_string(), workspace_key_id: "workspace-key".to_string(),
workspace_identity_revision: 1, workspace_public_key_fingerprint: workspace_fingerprint.clone(),
workspace_trust_generation: 1, workspace_trust_id: "trust-test".into(),
runtime_public_key_fingerprint: runtime_fingerprint, runtime_public_key_fingerprint: runtime_fingerprint,
runtime_identity_revision: 1,
workspace_nonce: "workspace-nonce".to_string(), workspace_nonce: "workspace-nonce".to_string(),
expires_at: unix_now_i64() + 60, expires_at: unix_now_i64() + 60,
}; };
@@ -2799,9 +2832,9 @@ mod tests {
issuer: "https://backend.test".to_string(), issuer: "https://backend.test".to_string(),
issuer_workspace_id: "workspace-a".to_string(), issuer_workspace_id: "workspace-a".to_string(),
issuer_key_id: "workspace-key".to_string(), issuer_key_id: "workspace-key".to_string(),
issuer_identity_revision: 1, issuer_public_key_fingerprint: workspace_fingerprint.clone(),
trust_generation: 1, trust_id: "trust-test".into(),
binding_revision: 4, binding_id: "binding-test".into(),
runtime_id: "runtime-test".to_string(), runtime_id: "runtime-test".to_string(),
worker_id: None, worker_id: None,
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
@@ -2850,14 +2883,15 @@ mod tests {
challenge_id: verification_response.challenge_id.clone(), challenge_id: verification_response.challenge_id.clone(),
workspace_id: verification_response.workspace_id.clone(), workspace_id: verification_response.workspace_id.clone(),
runtime_id: verification_response.runtime_id.clone(), runtime_id: verification_response.runtime_id.clone(),
binding_revision: verification_response.binding_revision, binding_id: verification_response.binding_id.clone(),
workspace_key_id: verification_response.workspace_key_id.clone(), workspace_key_id: verification_response.workspace_key_id.clone(),
workspace_identity_revision: verification_response.workspace_identity_revision, workspace_public_key_fingerprint: verification_response
workspace_trust_generation: verification_response.workspace_trust_generation, .workspace_public_key_fingerprint
.clone(),
workspace_trust_id: verification_response.workspace_trust_id.clone(),
runtime_public_key_fingerprint: verification_response runtime_public_key_fingerprint: verification_response
.runtime_public_key_fingerprint .runtime_public_key_fingerprint
.clone(), .clone(),
runtime_identity_revision: verification_response.runtime_identity_revision,
workspace_nonce: verification_response.workspace_nonce.clone(), workspace_nonce: verification_response.workspace_nonce.clone(),
runtime_nonce: verification_response.runtime_nonce.clone(), runtime_nonce: verification_response.runtime_nonce.clone(),
response_digest: workspace_request_body_digest(&response_body), response_digest: workspace_request_body_digest(&response_body),
@@ -2954,13 +2988,11 @@ mod tests {
workspace_id: "workspace-b".to_string(), workspace_id: "workspace-b".to_string(),
source_runtime_id: "runtime-a".to_string(), source_runtime_id: "runtime-a".to_string(),
worker_id: retention_worker_id, worker_id: retention_worker_id,
expected_worker_revision: "revision".to_string(),
source_created_at: "2025-01-01T00:00:00Z".to_string(), source_created_at: "2025-01-01T00:00:00Z".to_string(),
removed_at: "2025-01-02T00:00:00Z".to_string(), removed_at: "2025-01-02T00:00:00Z".to_string(),
effective_profile: None, effective_profile: None,
retention_class: None, retention_class: None,
policy_id: "policy".to_string(), policy_id: "policy".to_string(),
policy_revision: 1,
session_disposition: SessionDisposition::Purge, session_disposition: SessionDisposition::Purge,
diagnostics_disposition: DiagnosticsDisposition::Retain, diagnostics_disposition: DiagnosticsDisposition::Retain,
}; };
@@ -2998,6 +3030,141 @@ mod tests {
assert_eq!(error.error.code, "worker_not_found"); assert_eq!(error.error.code, "worker_not_found");
} }
#[tokio::test]
async fn reenrolled_binding_trust_or_keys_require_fresh_signed_verification() {
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let fingerprint = |key: &str| {
format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(
crate::auth::decode_public_key(key).unwrap()
))
)
};
let old_trust = WorkspaceIssuerTrustRecord::from_bundle(
server_api::WorkspacePublicIdentityBundle {
workspace_id: "workspace-a".into(),
backend_url: "https://backend.test".into(),
key_id: "workspace-key".into(),
algorithm: "ed25519".into(),
public_key: workspace_identity.public_key.clone(),
public_key_fingerprint: fingerprint(&workspace_identity.public_key),
},
"old-trust".into(),
1,
)
.unwrap();
let old_verification = WorkspaceRuntimeVerificationRecord {
workspace_id: old_trust.workspace_id.clone(),
runtime_id: "runtime-test".into(),
binding_id: "old-binding".into(),
workspace_key_id: old_trust.key_id.clone(),
workspace_public_key_fingerprint: old_trust.public_key_fingerprint.clone(),
workspace_trust_id: old_trust.trust_id.clone(),
runtime_public_key_fingerprint: fingerprint(&runtime_identity.public_key),
verified_at: 1,
};
for changed in ["binding", "trust", "workspace_key", "runtime_key"] {
let mut current = old_verification.clone();
let mut trust = old_trust.clone();
let mut workspace_key = workspace_identity.clone();
let mut runtime_key = runtime_identity.clone();
match changed {
"binding" => current.binding_id = "reenrolled-binding".into(),
"trust" => {
trust.trust_id = "reaccepted-trust".into();
current.workspace_trust_id = trust.trust_id.clone();
}
"workspace_key" => {
workspace_key = RuntimeIdentityMaterial::generate("new-workspace-key").unwrap();
trust.key_id = workspace_key.identity_id.clone();
trust.public_key = workspace_key.public_key.clone();
trust.public_key_fingerprint = fingerprint(&workspace_key.public_key);
current.workspace_key_id = trust.key_id.clone();
current.workspace_public_key_fingerprint = trust.public_key_fingerprint.clone();
}
"runtime_key" => {
runtime_key = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
current.runtime_public_key_fingerprint = fingerprint(&runtime_key.public_key);
}
_ => unreachable!(),
}
let authority = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
authority.record(old_verification.clone()).unwrap();
let app = runtime_http_router_with_workspace_auth(
Runtime::new_memory(),
None,
WorkspaceRuntimeHttpAuth {
verifier: WorkspaceCapabilityVerifier::new(
vec![trust.clone()],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
signer: RuntimeVerificationSigner::from_identity(&runtime_key).unwrap(),
verifications: authority.clone(),
},
);
let claims = WorkspaceCapabilityClaims {
issuer: trust.backend_url.clone(),
issuer_workspace_id: trust.workspace_id.clone(),
issuer_key_id: trust.key_id.clone(),
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
trust_id: trust.trust_id.clone(),
binding_id: current.binding_id.clone(),
runtime_id: current.runtime_id.clone(),
worker_id: None,
operation: RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: "/v1/ping".into(),
body_digest: workspace_request_body_digest(&[]),
iat: unix_now_i64(),
exp: unix_now_i64() + 60,
jti: "stale-verification".into(),
};
for (expected, token_id) in [
(StatusCode::FORBIDDEN, "stale-verification"),
(StatusCode::OK, "fresh-verification"),
] {
if expected == StatusCode::OK {
authority.record(current.clone()).unwrap();
}
let token = issue_workspace_capability_token(
&workspace_key.signing_key().unwrap(),
&WorkspaceCapabilityClaims {
jti: token_id.into(),
..claims.clone()
},
)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
expected,
"{changed}: {}",
String::from_utf8_lossy(&body)
);
if expected == StatusCode::FORBIDDEN {
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(error.error.code, "workspace_runtime_verification_stale");
}
}
}
}
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
#[test] #[test]
fn runtime_protocol_replaces_serialized_tracked_source() { fn runtime_protocol_replaces_serialized_tracked_source() {
@@ -3087,7 +3254,6 @@ mod tests {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
id: "http-test-bundle".to_string(), id: "http-test-bundle".to_string(),
digest: String::new(), digest: String::new(),
revision: "test".to_string(),
workspace_id: "test-workspace".to_string(), workspace_id: "test-workspace".to_string(),
created_at: "test".to_string(), created_at: "test".to_string(),
provenance: ConfigBundleProvenance { provenance: ConfigBundleProvenance {
@@ -3144,7 +3310,6 @@ mod tests {
workspace_api: None, workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(), workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}), }),
subjektiv_attached: false, subjektiv_attached: false,
@@ -4341,34 +4506,378 @@ mod tests {
assert!(!error.to_string().contains('/')); assert!(!error.to_string().contains('/'));
} }
enum CleanupHttpOutcome {
Removed,
Rejected(&'static str),
Panicked,
}
struct CleanupHttpGate {
entered: tokio::sync::oneshot::Sender<()>,
release: std::sync::mpsc::Receiver<()>,
}
struct CleanupHttpBackend {
gate: Mutex<Option<CleanupHttpGate>>,
outcome: CleanupHttpOutcome,
}
fn removed_working_directory() -> WorkingDirectoryStatus {
WorkingDirectoryStatus {
summary: crate::catalog::WorkingDirectorySummary {
working_directory_id: "cleanup-test".to_string(),
display_name: None,
repository_id: "repository-test".to_string(),
creation_selector: None,
creation_ref: None,
creation_tree: None,
current_selector: None,
current_ref: None,
current_tree: None,
observed_at_epoch_seconds: None,
materializer_kind: crate::catalog::MaterializerKind::RuntimeGitClone,
cleanup_target: None,
status: crate::catalog::WorkingDirectoryStatusKind::NotFound,
cleanliness: None,
occupied_by: None,
},
}
}
impl WorkerExecutionBackend for CleanupHttpBackend {
fn backend_id(&self) -> &str {
"http-cleanup-test"
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
AcceptingBackend.spawn_worker(request)
}
fn dispatch_input(
&self,
worker_ref: &WorkerRef,
input: WorkerInput,
) -> WorkerExecutionResult {
AcceptingBackend.dispatch_input(worker_ref, input)
}
fn cleanup_working_directory(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, crate::working_directory::WorkingDirectoryDiagnostic>
{
assert_eq!(working_directory_id, "cleanup-test");
let gate = self.gate.lock().unwrap().take();
if let Some(gate) = gate {
gate.entered.send(()).expect("cleanup entry receiver");
// A watchdog also bounds a regression that blocks the sole
// Tokio thread, where an async timeout could not make progress.
gate.release
.recv_timeout(std::time::Duration::from_secs(10))
.expect("cleanup must be released after observations complete");
}
match self.outcome {
CleanupHttpOutcome::Removed => Ok(removed_working_directory()),
CleanupHttpOutcome::Rejected(code) => Err(
crate::working_directory::WorkingDirectoryDiagnostic::rejected(
code,
"Resolve the current blocker and retry removal.",
),
),
CleanupHttpOutcome::Panicked => panic!("private provider path /secret/cleanup"),
}
}
}
fn cleanup_http_router(
outcome: CleanupHttpOutcome,
gate: Option<CleanupHttpGate>,
) -> (Runtime, Router) {
let runtime = Runtime::with_execution_backend(
RuntimeOptions::default(),
Arc::new(CleanupHttpBackend {
gate: Mutex::new(gate),
outcome,
}),
)
.unwrap();
let identity = RuntimeIdentityMaterial::generate("runtime-cleanup-test").unwrap();
let workspace_identity =
RuntimeIdentityMaterial::generate("workspace-cleanup-key").unwrap();
let workspace_public_key =
crate::auth::decode_public_key(&workspace_identity.public_key).unwrap();
let workspace_fingerprint = format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(workspace_public_key))
);
let app = runtime_http_router_with_workspace_auth(
runtime.clone(),
Some("cleanup-token".to_string()),
WorkspaceRuntimeHttpAuth {
verifier: WorkspaceCapabilityVerifier::new(
vec![WorkspaceIssuerTrustRecord {
workspace_id: "local".to_string(),
backend_url: "https://backend.test".to_string(),
key_id: "workspace-cleanup-key".to_string(),
algorithm: "ed25519".to_string(),
public_key: workspace_identity.public_key,
public_key_fingerprint: workspace_fingerprint,
trust_id: "trust-cleanup-test".to_string(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: 1,
updated_at_unix: 1,
}],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
signer: RuntimeVerificationSigner::from_identity(&identity).unwrap(),
verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()),
},
);
(runtime, app)
}
#[tokio::test(flavor = "current_thread")]
async fn slow_working_directory_cleanup_does_not_block_http_ping() {
let (entered, started) = tokio::sync::oneshot::channel();
let (release, wait_for_release) = std::sync::mpsc::channel();
let (runtime, app) = cleanup_http_router(
CleanupHttpOutcome::Removed,
Some(CleanupHttpGate {
entered,
release: wait_for_release,
}),
);
runtime
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
"builtin:coder".to_string(),
)))
.unwrap();
let scope = RuntimeWorkspaceScope::new("local", "local-token");
let mut request = task_request("unrelated Worker remains observable during cleanup");
request.workspace_api = Some(WorkspaceApiRef {
workspace_id: "local".to_string(),
base_url: "https://backend.test".to_string(),
});
let worker = runtime.create_worker_scoped(&scope, request).unwrap();
assert!(worker.workdir_attachments.is_empty());
let worker_path = format!("/v1/workers/{}", worker.worker_id);
let path = "/v1/working-directories/cleanup-test";
let unauthorized = empty_request(app.clone(), Method::DELETE, path).await;
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let error: RuntimeHttpErrorResponse = read_json(unauthorized).await;
assert_eq!(error.error.code, "unauthorized");
// A synchronous RuntimeState lock can stall this sole executor thread,
// including Tokio timeouts. Release from an independent thread on either
// completion, assertion unwind, or watchdog expiry, and report expiry as
// failure even if observations resume after the forced release.
let (observed, wait_for_observations) = std::sync::mpsc::channel();
let watchdog = std::thread::spawn(move || {
let timed_out = matches!(
wait_for_observations.recv_timeout(std::time::Duration::from_secs(5)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
);
let _ = release.send(());
timed_out
});
let cleanup = tokio::spawn(authed_empty_request(
app.clone(),
Method::DELETE,
path,
"cleanup-token",
));
tokio::time::timeout(std::time::Duration::from_secs(2), started)
.await
.expect("cleanup provider must be entered")
.expect("cleanup entry signal");
// Unlike ping, these HTTP routes synchronously acquire RuntimeState.
// Exercise existing Worker paths so authorization/not-found shortcuts
// cannot make a global occupancy-lock regression pass unnoticed.
for observation_path in [
"/v1/workers".to_string(),
worker_path.clone(),
format!("{worker_path}/session?workspace_id=local"),
] {
let response = tokio::time::timeout(
std::time::Duration::from_secs(2),
authed_empty_request(app.clone(), Method::GET, &observation_path, "cleanup-token"),
)
.await
.unwrap_or_else(|_| panic!("{observation_path} must finish while cleanup is gated"));
let status = response.status();
let body: serde_json::Value = read_json(response).await;
assert_eq!(status, StatusCode::OK, "{observation_path}: {body}");
if observation_path == "/v1/workers" {
assert_eq!(body["workers"].as_array().unwrap().len(), 1, "{body}");
assert_eq!(
body["workers"][0]["worker_id"],
worker.worker_id.to_string()
);
} else if observation_path == worker_path {
assert_eq!(body["worker"]["worker_id"], worker.worker_id.to_string());
} else {
// This minimal fake has no session store. Its normal, typed
// availability result still requires the real scoped HTTP read.
assert_eq!(
body,
serde_json::to_value(runtime_api::WorkerSessionAvailability::Unavailable {
reason: runtime_api::WorkerSessionUnavailableReason::StorageUnavailable,
message: "retained session storage is unavailable".to_string(),
})
.unwrap(),
);
}
}
// Exercise the same scoped snapshot acquisition used by Runtime WS
// subscriptions, without requiring a socket or the ws-server feature.
let subscription = runtime
.subscribe_event_selector_scoped(
&scope,
protocol::subscription::EventSubscriptionSelector::RuntimeWorkers,
)
.expect("Runtime subscription snapshot must complete while cleanup is gated");
let protocol::subscription::SubscriptionSnapshot::Workers { workers } =
subscription.snapshot()
else {
panic!("expected Runtime Worker subscription snapshot");
};
assert_eq!(workers.len(), 1);
assert_eq!(workers[0].worker_id.as_str(), worker.worker_id.to_string());
let ping = tokio::time::timeout(
std::time::Duration::from_secs(2),
app.oneshot(
Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, "Bearer cleanup-token")
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "local")
.body(Body::empty())
.unwrap(),
),
)
.await
.expect("ping must finish while cleanup is gated")
.unwrap();
let ping_status = ping.status();
let ping_body: serde_json::Value = read_json(ping).await;
assert_eq!(ping_status, StatusCode::OK, "{ping_body}");
assert_eq!(ping_body["runtime_id"], "runtime-cleanup-test");
assert_eq!(ping_body["protocol_version"], RUNTIME_HTTP_PROTOCOL_VERSION);
assert!(
!cleanup.is_finished(),
"cleanup must still be gated after ping",
);
// The watchdog owns the release sender; dropping `observed` on an
// assertion failure also releases the provider instead of leaking a task.
let observations_completed = observed.send(()).is_ok();
let watchdog_expired = watchdog.join().unwrap();
assert!(
observations_completed && !watchdog_expired,
"Worker observations, subscription snapshot, and ping must finish before cleanup is released",
);
let response = tokio::time::timeout(std::time::Duration::from_secs(2), cleanup)
.await
.expect("released cleanup must finish")
.unwrap();
let status = response.status();
let body: serde_json::Value = read_json(response).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body,
serde_json::to_value(RuntimeHttpWorkingDirectoryResponse {
working_directory: removed_working_directory(),
})
.unwrap(),
);
}
#[tokio::test(flavor = "current_thread")]
async fn working_directory_cleanup_panic_returns_bounded_http_error() {
let (_, app) = cleanup_http_router(CleanupHttpOutcome::Panicked, None);
let response = authed_empty_request(
app,
Method::DELETE,
"/v1/working-directories/cleanup-test",
"cleanup-token",
)
.await;
let status = response.status();
let body: serde_json::Value = read_json(response).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}");
assert_eq!(
body,
serde_json::json!({
"error": {
"code": "working_directory_task_failed",
"message": "Working directory operation failed to return a result",
},
}),
);
}
#[tokio::test] #[tokio::test]
async fn cleanup_cause_codes_survive_http_error_serialization() { async fn cleanup_cause_codes_survive_http_error_serialization() {
let cases = [ let cases = [
("mount_present", StatusCode::CONFLICT), ("working_directory_not_found", StatusCode::NOT_FOUND),
("mount_check_unavailable", StatusCode::SERVICE_UNAVAILABLE), (
("permission_denied", StatusCode::FORBIDDEN), "working_directory_cleanup_mount_present",
("resource_busy", StatusCode::CONFLICT), StatusCode::CONFLICT,
("storage_unavailable", StatusCode::SERVICE_UNAVAILABLE), ),
("ownership_unknown", StatusCode::CONFLICT), (
("changes_present", StatusCode::CONFLICT), "working_directory_cleanup_mount_check_unavailable",
("changes_unknown", StatusCode::CONFLICT), StatusCode::SERVICE_UNAVAILABLE,
),
(
"working_directory_cleanup_permission_denied",
StatusCode::FORBIDDEN,
),
(
"working_directory_cleanup_resource_busy",
StatusCode::CONFLICT,
),
(
"working_directory_cleanup_storage_unavailable",
StatusCode::SERVICE_UNAVAILABLE,
),
(
"working_directory_cleanup_ownership_unknown",
StatusCode::CONFLICT,
),
(
"working_directory_cleanup_changes_present",
StatusCode::CONFLICT,
),
(
"working_directory_cleanup_changes_unknown",
StatusCode::CONFLICT,
),
]; ];
for (cause, status) in cases { for (code, expected_status) in cases {
let code = format!("working_directory_cleanup_{cause}"); let (_, app) = cleanup_http_router(CleanupHttpOutcome::Rejected(code), None);
let response = authed_empty_request(
app,
Method::DELETE,
"/v1/working-directories/cleanup-test",
"cleanup-token",
)
.await;
let status = response.status();
let json: serde_json::Value = read_json(response).await;
assert_eq!(status, expected_status, "{json}");
assert_eq!(json["error"]["code"], code, "{json}");
let diagnostic = crate::working_directory::WorkingDirectoryDiagnostic::rejected( let diagnostic = crate::working_directory::WorkingDirectoryDiagnostic::rejected(
&code, code,
"Resolve the current blocker and retry removal.", "Resolve the current blocker and retry removal.",
); );
let response = assert_eq!(
RuntimeHttpRestError::runtime(RuntimeError::WorkingDirectory(diagnostic)) json["error"]["message"],
.into_response(); RuntimeError::WorkingDirectory(diagnostic).to_string(),
assert_eq!(response.status(), status); "{json}",
let body = axum::body::to_bytes(response.into_body(), 2048) );
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"], code, "{json}");
assert!(json["error"]["message"].as_str().unwrap().contains("retry"));
} }
} }
@@ -4676,7 +5185,6 @@ mod ws_tests {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
id: "ws-test-bundle".to_string(), id: "ws-test-bundle".to_string(),
digest: String::new(), digest: String::new(),
revision: "test".to_string(),
workspace_id: "test".to_string(), workspace_id: "test".to_string(),
created_at: "test".to_string(), created_at: "test".to_string(),
provenance: ConfigBundleProvenance { provenance: ConfigBundleProvenance {
@@ -4732,7 +5240,6 @@ mod ws_tests {
workspace_api: None, workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(), workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}), }),
subjektiv_attached: false, subjektiv_attached: false,
+71 -9
View File
@@ -905,12 +905,11 @@ fn print_workspace_trust_mutation(
WorkspaceIssuerTrustMutation::Unchanged => "unchanged", WorkspaceIssuerTrustMutation::Unchanged => "unchanged",
}; };
println!( println!(
"Workspace issuer trust {action}: workspace={} key={} fingerprint={} identity_revision={} trust_generation={} state={:?}", "Workspace issuer trust {action}: workspace={} key={} fingerprint={} trust_id={} state={:?}",
record.workspace_id, record.workspace_id,
record.key_id, record.key_id,
record.public_key_fingerprint, record.public_key_fingerprint,
record.identity_revision, record.trust_id,
record.trust_generation,
record.state, record.state,
); );
} }
@@ -936,10 +935,38 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
} }
let contents = String::from_utf8(contents) let contents = String::from_utf8(contents)
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
let auth: RuntimeAuthFile = toml::from_str(&contents) let mut document: toml::Value = toml::from_str(&contents)
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
let mut migrated = false;
if let Some(records) = document
.get_mut("workspace_issuers")
.and_then(toml::Value::as_array_mut)
{
for value in records {
if value.get("identity_revision").is_some() || value.get("trust_generation").is_some() {
let legacy = serde_json::to_value(&*value)
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
let record =
worker_runtime::workspace_issuer::migrate_legacy_workspace_issuer_trust_record(
legacy,
)
.map_err(|_| ProcessError::auth("runtime legacy trust store is corrupt"))?;
*value = toml::Value::try_from(record).map_err(|_| {
ProcessError::auth("runtime auth migration serialization failed")
})?;
migrated = true;
}
}
}
let auth: RuntimeAuthFile = document
.try_into()
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
validate_workspace_issuer_trust_records(&auth.workspace_issuers) validate_workspace_issuer_trust_records(&auth.workspace_issuers)
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?; .map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
if migrated {
// Commit the revoked migration before exposing any issuer authority.
write_runtime_auth_file(path, &auth)?;
}
Ok(auth) Ok(auth)
} }
@@ -1398,7 +1425,6 @@ mod tests {
algorithm: "ed25519".to_string(), algorithm: "ed25519".to_string(),
public_key: identity.public_key, public_key: identity.public_key,
public_key_fingerprint: fingerprint, public_key_fingerprint: fingerprint,
revision: 1,
}) })
.unwrap(), .unwrap(),
) )
@@ -1419,7 +1445,38 @@ mod tests {
let path = runtime_auth_path(&config); let path = runtime_auth_path(&config);
let first = read_runtime_auth_file(&path).unwrap(); let first = read_runtime_auth_file(&path).unwrap();
assert_eq!(first.workspace_issuers.len(), 1); assert_eq!(first.workspace_issuers.len(), 1);
assert_eq!(first.workspace_issuers[0].trust_generation, 1); assert!(!first.workspace_issuers[0].trust_id.is_empty());
// `trust-workspace show` serializes this persisted record. Owner
// registration must forward that exact enrollment ID, not derive one
// from the key fingerprint or an initial counter.
let shown = serde_json::to_value(&first.workspace_issuers[0]).unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("remote-runtime").unwrap();
let mut registration_wire = serde_json::json!({
"public_bundle": {
"identity_id": runtime_identity.identity_id,
"public_key": runtime_identity.public_key,
},
"endpoint": "https://runtime.example.test",
"workspace_trust_id": shown["trust_id"],
});
let registration: server_api::CreateRemoteRuntimeRequest =
serde_json::from_value(registration_wire.clone()).unwrap();
assert_eq!(
registration.workspace_trust_id,
first.workspace_issuers[0].trust_id
);
assert_ne!(
registration.workspace_trust_id,
first.workspace_issuers[0].public_key_fingerprint
);
registration_wire
.as_object_mut()
.unwrap()
.remove("workspace_trust_id");
assert!(
serde_json::from_value::<server_api::CreateRemoteRuntimeRequest>(registration_wire)
.is_err()
);
run_trust_workspace_command(VecDeque::from([ run_trust_workspace_command(VecDeque::from([
"add".to_string(), "add".to_string(),
@@ -1430,7 +1487,10 @@ mod tests {
])) ]))
.unwrap(); .unwrap();
let replay = read_runtime_auth_file(&path).unwrap(); let replay = read_runtime_auth_file(&path).unwrap();
assert_eq!(replay.workspace_issuers[0].trust_generation, 1); assert_eq!(
replay.workspace_issuers[0].trust_id,
first.workspace_issuers[0].trust_id
);
let replacement = RuntimeIdentityMaterial::generate("WK-2").unwrap(); let replacement = RuntimeIdentityMaterial::generate("WK-2").unwrap();
let public_key = decode_public_key(&replacement.public_key).unwrap(); let public_key = decode_public_key(&replacement.public_key).unwrap();
@@ -1451,7 +1511,6 @@ mod tests {
algorithm: "ed25519".to_string(), algorithm: "ed25519".to_string(),
public_key: replacement.public_key, public_key: replacement.public_key,
public_key_fingerprint: fingerprint, public_key_fingerprint: fingerprint,
revision: 2,
}) })
.unwrap(), .unwrap(),
) )
@@ -1483,7 +1542,10 @@ mod tests {
])) ]))
.unwrap(); .unwrap();
let revoked = read_runtime_auth_file(&path).unwrap(); let revoked = read_runtime_auth_file(&path).unwrap();
assert_eq!(revoked.workspace_issuers[0].trust_generation, 3); assert_ne!(
revoked.workspace_issuers[0].trust_id,
first.workspace_issuers[0].trust_id
);
assert_eq!( assert_eq!(
revoked.workspace_issuers[0].state, revoked.workspace_issuers[0].state,
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
+3 -8
View File
@@ -17,7 +17,7 @@ pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecretCandidate { pub struct RepositorySshAccessSecretCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, pub public_key_fingerprint: String,
pub private_key: String, pub private_key: String,
} }
@@ -32,7 +32,7 @@ impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
formatter formatter
.debug_struct("RepositorySshAccessSecretCandidate") .debug_struct("RepositorySshAccessSecretCandidate")
.field("credential_id", &self.credential_id) .field("credential_id", &self.credential_id)
.field("credential_revision", &self.credential_revision) .field("public_key_fingerprint", &self.public_key_fingerprint)
.field("private_key", &"[REDACTED]") .field("private_key", &"[REDACTED]")
.finish() .finish()
} }
@@ -89,9 +89,6 @@ pub struct BackendResourceHandle {
pub operation: BackendResourceOperation, pub operation: BackendResourceOperation,
pub expires_at_unix_seconds: i64, pub expires_at_unix_seconds: i64,
pub nonce: String, pub nonce: String,
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
pub max_bytes: u64, pub max_bytes: u64,
pub content_type: String, pub content_type: String,
pub redaction: ResourceRedactionPolicy, pub redaction: ResourceRedactionPolicy,
@@ -425,8 +422,6 @@ mod tests {
operation: BackendResourceOperation::FetchArchive, operation: BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: 4_102_444_800, expires_at_unix_seconds: 4_102_444_800,
nonce: "nonce-test".to_string(), nonce: "nonce-test".to_string(),
revision: sha256_hex(bytes),
generation: Some(1),
max_bytes: 1024, max_bytes: 1024,
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly, redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -477,7 +472,7 @@ mod tests {
let secret = RepositorySshAccessSecret { let secret = RepositorySshAccessSecret {
credential_candidates: vec![RepositorySshAccessSecretCandidate { credential_candidates: vec![RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 2, public_key_fingerprint: "key-2".into(),
private_key: "PRIVATE KEY secret bytes".to_string(), private_key: "PRIVATE KEY secret bytes".to_string(),
}], }],
known_hosts_entry: "host key secret bytes".to_string(), known_hosts_entry: "host key secret bytes".to_string(),
-8
View File
@@ -116,13 +116,11 @@ pub struct WorkerRetentionExecutionRequest {
pub workspace_id: String, pub workspace_id: String,
pub source_runtime_id: String, pub source_runtime_id: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub expected_worker_revision: String,
pub source_created_at: String, pub source_created_at: String,
pub removed_at: String, pub removed_at: String,
pub effective_profile: Option<String>, pub effective_profile: Option<String>,
pub retention_class: Option<String>, pub retention_class: Option<String>,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition, pub diagnostics_disposition: DiagnosticsDisposition,
} }
@@ -145,7 +143,6 @@ pub struct WorkerSessionArchiveManifest {
pub content_bytes: u64, pub content_bytes: u64,
pub content_file_count: u64, pub content_file_count: u64,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String, pub operation_id: String,
pub input_fingerprint: String, pub input_fingerprint: String,
} }
@@ -154,7 +151,6 @@ pub struct WorkerSessionArchiveManifest {
pub struct WorkerRetentionExecutionResult { pub struct WorkerRetentionExecutionResult {
pub operation_id: String, pub operation_id: String,
pub input_fingerprint: String, pub input_fingerprint: String,
pub expected_worker_revision: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition, pub diagnostics_disposition: DiagnosticsDisposition,
@@ -524,7 +520,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
let mut result = WorkerRetentionExecutionResult { let mut result = WorkerRetentionExecutionResult {
operation_id: request.operation_id.clone(), operation_id: request.operation_id.clone(),
input_fingerprint: request.input_fingerprint.clone(), input_fingerprint: request.input_fingerprint.clone(),
expected_worker_revision: request.expected_worker_revision.clone(),
worker_id: request.worker_id, worker_id: request.worker_id,
session_disposition: request.session_disposition, session_disposition: request.session_disposition,
diagnostics_disposition: request.diagnostics_disposition, diagnostics_disposition: request.diagnostics_disposition,
@@ -749,7 +744,6 @@ fn commit_session_archive(
content_bytes: bytes, content_bytes: bytes,
content_file_count: count, content_file_count: count,
policy_id: request.policy_id.clone(), policy_id: request.policy_id.clone(),
policy_revision: request.policy_revision,
operation_id: request.operation_id.clone(), operation_id: request.operation_id.clone(),
input_fingerprint: request.input_fingerprint.clone(), input_fingerprint: request.input_fingerprint.clone(),
}; };
@@ -1290,7 +1284,6 @@ mod tests {
WorkerRetentionExecutionRequest { WorkerRetentionExecutionRequest {
operation_id: "operation-a".to_string(), operation_id: "operation-a".to_string(),
input_fingerprint: "fingerprint-a".to_string(), input_fingerprint: "fingerprint-a".to_string(),
expected_worker_revision: "revision-a".to_string(),
archive_id: (disposition == SessionDisposition::Archive) archive_id: (disposition == SessionDisposition::Archive)
.then(|| "archive-a".to_string()), .then(|| "archive-a".to_string()),
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
@@ -1301,7 +1294,6 @@ mod tests {
effective_profile: Some("builtin:coder".to_string()), effective_profile: Some("builtin:coder".to_string()),
retention_class: None, retention_class: None,
policy_id: "policy-a".to_string(), policy_id: "policy-a".to_string(),
policy_revision: 3,
session_disposition: disposition, session_disposition: disposition,
diagnostics_disposition: DiagnosticsDisposition::Purge, diagnostics_disposition: DiagnosticsDisposition::Purge,
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
//! Retain a Runtime active-use reservation for a live provider session.
//!
//! Cloned handles share the reservation. A successful provider close releases
//! it even while closed handles remain; a failed or cancelled close retains it
//! until a later successful close or the final wrapper is dropped. Dropping the
//! wrapper does not close the provider or wait for outstanding commands, so
//! callers must keep a session handle alive while using provider-owned work.
use super::WorkdirOperationGuard;
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
use workdir::{
CheckoutObservation, CheckoutRequest, CheckoutResult, CheckoutSearchRequest,
CheckoutSearchResult, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest,
CommandRequest, CommandSnapshot, CommandStatus, EditRequest, EditResult, GlobRequest,
GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadBytesRequest,
ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError,
WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
};
pub(super) fn retain_operation(
session: WorkdirSessionHandle,
operation: WorkdirOperationGuard,
) -> WorkdirSessionHandle {
Arc::new(RetainedWorkdirSession {
inner: session,
operation: Mutex::new(Some(operation)),
})
}
struct RetainedWorkdirSession {
inner: WorkdirSessionHandle,
operation: Mutex<Option<WorkdirOperationGuard>>,
}
impl std::fmt::Debug for RetainedWorkdirSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Do not expose provider internals or reservation state containing host paths.
f.debug_struct("RetainedWorkdirSession")
.field("workdir", self.inner.workdir())
.field("capabilities", &self.inner.capabilities())
.finish_non_exhaustive()
}
}
#[async_trait]
impl WorkdirSession for RetainedWorkdirSession {
fn workdir(&self) -> &Workdir {
self.inner.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.inner.capabilities()
}
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
self.inner.authorize_scope_path(request).await
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.inner.scope_rules_overlap(request).await
}
async fn checkout_search(
&self,
request: CheckoutSearchRequest,
) -> Result<CheckoutSearchResult, WorkdirError> {
self.inner.checkout_search(request).await
}
async fn checkout_observe(
&self,
path: WorkdirPath,
) -> Result<CheckoutObservation, WorkdirError> {
self.inner.checkout_observe(path).await
}
async fn checkout_execute(
&self,
request: CheckoutRequest,
) -> Result<CheckoutResult, WorkdirError> {
self.inner.checkout_execute(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.inner.stat(request).await
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.inner.read(request).await
}
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
self.inner.read_bytes(request).await
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
self.inner.write(request).await
}
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
self.inner.edit(request).await
}
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
self.inner.list(request).await
}
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
self.inner.glob(request).await
}
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
self.inner.grep(request).await
}
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.inner.start_command(request).await
}
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
self.inner.command_status(handle).await
}
async fn command_output(
&self,
request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
self.inner.command_output(request).await
}
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
self.inner.cancel_command(handle).await
}
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
self.inner.subscribe_command_events()
}
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
self.inner.command_snapshot()
}
async fn close(&self) -> Result<(), WorkdirError> {
// Provider close must succeed before releasing the active-use reservation.
// No wrapper-local terminal checks may bypass the provider's own checks.
self.inner.close().await?;
let operation = self
.operation
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.take();
// Release outside the mutex: guard Drop may acquire Runtime state locks.
drop(operation);
Ok(())
}
}
@@ -374,7 +374,6 @@ fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiv
content_bytes: manifest.content_bytes, content_bytes: manifest.content_bytes,
content_file_count: manifest.content_file_count, content_file_count: manifest.content_file_count,
policy_id: manifest.policy_id.clone(), policy_id: manifest.policy_id.clone(),
policy_revision: manifest.policy_revision,
operation_id: manifest.operation_id.clone(), operation_id: manifest.operation_id.clone(),
input_fingerprint: manifest.input_fingerprint.clone(), input_fingerprint: manifest.input_fingerprint.clone(),
} }
+82 -62
View File
@@ -133,7 +133,7 @@ use worker::feature::builtin::{
WorkspaceClientWorkerObservationProvider, WorkspaceClientWorkerObservationProvider,
}; };
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session}; use worker::ipc::protocol_session::{WorkerProtocolSessionStreams, live_log_entry_event};
use worker::{ use worker::{
PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle, PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle,
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
@@ -142,6 +142,15 @@ use worker::{
bash_output_dir_for_worker_id, bash_output_dir_for_worker_id,
}; };
#[cfg(feature = "ws-server")]
fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtocolSessionStreams {
// Child state changes and their live events share this publication gate.
// Acquire it before subscribing, and retain it through the parent snapshot
// capture, so a pre-snapshot child event cannot replay after newer state.
let _publish_guard = handle.protocol_snapshot_publish_guard();
worker::ipc::protocol_session::subscribe_worker_protocol_session(handle)
}
const DEFAULT_BACKEND_ID: &str = "worker-crate"; const DEFAULT_BACKEND_ID: &str = "worker-crate";
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10); const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
const SPAWN_RESTORE_TASK_TIMEOUT: Duration = Duration::from_secs(60); const SPAWN_RESTORE_TASK_TIMEOUT: Duration = Duration::from_secs(60);
@@ -403,26 +412,12 @@ impl WorkspacePromptProjectionCache {
.active .active
.lock() .lock()
.map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?; .map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?;
if let Some(current) = active.get(&workspace_id) { // Runtime receives authoritative projections in operation/arrival order.
if current.projection.config_revision > resolution.projection.config_revision { // Equal content reuses the compiled catalog; counters are not authority.
return Ok(current.clone()); if let Some(current) = active.get(&workspace_id)
} && current.projection == resolution.projection
if current.projection.config_revision == resolution.projection.config_revision
&& (current.projection.source_digest != resolution.projection.source_digest
|| current.projection.projection_digest
!= resolution.projection.projection_digest
|| current.projection.catalog.catalog_digest
!= resolution.projection.catalog.catalog_digest
|| current.projection.catalog.schema_fingerprint
!= resolution.projection.catalog.schema_fingerprint
|| current.projection.catalog.toolchain_fingerprint
!= resolution.projection.catalog.toolchain_fingerprint)
{ {
return Err(format!( return Ok(current.clone());
"Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}",
resolution.projection.config_revision
));
}
} }
active.insert(workspace_id, resolution.clone()); active.insert(workspace_id, resolution.clone());
Ok(resolution) Ok(resolution)
@@ -688,7 +683,7 @@ impl ProfileRuntimeWorkerFactory {
None => fallback, None => fallback,
}; };
// Saved policy is execution authority. Rebinding current settings here // Saved policy is execution authority. Rebinding current settings here
// would hide revision and attachment mismatches. // would hide content and attachment mismatches.
validate_worker_memory_settings(&manifest, request)?; validate_worker_memory_settings(&manifest, request)?;
Ok((manifest, loader)) Ok((manifest, loader))
} }
@@ -1025,7 +1020,9 @@ fn runtime_local_workdir_router(
command_output_scope.clone(), command_output_scope.clone(),
capabilities, capabilities,
binding.command_environment(), binding.command_environment(),
binding.session_resources(), binding.session_resources().map_err(|error| {
format!("open Workdir attachment `{alias}` resources: {error}")
})?,
), ),
) )
.map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?; .map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?;
@@ -1193,11 +1190,8 @@ fn validate_worker_memory_settings(
"Workspace Worker restored without its bound Memory settings snapshot".to_string() "Workspace Worker restored without its bound Memory settings snapshot".to_string()
})?; })?;
return Err(format!( return Err(format!(
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}", "Workspace Worker Memory settings snapshot mismatch: expected {} language {}, restored {} language {}",
expected.workspace_id, expected.workspace_id, expected.language, actual.workspace_id, actual.language
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
)); ));
} }
let subjektiv_settings = manifest.feature.subjektiv.workspace_settings(); let subjektiv_settings = manifest.feature.subjektiv.workspace_settings();
@@ -1214,11 +1208,8 @@ fn validate_worker_memory_settings(
.to_string() .to_string()
})?; })?;
return Err(format!( return Err(format!(
"Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}", "Workspace Worker subjektiv settings snapshot mismatch: expected {} language {}, restored {} language {}",
expected.workspace_id, expected.workspace_id, expected.language, actual.workspace_id, actual.language
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
)); ));
} }
} else if subjektiv_settings.is_some() { } else if subjektiv_settings.is_some() {
@@ -3453,7 +3444,7 @@ where
scope, scope,
WorkdirSessionCapabilities::ALL, WorkdirSessionCapabilities::ALL,
binding.command_environment(), binding.command_environment(),
binding.session_resources(), binding.session_resources()?,
)) ))
} }
@@ -4787,7 +4778,6 @@ mod tests {
request.subjektiv_attached = true; request.subjektiv_attached = true;
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-saved-subjektiv".to_string(), workspace_id: "workspace-saved-subjektiv".to_string(),
settings_revision: 17,
language: "English".to_string(), language: "English".to_string(),
}); });
bind_workspace_memory_settings(&mut saved, &request).unwrap(); bind_workspace_memory_settings(&mut saved, &request).unwrap();
@@ -5980,7 +5970,6 @@ mod tests {
); );
let mut catalog = worker::EffectivePromptCatalog::new( let mut catalog = worker::EffectivePromptCatalog::new(
templates, templates,
17,
projection.schema_fingerprint.clone(), projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(), projection.toolchain_fingerprint.clone(),
) )
@@ -6003,7 +5992,6 @@ mod tests {
missing_instruction_bundle.prompt_catalog = Some( missing_instruction_bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new( worker::EffectivePromptCatalog::new(
templates, templates,
17,
projection.schema_fingerprint.clone(), projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(), projection.toolchain_fingerprint.clone(),
) )
@@ -6113,19 +6101,19 @@ mod tests {
.with_runtime_store_dir(&runtime_store) .with_runtime_store_dir(&runtime_store)
.with_runtime_id("runtime-subjektiv-test"); .with_runtime_id("runtime-subjektiv-test");
let before = persisted_files(&runtime_store); let before = persisted_files(&runtime_store);
let mut wrong_revision = request.clone(); let mut wrong_settings = request.clone();
wrong_revision wrong_settings
.request .request
.memory_settings .memory_settings
.as_mut() .as_mut()
.unwrap() .unwrap()
.settings_revision = 18; .language = "different language".into();
let mut detached = request.clone(); let mut detached = request.clone();
detached.request.subjektiv_attached = false; detached.request.subjektiv_attached = false;
let mut missing_settings = request.clone(); let mut missing_settings = request.clone();
missing_settings.request.memory_settings = None; missing_settings.request.memory_settings = None;
for (invalid, expected) in [ for (invalid, expected) in [
(wrong_revision, "subjektiv settings snapshot mismatch"), (wrong_settings, "subjektiv settings snapshot mismatch"),
(detached, "unauthorized subjektiv attachment"), (detached, "unauthorized subjektiv attachment"),
(missing_settings, "missing its trusted settings snapshot"), (missing_settings, "missing its trusted settings snapshot"),
] { ] {
@@ -6658,7 +6646,6 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default(); let cache = WorkspacePromptProjectionCache::default();
let catalog_v1 = worker::EffectivePromptCatalog::new( let catalog_v1 = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt-v1".to_string())]), BTreeMap::from([("default".to_string(), "prompt-v1".to_string())]),
8,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -6672,7 +6659,6 @@ mod tests {
.unwrap(); .unwrap();
let catalog_v2 = worker::EffectivePromptCatalog::new( let catalog_v2 = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]), BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]),
9,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -6689,7 +6675,7 @@ mod tests {
cache.observe(projection_v2).unwrap(); cache.observe(projection_v2).unwrap();
let active = cache.active("workspace-a").unwrap().unwrap(); let active = cache.active("workspace-a").unwrap().unwrap();
assert_eq!(active.projection.config_revision, 9); assert_eq!(active.projection.source_digest, "source-v2");
assert_eq!( assert_eq!(
active.projection.catalog.catalog_digest, active.projection.catalog.catalog_digest,
catalog_v2.catalog_digest catalog_v2.catalog_digest
@@ -6697,10 +6683,9 @@ mod tests {
} }
#[test] #[test]
fn workspace_prompt_projection_cache_rejects_same_revision_source_drift() { fn workspace_prompt_projection_cache_accepts_authoritative_source_change() {
let catalog = worker::EffectivePromptCatalog::new( let catalog = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt".to_string())]), BTreeMap::from([("default".to_string(), "prompt".to_string())]),
8,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -6722,22 +6707,20 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default(); let cache = WorkspacePromptProjectionCache::default();
cache.observe(first).unwrap(); cache.observe(first).unwrap();
let error = cache.observe(drifted).unwrap_err(); assert_eq!(
assert!( cache.observe(drifted).unwrap().projection.source_digest,
error "source-b"
.to_string()
.contains("without a config revision transition")
); );
} }
#[test] #[test]
fn workspace_prompt_projection_cache_rejects_same_revision_schema_drift() { fn workspace_prompt_projection_cache_accepts_authoritative_toolchain_change() {
let templates = BTreeMap::from([("default".to_string(), "prompt".to_string())]); let templates = BTreeMap::from([("default".to_string(), "prompt".to_string())]);
let first_catalog = let first_catalog =
worker::EffectivePromptCatalog::new(templates.clone(), 8, "schema-a", "toolchain") worker::EffectivePromptCatalog::new(templates.clone(), "schema-a", "toolchain")
.unwrap(); .unwrap();
let drifted_catalog = let drifted_catalog =
worker::EffectivePromptCatalog::new(templates, 8, "schema-b", "toolchain").unwrap(); worker::EffectivePromptCatalog::new(templates, "schema-b", "toolchain").unwrap();
let first = worker::WorkspacePromptProjection::new( let first = worker::WorkspacePromptProjection::new(
"workspace-a", "workspace-a",
"source-a", "source-a",
@@ -6755,8 +6738,14 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default(); let cache = WorkspacePromptProjectionCache::default();
cache.observe(first).unwrap(); cache.observe(first).unwrap();
let error = cache.observe(drifted).unwrap_err(); assert_eq!(
assert!(error.contains("without a config revision transition")); cache
.observe(drifted)
.unwrap()
.projection
.schema_fingerprint,
"schema-b"
);
} }
#[test] #[test]
@@ -7163,7 +7152,6 @@ mod tests {
metadata: crate::config_bundle::ConfigBundleMetadata { metadata: crate::config_bundle::ConfigBundleMetadata {
id: "adapter-test-bundle".to_string(), id: "adapter-test-bundle".to_string(),
digest: String::new(), digest: String::new(),
revision: "test".to_string(),
workspace_id: "adapter-test".to_string(), workspace_id: "adapter-test".to_string(),
created_at: "test".to_string(), created_at: "test".to_string(),
provenance: crate::config_bundle::ConfigBundleProvenance { provenance: crate::config_bundle::ConfigBundleProvenance {
@@ -7271,7 +7259,7 @@ mod tests {
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding { request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
job_id: "job-1".into(), job_id: "job-1".into(),
attempt_id: "attempt-1".into(), attempt_id: "attempt-1".into(),
input_revision: None, input_digest: None,
subjektiv_consolidation: granted, subjektiv_consolidation: granted,
}); });
for tools in [false, true] { for tools in [false, true] {
@@ -7327,7 +7315,6 @@ mod tests {
}; };
let settings = manifest::WorkspaceMemorySettingsSnapshot { let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-subject".to_string(), workspace_id: "workspace-subject".to_string(),
settings_revision: 3,
language: "English".to_string(), language: "English".to_string(),
}; };
let mut ordinary_request = create_request("ordinary"); let mut ordinary_request = create_request("ordinary");
@@ -7985,7 +7972,6 @@ mod tests {
kind: server_api::RepositorySourceKind::LocalPath, kind: server_api::RepositorySourceKind::LocalPath,
uri: repo.display().to_string(), uri: repo.display().to_string(),
}, },
source_revision: 1,
source_fingerprint: "sha256:test".to_string(), source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")), selector: Some(RepositorySelector::from("HEAD")),
}, },
@@ -8195,7 +8181,6 @@ mod tests {
); );
let mut effective = worker::EffectivePromptCatalog::new( let mut effective = worker::EffectivePromptCatalog::new(
templates, templates,
7,
projection.schema_fingerprint.clone(), projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(), projection.toolchain_fingerprint.clone(),
) )
@@ -8211,7 +8196,7 @@ mod tests {
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(resolution.projection.config_revision, 7); assert_eq!(resolution.projection.source_digest, "source-7");
assert_eq!( assert_eq!(
resolution.catalog.notify_wrapper("restored").unwrap(), resolution.catalog.notify_wrapper("restored").unwrap(),
"PENDING-LAUNCH restored" "PENDING-LAUNCH restored"
@@ -8248,7 +8233,6 @@ mod tests {
}); });
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-restore".to_string(), workspace_id: "workspace-restore".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}); });
let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap(); let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap();
@@ -9501,6 +9485,42 @@ mod tests {
assert_eq!(status.summary.cleanliness.as_deref(), Some("clean")); assert_eq!(status.summary.cleanliness.as_deref(), Some("clean"));
} }
#[cfg(target_os = "linux")]
#[tokio::test]
async fn local_sessions_exclude_cleanup_until_close_releases_resources() {
let runtime_base = tempfile::tempdir().unwrap();
let repo = create_clean_repo();
let materializer = RuntimeGitMaterializer::new(runtime_base.path());
let binding = materializer
.create(&working_directory_request(repo.path()))
.unwrap();
let id = &binding.working_directory.id;
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap()
.with_working_directory_materializer(materializer.clone());
let session = backend.open_workdir_session(id).unwrap();
let session_clone = session.clone();
drop(session);
assert_eq!(
materializer.cleanup_working_directory(id).unwrap_err().code,
"working_directory_cleanup_resource_busy"
);
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
let attachments = BTreeMap::from([(alias, binding.clone())]);
let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap());
let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope).unwrap();
session_clone.close().await.unwrap();
assert_eq!(
materializer.cleanup_working_directory(id).unwrap_err().code,
"working_directory_cleanup_resource_busy"
);
router.close_all().await.unwrap();
// Closed handles, router and passive bindings can remain alive.
materializer.cleanup_working_directory(id).unwrap();
assert!(!binding.working_directory_root().exists());
}
#[test] #[test]
fn spawn_failure_with_existing_working_directory_preserves_workdir() { fn spawn_failure_with_existing_working_directory_preserves_workdir() {
let runtime_base = tempfile::tempdir().unwrap(); let runtime_base = tempfile::tempdir().unwrap();
+7 -32
View File
@@ -736,7 +736,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn current_prompt_projection( fn current_prompt_projection(
&self, &self,
minimum_revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> { ) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
let Some(cache) = self.prompt_projection_cache.as_ref() else { let Some(cache) = self.prompt_projection_cache.as_ref() else {
return Ok(None); return Ok(None);
@@ -744,11 +743,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
if let Some(resolution) = cache if let Some(resolution) = cache
.active(&self.workspace_id) .active(&self.workspace_id)
.map_err(WorkspaceClientError::Request)? .map_err(WorkspaceClientError::Request)?
.filter(|resolution| {
minimum_revision
.map(|minimum| resolution.projection.config_revision >= minimum)
.unwrap_or(true)
})
{ {
return Ok(Some((*resolution).clone())); return Ok(Some((*resolution).clone()));
} }
@@ -763,11 +757,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
if let Some(resolution) = cache if let Some(resolution) = cache
.active(&self.workspace_id) .active(&self.workspace_id)
.map_err(WorkspaceClientError::Request)? .map_err(WorkspaceClientError::Request)?
.filter(|resolution| {
minimum_revision
.map(|minimum| resolution.projection.config_revision >= minimum)
.unwrap_or(true)
})
{ {
return Ok(Some((*resolution).clone())); return Ok(Some((*resolution).clone()));
} }
@@ -807,14 +796,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
let resolution = cache let resolution = cache
.observe(projection) .observe(projection)
.map_err(WorkspaceClientError::Request)?; .map_err(WorkspaceClientError::Request)?;
if let Some(minimum_revision) = minimum_revision
&& resolution.projection.config_revision < minimum_revision
{
return Err(WorkspaceClientError::Request(format!(
"active Workspace Prompt projection is stale: required revision {minimum_revision}, got {}",
resolution.projection.config_revision
)));
}
Ok(Some((*resolution).clone())) Ok(Some((*resolution).clone()))
} }
@@ -1082,7 +1063,6 @@ mod tests {
"default".to_string(), "default".to_string(),
"workspace prompt".to_string(), "workspace prompt".to_string(),
)]), )]),
3,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -1103,22 +1083,20 @@ mod tests {
) )
.with_prompt_projection_cache(cache); .with_prompt_projection_cache(cache);
let projection = client.current_prompt_projection(None).unwrap().unwrap(); let projection = client.current_prompt_projection().unwrap().unwrap();
let second = client.current_prompt_projection(None).unwrap().unwrap(); let second = client.current_prompt_projection().unwrap().unwrap();
assert_eq!(projection.projection.config_revision, 3);
assert_eq!(projection.projection.source_digest, "source-3"); assert_eq!(projection.projection.source_digest, "source-3");
assert!(Arc::ptr_eq(&projection.catalog, &second.catalog)); assert!(Arc::ptr_eq(&projection.catalog, &second.catalog));
} }
#[test] #[test]
fn prompt_projection_minimum_revision_rejects_stale_server_response() { fn prompt_projection_cache_miss_fetches_content_identity() {
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
let catalog = worker::EffectivePromptCatalog::new( let catalog = worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([("default".to_string(), "stale prompt".to_string())]), std::collections::BTreeMap::from([("default".to_string(), "stale prompt".to_string())]),
3,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -1132,7 +1110,6 @@ mod tests {
.unwrap(); .unwrap();
let body = serde_json::to_string(&projection).unwrap(); let body = serde_json::to_string(&projection).unwrap();
let cache = Arc::new(WorkspacePromptProjectionCache::default()); let cache = Arc::new(WorkspacePromptProjectionCache::default());
cache.observe(projection).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap()); let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || { let server = std::thread::spawn(move || {
@@ -1151,10 +1128,10 @@ mod tests {
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
.with_prompt_projection_cache(cache); .with_prompt_projection_cache(cache);
let error = client.current_prompt_projection(Some(4)).unwrap_err(); let fetched = client.current_prompt_projection().unwrap().unwrap();
server.join().unwrap(); server.join().unwrap();
assert!(error.to_string().contains("required revision 4, got 3")); assert_eq!(fetched.projection.source_digest, "source-3");
} }
#[test] #[test]
@@ -1168,7 +1145,6 @@ mod tests {
"default".to_string(), "default".to_string(),
"shared prompt".to_string(), "shared prompt".to_string(),
)]), )]),
5,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -1211,7 +1187,7 @@ mod tests {
let barrier = barrier.clone(); let barrier = barrier.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
barrier.wait(); barrier.wait();
client.current_prompt_projection(None).unwrap().unwrap() client.current_prompt_projection().unwrap().unwrap()
}) })
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -1239,7 +1215,6 @@ mod tests {
"default".to_string(), "default".to_string(),
"foreign prompt".to_string(), "foreign prompt".to_string(),
)]), )]),
4,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -1270,7 +1245,7 @@ mod tests {
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
.with_prompt_projection_cache(Arc::new(WorkspacePromptProjectionCache::default())); .with_prompt_projection_cache(Arc::new(WorkspacePromptProjectionCache::default()));
let error = client.current_prompt_projection(None).unwrap_err(); let error = client.current_prompt_projection().unwrap_err();
server.join().unwrap(); server.join().unwrap();
assert!(error.to_string().contains("scope mismatch")); assert!(error.to_string().contains("scope mismatch"));
File diff suppressed because it is too large Load Diff
@@ -76,19 +76,11 @@ pub(super) fn remaining_status(
} }
let mut summary = saved.working_directory.status_summary(); let mut summary = saved.working_directory.status_summary();
summary.status = WorkingDirectoryStatusKind::CleanupPending; summary.status = WorkingDirectoryStatusKind::CleanupPending;
summary.cleanliness = Some( // Observation is not removal admission: do not inventory or hash the
match check_mounts( // surviving checkout just to list it. The witness can be stale, so neither
&materializer.working_directory_root(id), // its old cleanliness nor a successful identity check proves current content
&fs::read("/proc/self/mountinfo").ok()?, // is safe to remove. Actual retries still perform the full safety checks.
) summary.cleanliness = Some("unknown".to_string());
.and_then(|()| retry_inventory(materializer, id, &tree, &saved))
{
Ok(_) => "clean",
Err(error) if error.code == "working_directory_cleanup_changes_present" => "dirty",
Err(_) => "unknown",
}
.to_string(),
);
Some(WorkingDirectoryStatus { summary }) Some(WorkingDirectoryStatus { summary })
} }
@@ -198,7 +190,7 @@ fn remove_with_checks(
} }
} else { } else {
let mut binding = materializer let mut binding = materializer
.read_binding(id) .read_binding_for_cleanup(id)
.map_err(|_| cause("ownership_unknown"))?; .map_err(|_| cause("ownership_unknown"))?;
validate_binding(materializer, id, &binding)?; validate_binding(materializer, id, &binding)?;
// Preflight the entire tree without following links or crossing mounts, // Preflight the entire tree without following links or crossing mounts,
@@ -286,8 +278,8 @@ fn same_materialization(saved: &WorkingDirectory, current: &WorkingDirectory) ->
// These fields are refreshed by authorize_repository_access, not a new // These fields are refreshed by authorize_repository_access, not a new
// materialization. All repository, creation and cleanup identity stays. // materialization. All repository, creation and cleanup identity stays.
directory.evidence.operation_id = None; directory.evidence.operation_id = None;
directory.evidence.credential_revision = None; directory.evidence.public_key_fingerprint = None;
directory.evidence.host_trust_revision = None; directory.evidence.host_key_fingerprint = None;
directory directory
}; };
immutable(saved) == immutable(current) immutable(saved) == immutable(current)
@@ -306,7 +298,7 @@ fn retry_inventory(
return Ok(before); return Ok(before);
} }
let binding = materializer let binding = materializer
.read_binding(id) .read_binding_for_cleanup(id)
.map_err(|_| cause("changes_present"))?; .map_err(|_| cause("changes_present"))?;
validate_binding(materializer, id, &binding)?; validate_binding(materializer, id, &binding)?;
if !same_materialization(&saved.working_directory, &binding.working_directory) if !same_materialization(&saved.working_directory, &binding.working_directory)
@@ -974,7 +966,7 @@ pub(super) mod tests {
} }
#[test] #[test]
fn cleanup_pending_rechecks_current_cleanliness_and_retries_without_state_edit() { fn cleanup_pending_observation_is_unknown_and_retry_checks_safety() {
let (_runtime, materializer, binding) = fixture(); let (_runtime, materializer, binding) = fixture();
let id = &binding.working_directory.id; let id = &binding.working_directory.id;
fail_once(&materializer, id); fail_once(&materializer, id);
@@ -983,12 +975,54 @@ pub(super) mod tests {
status.summary.status, status.summary.status,
WorkingDirectoryStatusKind::CleanupPending WorkingDirectoryStatusKind::CleanupPending
); );
assert_eq!(status.summary.cleanliness.as_deref(), Some("clean")); assert_eq!(status.summary.cleanliness.as_deref(), Some("unknown"));
let removed = materializer.cleanup_working_directory(id).unwrap(); let removed = materializer.cleanup_working_directory(id).unwrap();
assert_eq!(removed.summary.status, WorkingDirectoryStatusKind::NotFound); assert_eq!(removed.summary.status, WorkingDirectoryStatusKind::NotFound);
assert!(!binding.working_directory_root.exists()); assert!(!binding.working_directory_root.exists());
} }
#[test]
fn cleanup_pending_observation_does_not_open_checkout_content() {
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::ffi::OsStrExt;
let (_runtime, materializer, binding) = fixture();
let id = &binding.working_directory.id;
fail_once(&materializer, id);
// Observe actual file opens rather than a timing threshold or a large
// fixture. Inventory would open this surviving tracked file to hash it.
// SAFETY: inotify_init1 has no pointer arguments.
let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
assert!(fd >= 0, "{}", io::Error::last_os_error());
// SAFETY: fd is a freshly created descriptor, owned only by this File.
let mut events = unsafe { fs::File::from_raw_fd(fd) };
let path =
std::ffi::CString::new(binding.root.join("README.md").as_os_str().as_bytes()).unwrap();
// SAFETY: the descriptor is live and path is a valid terminated string.
let watch =
unsafe { libc::inotify_add_watch(events.as_raw_fd(), path.as_ptr(), libc::IN_OPEN) };
assert!(watch >= 0, "{}", io::Error::last_os_error());
let detail = materializer.working_directory_status(id).unwrap();
let listed = materializer.list_working_directories().unwrap();
let mut buffer = [0u8; 4096];
assert_eq!(
events
.read(&mut buffer)
.expect_err("status observation opened surviving checkout content")
.kind(),
io::ErrorKind::WouldBlock
);
assert_eq!(
detail.summary.status,
WorkingDirectoryStatusKind::CleanupPending
);
assert_eq!(detail.summary.cleanliness.as_deref(), Some("unknown"));
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].summary, detail.summary);
}
#[test] #[test]
fn partial_deletion_missing_checkout_and_restart_finish_with_durable_content_authority() { fn partial_deletion_missing_checkout_and_restart_finish_with_durable_content_authority() {
for removed in [ for removed in [
@@ -1021,7 +1055,7 @@ pub(super) mod tests {
retained.summary.cleanup_target.as_ref(), retained.summary.cleanup_target.as_ref(),
Some(&binding.working_directory.cleanup_target) Some(&binding.working_directory.cleanup_target)
); );
assert_eq!(retained.summary.cleanliness.as_deref(), Some("clean")); assert_eq!(retained.summary.cleanliness.as_deref(), Some("unknown"));
assert_eq!( assert_eq!(
restarted.list_working_directories().unwrap()[0].summary, restarted.list_working_directories().unwrap()[0].summary,
retained.summary retained.summary
@@ -1074,7 +1108,7 @@ pub(super) mod tests {
.summary .summary
.cleanliness .cleanliness
.as_deref(), .as_deref(),
Some("dirty") Some("unknown")
); );
assert_eq!( assert_eq!(
materializer.cleanup_working_directory(id).unwrap_err().code, materializer.cleanup_working_directory(id).unwrap_err().code,
@@ -1117,7 +1151,7 @@ pub(super) mod tests {
.summary .summary
.cleanliness .cleanliness
.as_deref(), .as_deref(),
Some("clean"), Some("unknown"),
"{resolution}" "{resolution}"
); );
// Renewal itself can fail at unlink. Its new witness must survive a // Renewal itself can fail at unlink. Its new witness must survive a
@@ -1177,6 +1211,13 @@ pub(super) mod tests {
for update in ["access", "repository", "creation"] { for update in ["access", "repository", "creation"] {
let (_runtime, materializer, binding) = fixture(); let (_runtime, materializer, binding) = fixture();
let id = &binding.working_directory.id; let id = &binding.working_directory.id;
// Model an existing SSH materialization before taking the witness.
// Refresh replaces its access proof, not its requirement for SSH authority.
let mut record = materializer.read_binding(id).unwrap();
record.working_directory.evidence.repository_access_required = true;
record.working_directory.evidence.public_key_fingerprint = Some("key-1".into());
record.working_directory.evidence.host_key_fingerprint = Some("host-key-1".into());
materializer.write_record(&record).unwrap();
fail_once(&materializer, id); fail_once(&materializer, id);
let mut record = materializer.read_binding(id).unwrap(); let mut record = materializer.read_binding(id).unwrap();
match update { match update {
@@ -1184,8 +1225,8 @@ pub(super) mod tests {
// The same serialized fields changed by authorize_repository_access. // The same serialized fields changed by authorize_repository_access.
record.working_directory.evidence.operation_id = record.working_directory.evidence.operation_id =
Some("reattach-operation".into()); Some("reattach-operation".into());
record.working_directory.evidence.credential_revision = Some(2); record.working_directory.evidence.public_key_fingerprint = Some("key-2".into());
record.working_directory.evidence.host_trust_revision = Some(2); record.working_directory.evidence.host_key_fingerprint = Some("key-2".into());
} }
"repository" => { "repository" => {
record.working_directory.repository_id = "substitute".into(); record.working_directory.repository_id = "substitute".into();
@@ -0,0 +1,196 @@
//! Transient, clone-shared admission for local filesystem effects. Bindings are
//! passive; only effects and activated session resources own leases. The mutex
//! protects admission bookkeeping only, never filesystem work.
use super::WorkingDirectoryDiagnostic;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug, Default)]
pub(super) struct Occupancy {
entries: Arc<Mutex<HashMap<String, Entry>>>,
generations: Arc<Mutex<HashMap<String, Arc<()>>>>,
}
/// Passive bindings identify a materialization generation, not merely its ID.
#[derive(Clone, Debug)]
pub(super) struct Generation(Arc<()>);
#[derive(Debug)]
enum Entry {
Use(usize),
Create,
Cleanup,
}
#[derive(Debug)]
pub(super) struct Lease {
occupancy: Occupancy,
id: String,
}
fn busy() -> WorkingDirectoryDiagnostic {
WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_resource_busy",
"A filesystem resource is busy or changed during removal. Release the resource and retry.",
)
}
impl Occupancy {
pub(super) fn acquire_use(&self, id: &str) -> Result<Lease, WorkingDirectoryDiagnostic> {
let mut entries = self
.entries
.lock()
.unwrap_or_else(|error| error.into_inner());
match entries.get_mut(id) {
Some(Entry::Cleanup | Entry::Create) => return Err(busy()),
Some(Entry::Use(count)) => *count += 1,
None => {
entries.insert(id.to_string(), Entry::Use(1));
}
}
Ok(Lease {
occupancy: self.clone(),
id: id.to_string(),
})
}
pub(super) fn generation(&self, id: &str) -> Generation {
let mut generations = self
.generations
.lock()
.unwrap_or_else(|error| error.into_inner());
Generation(
generations
.entry(id.to_string())
.or_insert_with(|| Arc::new(()))
.clone(),
)
}
// Called only after successful removal, while exclusive cleanup is held.
pub(super) fn invalidate_generation(&self, id: &str) {
self.generations
.lock()
.unwrap_or_else(|error| error.into_inner())
.remove(id);
}
pub(super) fn acquire_session_use(
&self,
id: &str,
generation: &Generation,
) -> Result<Lease, WorkingDirectoryDiagnostic> {
let lease = self.acquire_use(id)?;
let valid = self
.generations
.lock()
.unwrap_or_else(|error| error.into_inner())
.get(id)
.is_some_and(|current| Arc::ptr_eq(current, &generation.0));
if !valid {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_not_found",
"Workdir binding is stale; acquire a fresh binding before opening a session",
));
}
Ok(lease)
}
pub(super) fn acquire_creation(&self, id: &str) -> Result<Lease, WorkingDirectoryDiagnostic> {
let mut entries = self
.entries
.lock()
.unwrap_or_else(|error| error.into_inner());
if entries.contains_key(id) {
return Err(busy());
}
entries.insert(id.to_string(), Entry::Create);
Ok(Lease {
occupancy: self.clone(),
id: id.to_string(),
})
}
pub(super) fn acquire_cleanup(&self, id: &str) -> Result<Lease, WorkingDirectoryDiagnostic> {
let mut entries = self
.entries
.lock()
.unwrap_or_else(|error| error.into_inner());
if entries.contains_key(id) {
return Err(busy());
}
entries.insert(id.to_string(), Entry::Cleanup);
Ok(Lease {
occupancy: self.clone(),
id: id.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn use_and_cleanup_leases_release_during_unwind() {
let occupancy = Occupancy::default();
for operation in ["use", "create", "cleanup"] {
let result = std::panic::catch_unwind(|| {
let _lease = match operation {
"cleanup" => occupancy.acquire_cleanup("workdir-panic").unwrap(),
"create" => occupancy.acquire_creation("workdir-panic").unwrap(),
_ => occupancy.acquire_use("workdir-panic").unwrap(),
};
panic!("effect failed after admission");
});
assert!(result.is_err());
drop(occupancy.acquire_use("workdir-panic").unwrap());
drop(occupancy.acquire_cleanup("workdir-panic").unwrap());
}
}
#[test]
fn simultaneous_use_and_cleanup_admit_only_one_direction() {
let occupancy = Occupancy::default();
let barrier = Arc::new(std::sync::Barrier::new(2));
std::thread::scope(|scope| {
let use_occupancy = occupancy.clone();
let use_barrier = barrier.clone();
let use_thread = scope.spawn(move || {
use_barrier.wait();
let result = use_occupancy.acquire_use("workdir-race");
// Keep the winner's lease until both admission attempts finish.
use_barrier.wait();
result
});
barrier.wait();
let cleanup = occupancy.acquire_cleanup("workdir-race");
barrier.wait();
let use_result = use_thread.join().unwrap();
assert_ne!(use_result.is_ok(), cleanup.is_ok());
let error = if let Err(error) = use_result {
error
} else {
cleanup.unwrap_err()
};
assert_eq!(error.code, "working_directory_cleanup_resource_busy");
});
drop(occupancy.acquire_cleanup("workdir-race").unwrap());
}
}
impl Drop for Lease {
fn drop(&mut self) {
let mut entries = self
.occupancy
.entries
.lock()
.unwrap_or_else(|error| error.into_inner());
match entries.get_mut(&self.id) {
Some(Entry::Use(count)) if *count > 1 => *count -= 1,
_ => {
entries.remove(&self.id);
}
}
}
}
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -68,13 +68,14 @@ commands and generic POSIX operations are not exposed. The adapter uses the type
an owner config API as a bypass. an owner config API as a bypass.
Client-managed PATH validators are compared against freshly resolved Host Client-managed PATH validators are compared against freshly resolved Host
metadata. Each mutation captures the ROOT validator, whole-tree revision/digest, metadata. Each mutation captures the ROOT validator, whole-tree content digest,
canonical entrypoints and node state from that same observation. Backend resolves canonical entrypoints and node state from that same observation. Backend resolves
the live grant/link and validates canonical changes/CAS under its execution lock. the live grant/link and validates canonical changes/CAS under its execution lock.
No operation accepts LLM-supplied validators or `expected_digest` fields. Returned No operation accepts LLM-supplied validators or `expected_digest` fields. Returned
post-call path validators are refreshed from the exact committed revision, not post-call path validators are refreshed from the exact committed content, not
substituted with root validators or later unrelated state. UI/Worker edits and substituted with root validators or later unrelated state. Content changes and
same-bytes replacement revisions reject stale calls. Old descriptors cannot reattachment to a new connection lifetime reject stale calls, even when the new
attachment exposes the same bytes. Old descriptors cannot
bypass read-only restrictions, detach, revocation or Workspace boundaries. bypass read-only restrictions, detach, revocation or Workspace boundaries.
Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB
+3 -3
View File
@@ -10,7 +10,7 @@ pub struct BackendJobExecutionBinding {
pub job_id: String, pub job_id: String,
pub attempt_id: String, pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>, pub input_digest: Option<String>,
#[serde(default)] #[serde(default)]
pub subjektiv_consolidation: bool, pub subjektiv_consolidation: bool,
} }
@@ -19,10 +19,10 @@ impl BackendJobExecutionBinding {
pub fn validate(&self) -> Result<(), &'static str> { pub fn validate(&self) -> Result<(), &'static str> {
if [&self.job_id, &self.attempt_id] if [&self.job_id, &self.attempt_id]
.into_iter() .into_iter()
.chain(self.input_revision.iter()) .chain(self.input_digest.iter())
.any(|value| value.trim().is_empty() || value.chars().any(char::is_control)) .any(|value| value.trim().is_empty() || value.chars().any(char::is_control))
{ {
return Err("Backend Job binding contains an invalid identity or input revision"); return Err("Backend Job binding contains an invalid identity or input digest");
} }
Ok(()) Ok(())
} }

Some files were not shown because too many files have changed in this diff Show More