Compare commits
12
Commits
2060e8f2c1
...
c78f7f7d80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c78f7f7d80 | ||
|
|
72ce817de4 | ||
|
|
ac01f64f4d | ||
|
|
8af492ff84 | ||
|
|
defaf8f369 | ||
|
|
fc57716344 | ||
|
|
dc8e586a0b | ||
|
|
7321d6c7c3 | ||
|
|
fd759545c1 | ||
|
|
6ac87c885e | ||
|
|
10c55874e3 | ||
|
|
f9d70e2ae2 |
Generated
+2
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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(¤t),
|
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||||
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(¤t),
|
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||||
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());
|
||||||
|
|||||||
@@ -230,27 +230,51 @@ 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(
|
||||||
[
|
VirtualPath::parse("main.dcdl").unwrap(),
|
||||||
ConfigEntry::new(
|
ConfigContentType::Decodal,
|
||||||
VirtualPath::parse("main.dcdl").unwrap(),
|
"{}",
|
||||||
ConfigContentType::Decodal,
|
)
|
||||||
"{}",
|
.unwrap(),
|
||||||
)
|
// A same-suffix workspace file must not be an unknown builtin fallback.
|
||||||
.unwrap(),
|
ConfigEntry::new(
|
||||||
// A same-suffix workspace file must not be an unknown builtin fallback.
|
VirtualPath::parse("profiles/missing.dcdl").unwrap(),
|
||||||
ConfigEntry::new(
|
ConfigContentType::Decodal,
|
||||||
VirtualPath::parse("profiles/missing.dcdl").unwrap(),
|
"{}",
|
||||||
ConfigContentType::Decodal,
|
)
|
||||||
"{}",
|
.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, };
|
||||||
+344
-169
@@ -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(¤t, specifier)
|
resolve_import(¤t, 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(¤t)
|
||||||
.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("shared.dcdl", "{}"),
|
||||||
entry("profiles/a.dcdl", "{ a = 1; }"),
|
])
|
||||||
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,
|
"main.dcdl",
|
||||||
[entry(
|
"{ answer = 42; } as WorkspaceConfigSchema",
|
||||||
"main.dcdl",
|
)])
|
||||||
"{ 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("recipe.dcdl", "{ worker = { mode = 42; }; }"),
|
||||||
entry("main.dcdl", source),
|
])
|
||||||
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("shared/value.dcdl", "{ answer = 42; }"),
|
||||||
entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#),
|
entry("other.dcdl", "{}"),
|
||||||
entry("shared/value.dcdl", "{ answer = 42; }"),
|
])
|
||||||
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,
|
"main.dcdl",
|
||||||
[entry(
|
"{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }",
|
||||||
"main.dcdl",
|
)])
|
||||||
"{ 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,
|
"main.dcdl",
|
||||||
[entry(
|
"{ web = { enabled = true; extension_value = 42; }; }",
|
||||||
"main.dcdl",
|
)])
|
||||||
"{ 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,11 +3540,9 @@ 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",
|
||||||
"web",
|
"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""#),
|
||||||
[
|
text_entry("skills/plain/SKILL.md", source),
|
||||||
entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#),
|
])
|
||||||
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""#),
|
||||||
[
|
text_entry(
|
||||||
entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#),
|
"skills/broken/SKILL.md",
|
||||||
text_entry(
|
"---\nname: [unterminated\n---\nbody\n",
|
||||||
"skills/broken/SKILL.md",
|
),
|
||||||
"---\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,
|
"main.dcdl",
|
||||||
[entry(
|
r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#,
|
||||||
"main.dcdl",
|
)])
|
||||||
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("profiles/base.dcdl", "{ spoof = true; }"),
|
||||||
entry("main.dcdl", &source),
|
])
|
||||||
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/shared.dcdl", "{ answer = 42; }"),
|
||||||
entry("profiles/main.dcdl", r#"import "./shared.dcdl""#),
|
])
|
||||||
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("unused.dcdl", "{ broken = ; }"),
|
||||||
entry("workspace.dcdl", "{ answer = 42; }"),
|
])
|
||||||
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("b.dcdl", r#"import "./a.dcdl""#),
|
||||||
entry("a.dcdl", r#"import "./b.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))
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 =
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
let mut invalid = valid.clone();
|
for bad in [
|
||||||
invalid["mutation"]["expected_revision"] = json!("01");
|
"".to_string(),
|
||||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
"\ncontrol".to_string(),
|
||||||
|
"x".repeat(129),
|
||||||
|
"é".repeat(65),
|
||||||
|
] {
|
||||||
|
let mut invalid = valid.clone();
|
||||||
|
invalid["mutation"]["expected_mutation_id"] = json!(bad);
|
||||||
|
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"),
|
||||||
(
|
(
|
||||||
|
|||||||
+289
-316
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()
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 => {
|
||||||
tx.execute_batch(GRANT_SCHEMA)?;
|
if version == 1 {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(¤t).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!(
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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
@@ -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(_) => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
+711
-1332
File diff suppressed because it is too large
Load Diff
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+87
-10
@@ -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) => {
|
||||||
workdir::WorkdirError::NotFound(_)
|
// Diagnostic context is transparent to the established normal-tool
|
||||||
| workdir::WorkdirError::Io { .. }
|
// classification. Preserve the original display, including unknown
|
||||||
| workdir::WorkdirError::Unavailable(_)
|
// outcome guidance, while classifying through nested wrappers.
|
||||||
| workdir::WorkdirError::OperationFailed
|
match error.classification_source() {
|
||||||
| workdir::WorkdirError::Transport(_)
|
workdir::WorkdirError::NotFound(_)
|
||||||
| workdir::WorkdirError::Conflict(_)
|
| workdir::WorkdirError::Io { .. }
|
||||||
| workdir::WorkdirError::OutcomeUnknown(_),
|
| workdir::WorkdirError::Unavailable(_)
|
||||||
) => ToolError::ExecutionFailed(err.to_string()),
|
| workdir::WorkdirError::OperationFailed
|
||||||
|
| workdir::WorkdirError::Transport(_)
|
||||||
|
| workdir::WorkdirError::Conflict(_)
|
||||||
|
| workdir::WorkdirError::OutcomeUnknown(_) => {
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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(¤t));
|
assert_ne!(request_id(&first), request_id(¤t));
|
||||||
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(¤t, "資料/current"));
|
app.handle_worker_event(reply(¤t, "資料/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),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
+55
-25
@@ -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 Drop for InputBuffer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.cancel_completion_on_edit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl InputBuffer {
|
impl InputBuffer {
|
||||||
pub fn revision(&self) -> u64 {
|
pub(crate) fn watch_completion_input(&self) -> CompletionInputWatch {
|
||||||
self.revision
|
CompletionInputWatch(Arc::clone(
|
||||||
|
self.pending_completion
|
||||||
|
.borrow_mut()
|
||||||
|
.get_or_insert_with(|| Arc::new(AtomicBool::new(false))),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bump_revision(&mut self) {
|
fn cancel_completion_on_edit(&mut self) {
|
||||||
self.revision = self.revision.wrapping_add(1);
|
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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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,7 +404,18 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
+312
-25
@@ -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(
|
||||||
"Workdir path `{}` exceeds the provider attachment scope",
|
crate::WorkdirDenialReason::AttachmentScopeExceeded,
|
||||||
request.path
|
format!(
|
||||||
)));
|
"Workdir path `{}` exceeds the provider attachment scope",
|
||||||
|
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(
|
||||||
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
crate::WorkdirDenialReason::DelegatedScopeExceeded,
|
||||||
request.path
|
format!(
|
||||||
)))
|
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
||||||
|
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)]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+150
-46
@@ -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(
|
||||||
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
|
crate::WorkdirDenialReason::LogicalScopeExceeded,
|
||||||
)));
|
format!(
|
||||||
|
"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(
|
||||||
"logical workdir scope `{}` exceeds the parent tool scope",
|
crate::WorkdirDenialReason::ParentScopeExceeded,
|
||||||
requested.target
|
format!(
|
||||||
)));
|
"logical workdir scope `{}` exceeds the parent tool scope",
|
||||||
|
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(
|
||||||
"scoped tool cwd `{}` is outside the readable scope",
|
crate::WorkdirDenialReason::CwdOutsideReadableScope,
|
||||||
request.cwd
|
format!(
|
||||||
)));
|
"scoped tool cwd `{}` is outside the readable scope",
|
||||||
|
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(
|
||||||
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
|
||||||
requested.target
|
format!(
|
||||||
)));
|
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
workspace_id: acknowledgement.workspace_id.clone(),
|
&acknowledgement.challenge_id,
|
||||||
runtime_id: acknowledgement.runtime_id.clone(),
|
WorkspaceRuntimeVerificationRecord {
|
||||||
binding_revision: acknowledgement.binding_revision,
|
workspace_id: acknowledgement.workspace_id.clone(),
|
||||||
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
runtime_id: acknowledgement.runtime_id.clone(),
|
||||||
workspace_identity_revision: acknowledgement.workspace_identity_revision,
|
binding_id: acknowledgement.binding_id.clone(),
|
||||||
workspace_trust_generation: acknowledgement.workspace_trust_generation,
|
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
||||||
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
|
workspace_public_key_fingerprint: acknowledgement
|
||||||
runtime_identity_revision: acknowledgement.runtime_identity_revision,
|
.workspace_public_key_fingerprint
|
||||||
verified_at: unix_now_i64(),
|
.clone(),
|
||||||
})
|
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
|
||||||
|
runtime_public_key_fingerprint: acknowledgement
|
||||||
|
.runtime_public_key_fingerprint
|
||||||
|
.clone(),
|
||||||
|
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,10 +966,12 @@ 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())
|
||||||
.map_err(RuntimeHttpRestError::runtime)?;
|
.await
|
||||||
|
.map_err(RuntimeHttpRestError::working_directory_join)?
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
Ok(Json(RuntimeHttpWorkingDirectoriesResponse {
|
Ok(Json(RuntimeHttpWorkingDirectoriesResponse {
|
||||||
working_directories,
|
working_directories,
|
||||||
}))
|
}))
|
||||||
@@ -989,10 +1006,11 @@ 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::runtime)?;
|
.map_err(RuntimeHttpRestError::working_directory_join)?
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
||||||
working_directory,
|
working_directory,
|
||||||
}))
|
}))
|
||||||
@@ -1130,10 +1148,16 @@ 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
|
||||||
.runtime
|
// synchronous filesystem/Git work on the HTTP executor.
|
||||||
.cleanup_working_directory(&working_directory_id)
|
let working_directory = tokio::task::spawn_blocking(move || {
|
||||||
.map_err(RuntimeHttpRestError::runtime)?;
|
state
|
||||||
|
.runtime
|
||||||
|
.cleanup_working_directory(&working_directory_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeHttpRestError::working_directory_join)?
|
||||||
|
.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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
return Ok(current.clone());
|
||||||
|| 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!(
|
|
||||||
"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();
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user