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",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -4788,6 +4789,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"server-api",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
|
||||
+1
-1
@@ -172,5 +172,5 @@ webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation
|
||||
[workspace.metadata.wip-upstream]
|
||||
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
||||
version = "0.2.0"
|
||||
reference-revision = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
|
||||
reference-commit = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
|
||||
source = "crates.io"
|
||||
|
||||
@@ -3,11 +3,11 @@ use server_api::{
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery,
|
||||
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse,
|
||||
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse,
|
||||
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
RuntimeTrustKeyRevealResponse, SubjektivMemoryChangesQuery, SubjektivMemoryDetailQuery,
|
||||
SubjektivMemoryListChangesResponse, SubjektivMemoryListQuery, SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse, SubjektivResidentSurfaceResponse, SubjektivSubjectListQuery,
|
||||
SubjektivSubjectListResponse, SubjektivSubjectResponse, WorkerLaunchOptionsResponse,
|
||||
WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
};
|
||||
use ticket::{
|
||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
@@ -168,7 +168,7 @@ impl BackendWorkspaceProductClient {
|
||||
};
|
||||
Ok(ticket::TicketStateUpdate {
|
||||
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)?,
|
||||
state: parse(&change.to)?,
|
||||
reason: if change.body.as_str().is_empty() {
|
||||
@@ -208,7 +208,7 @@ impl BackendWorkspaceProductClient {
|
||||
let current = self.show_ticket(id)?;
|
||||
let request = ticket::TicketCompletion {
|
||||
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,
|
||||
reason: resolution.as_str().to_string(),
|
||||
references: Vec::new(),
|
||||
@@ -521,19 +521,19 @@ impl BackendWorkspaceProductClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_memory_revisions(
|
||||
pub fn list_subjektiv_memory_changes(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
memory_id: &str,
|
||||
query: &SubjektivMemoryRevisionsQuery,
|
||||
) -> Result<SubjektivMemoryListRevisionsResponse, BackendWorkspaceClientError> {
|
||||
query: &SubjektivMemoryChangesQuery,
|
||||
) -> Result<SubjektivMemoryListChangesResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let memory_id = memory_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query)
|
||||
.subjektiv_memory_changes(workspace_id, subject_id, memory_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
@@ -997,7 +997,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
@@ -1021,8 +1021,8 @@ mod tests {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_item_revision"],
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "ready");
|
||||
assert_eq!(body["state"], "done");
|
||||
@@ -1057,8 +1057,8 @@ mod tests {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_item_revision"],
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "The request was withdrawn");
|
||||
@@ -1070,7 +1070,7 @@ mod tests {
|
||||
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
|
||||
let (base_url, request, handle) = one_response_server(
|
||||
"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(
|
||||
base_url,
|
||||
@@ -1083,7 +1083,7 @@ mod tests {
|
||||
"T-718",
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "research-done".into(),
|
||||
expected_item_revision: "ticket:1".into(),
|
||||
expected_content_digest: "ticket:1".into(),
|
||||
expected_state: ticket::TicketWorkflowState::Planning,
|
||||
reason: "Research findings recorded".into(),
|
||||
references: Vec::new(),
|
||||
@@ -1100,7 +1100,7 @@ mod tests {
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["state"], "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["reason"], "Research findings recorded");
|
||||
assert!(body.get("merge_request_ids").is_none());
|
||||
|
||||
@@ -230,27 +230,51 @@ mod tests {
|
||||
use config_source::{ConfigContentType, ConfigEntry};
|
||||
|
||||
fn snapshot() -> ConfigTreeSnapshot {
|
||||
ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
// A same-suffix workspace file must not be an unknown builtin fallback.
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("profiles/missing.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
)
|
||||
ConfigTreeSnapshot::from_entries([
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
// A same-suffix workspace file must not be an unknown builtin fallback.
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("profiles/missing.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.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]
|
||||
fn wasm_completion_boundary_preserves_read_only_builtin_detail() {
|
||||
let snapshot = snapshot();
|
||||
@@ -301,7 +325,6 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diagnostic.path, path);
|
||||
assert_eq!(diagnostic.revision, 7);
|
||||
assert_eq!(diagnostic.tree_digest, snapshot.digest);
|
||||
assert!(
|
||||
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_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);
|
||||
|
||||
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 {
|
||||
pub fn parse(value: impl AsRef<str>) -> Result<Self, ConfigTreeError> {
|
||||
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 {
|
||||
#[ts(type = "number")]
|
||||
pub revision: u64,
|
||||
/// SHA-256 of ordered virtual paths, content types and complete source bytes.
|
||||
/// Identifies tree content, not an edit sequence or freshness counter.
|
||||
pub digest: String,
|
||||
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 {
|
||||
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(
|
||||
revision: u64,
|
||||
entries: impl IntoIterator<Item = ConfigEntry>,
|
||||
) -> Result<Self, ConfigTreeError> {
|
||||
let mut ordered = BTreeMap::new();
|
||||
let mut total = 0usize;
|
||||
for entry in entries {
|
||||
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 {
|
||||
return Err(ConfigTreeError::LimitExceeded("entry bytes"));
|
||||
}
|
||||
@@ -306,12 +332,25 @@ impl ConfigTreeSnapshot {
|
||||
}
|
||||
let digest = snapshot_digest(&ordered);
|
||||
Ok(Self {
|
||||
revision,
|
||||
digest,
|
||||
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> {
|
||||
self.entries
|
||||
.values()
|
||||
@@ -364,7 +403,11 @@ impl ConfigTreeSnapshot {
|
||||
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> {
|
||||
self.validate()?;
|
||||
if changes.len() > MAX_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)]
|
||||
pub struct ConfigDiagnostic {
|
||||
pub path: VirtualPath,
|
||||
#[ts(type = "number")]
|
||||
pub revision: u64,
|
||||
pub tree_digest: String,
|
||||
pub kind: String,
|
||||
pub span: ConfigSpan,
|
||||
@@ -1436,7 +1477,6 @@ impl SnapshotEnvironment {
|
||||
) -> ConfigDiagnostic {
|
||||
ConfigDiagnostic {
|
||||
path,
|
||||
revision: self.snapshot.revision,
|
||||
tree_digest: self.snapshot.digest.clone(),
|
||||
kind: kind.into(),
|
||||
span: ConfigSpan {
|
||||
@@ -1572,13 +1612,39 @@ impl SnapshotImportLoader {
|
||||
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(
|
||||
&self,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> Result<VirtualPath, ConfigTreeError> {
|
||||
let current = current_key
|
||||
.map(VirtualPath::parse)
|
||||
.map(|key| self.source_path(key))
|
||||
.transpose()?
|
||||
.ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?;
|
||||
resolve_import(¤t, specifier)
|
||||
@@ -1594,11 +1660,10 @@ impl ImportLoader for SnapshotImportLoader {
|
||||
let path = self.resolve(current_key, specifier).map_err(import_error)?;
|
||||
// Guard the import graph before returning a module to Decodal. In particular,
|
||||
// cycles hidden inside lazy object fields must not recurse during materialization.
|
||||
let current = current_key
|
||||
.expect("resolve requires a source context")
|
||||
.split("@sha256:")
|
||||
.next()
|
||||
.unwrap();
|
||||
let current = self
|
||||
.source_path(current_key.expect("resolve requires a source context"))
|
||||
.map_err(import_error)?
|
||||
.to_string();
|
||||
if self
|
||||
.import_edges
|
||||
.entry(current.to_string())
|
||||
@@ -1614,7 +1679,7 @@ impl ImportLoader for SnapshotImportLoader {
|
||||
// A language service can keep using its loader after a diagnostic.
|
||||
// Only successful edges may be cached as already validated.
|
||||
self.import_edges
|
||||
.get_mut(current)
|
||||
.get_mut(¤t)
|
||||
.unwrap()
|
||||
.remove(path.as_str());
|
||||
return Err(diagnostic);
|
||||
@@ -1651,7 +1716,10 @@ impl ImportLoader for SnapshotImportLoader {
|
||||
return Err(import_error(ConfigTreeError::LimitExceeded("total bytes")));
|
||||
}
|
||||
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(),
|
||||
resource.source,
|
||||
));
|
||||
@@ -1747,9 +1815,16 @@ fn validate_import_graph(
|
||||
|
||||
fn snapshot_import_cache_key(entry: &ConfigEntry) -> String {
|
||||
// The source id remains the virtual path for diagnostics and relative-import
|
||||
// resolution. The cache identity also includes immutable source content so
|
||||
// equal paths from different revisions cannot alias in an Engine cache.
|
||||
format!("{}@{}", entry.path, entry.content_digest)
|
||||
// resolution. Cache identity binds path, import interpretation and source bytes,
|
||||
// so changed content or content type cannot alias in an Engine cache.
|
||||
// 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 {
|
||||
@@ -1881,7 +1956,6 @@ fn project_engine_diagnostic(
|
||||
.unwrap_or(fallback_path);
|
||||
ConfigDiagnostic {
|
||||
path,
|
||||
revision: snapshot.revision,
|
||||
tree_digest: snapshot.digest.clone(),
|
||||
span: ConfigSpan {
|
||||
start_byte: diagnostic.span.start,
|
||||
@@ -1911,7 +1985,6 @@ fn project_diagnostic(
|
||||
) -> ConfigDiagnostic {
|
||||
ConfigDiagnostic {
|
||||
path: fallback_path,
|
||||
revision: snapshot.revision,
|
||||
tree_digest: snapshot.digest.clone(),
|
||||
kind: diagnostic_kind(diagnostic.kind).to_string(),
|
||||
span: ConfigSpan {
|
||||
@@ -2174,6 +2247,12 @@ pub enum ConfigTreeError {
|
||||
NotFound(VirtualPath),
|
||||
#[error("virtual config entry changed: {0}")]
|
||||
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}")]
|
||||
PathChangedMoreThanOnce(VirtualPath),
|
||||
#[error("duplicate virtual config path")]
|
||||
@@ -2254,13 +2333,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
|
||||
let base = ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[
|
||||
entry("profiles/a.dcdl", "{ a = 1; }"),
|
||||
entry("shared.dcdl", "{}"),
|
||||
],
|
||||
)
|
||||
let base = ConfigTreeSnapshot::from_entries([
|
||||
entry("profiles/a.dcdl", "{ a = 1; }"),
|
||||
entry("shared.dcdl", "{}"),
|
||||
])
|
||||
.unwrap();
|
||||
let updated = base
|
||||
.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]
|
||||
fn snapshot_digest_is_deterministic() {
|
||||
let left = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[entry("z.dcdl", "{}"), entry("a.dcdl", "{ x = 1; }")],
|
||||
)
|
||||
let left = ConfigTreeSnapshot::from_entries([
|
||||
entry("z.dcdl", "{}"),
|
||||
entry("a.dcdl", "{ x = 1; }"),
|
||||
])
|
||||
.unwrap();
|
||||
let right = ConfigTreeSnapshot::from_entries(
|
||||
99,
|
||||
[entry("a.dcdl", "{ x = 1; }"), entry("z.dcdl", "{}")],
|
||||
)
|
||||
let right = ConfigTreeSnapshot::from_entries([
|
||||
entry("a.dcdl", "{ x = 1; }"),
|
||||
entry("z.dcdl", "{}"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(left.digest, right.digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entrypoint_can_assert_empty_workspace_schema_global() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[entry(
|
||||
"main.dcdl",
|
||||
"{ answer = 42; } as WorkspaceConfigSchema",
|
||||
)],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([entry(
|
||||
"main.dcdl",
|
||||
"{ answer = 42; } as WorkspaceConfigSchema",
|
||||
)])
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
|
||||
SnapshotEnvironment::new(snapshot)
|
||||
@@ -2373,7 +2515,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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(
|
||||
"builtin:profile",
|
||||
"profile",
|
||||
@@ -2458,7 +2600,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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)
|
||||
.with_schema_bundle(profile_entries_completion_schema());
|
||||
for (marked_source, expected, prefix_len) in [
|
||||
@@ -2544,7 +2686,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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(
|
||||
"builtin:profile",
|
||||
"profile",
|
||||
@@ -2617,7 +2759,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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)
|
||||
.with_schema_bundle(profile_entries_completion_schema());
|
||||
for source in [
|
||||
@@ -2642,7 +2784,7 @@ mod tests {
|
||||
#[test]
|
||||
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 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)
|
||||
.with_schema_bundle(profile_entries_completion_schema());
|
||||
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
|
||||
@@ -2663,7 +2805,7 @@ mod tests {
|
||||
diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.kind == "constraint_violation"
|
||||
&& diagnostic.path == path("main.dcdl")
|
||||
&& diagnostic.revision == 7
|
||||
&& diagnostic.tree_digest == environment.snapshot().digest
|
||||
&& diagnostic.span.end_byte > diagnostic.span.start_byte
|
||||
&& (diagnostic.message.contains(field)
|
||||
|| diagnostic
|
||||
@@ -2690,7 +2832,7 @@ mod tests {
|
||||
)])
|
||||
.unwrap();
|
||||
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());
|
||||
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
|
||||
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() {
|
||||
let schema = actual_profile_schema();
|
||||
let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
8,
|
||||
[
|
||||
entry("main.dcdl", source),
|
||||
entry("recipe.dcdl", "{ worker = { mode = 42; }; }"),
|
||||
],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("main.dcdl", source),
|
||||
entry("recipe.dcdl", "{ worker = { mode = 42; }; }"),
|
||||
])
|
||||
.unwrap();
|
||||
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
|
||||
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
|
||||
@@ -2814,7 +2953,7 @@ mod tests {
|
||||
"{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" {
|
||||
&source
|
||||
} else {
|
||||
@@ -2874,10 +3013,10 @@ mod tests {
|
||||
let source = format!(
|
||||
r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
|
||||
);
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
9,
|
||||
[entry("main.dcdl", &source), entry("scoped.dcdl", recipe)],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("main.dcdl", &source),
|
||||
entry("scoped.dcdl", recipe),
|
||||
])
|
||||
.unwrap();
|
||||
let schema = actual_profile_schema();
|
||||
let environment =
|
||||
@@ -2967,10 +3106,10 @@ mod tests {
|
||||
let source = format!(
|
||||
r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
|
||||
);
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
10,
|
||||
[entry("main.dcdl", &source), entry("recipe.dcdl", &recipe)],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("main.dcdl", &source),
|
||||
entry("recipe.dcdl", &recipe),
|
||||
])
|
||||
.unwrap();
|
||||
let schema = actual_profile_schema();
|
||||
let environment =
|
||||
@@ -3001,7 +3140,7 @@ mod tests {
|
||||
}
|
||||
let source = "{} as WorkspaceConfigSchema";
|
||||
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());
|
||||
for (profile_path, prefix, label) in [
|
||||
@@ -3051,7 +3190,7 @@ mod tests {
|
||||
.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);
|
||||
for (marked_source, expected) in [
|
||||
("{ ed| } as WorkspaceConfigSchema", "editor"),
|
||||
@@ -3148,14 +3287,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn relative_imports_and_completion_share_the_snapshot_namespace() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[
|
||||
entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#),
|
||||
entry("shared/value.dcdl", "{ answer = 42; }"),
|
||||
entry("other.dcdl", "{}"),
|
||||
],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#),
|
||||
entry("shared/value.dcdl", "{ answer = 42; }"),
|
||||
entry("other.dcdl", "{}"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(),
|
||||
@@ -3207,7 +3343,7 @@ mod tests {
|
||||
#[test]
|
||||
fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() {
|
||||
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(
|
||||
"builtin:web",
|
||||
"web",
|
||||
@@ -3239,8 +3375,7 @@ mod tests {
|
||||
("{ web = {}; custom = 42; }", "custom"),
|
||||
("{ web = { typo = true; }; }", "typo"),
|
||||
] {
|
||||
let snapshot =
|
||||
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&ToolchainContract::with_schema_bundle(
|
||||
1,
|
||||
@@ -3258,13 +3393,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_schema_supports_typed_associative_collections() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[entry(
|
||||
"main.dcdl",
|
||||
"{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }",
|
||||
)],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([entry(
|
||||
"main.dcdl",
|
||||
"{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }",
|
||||
)])
|
||||
.unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:features",
|
||||
@@ -3315,8 +3447,7 @@ mod tests {
|
||||
"constraintviolation",
|
||||
),
|
||||
] {
|
||||
let snapshot =
|
||||
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&ToolchainContract::with_schema_bundle(
|
||||
1,
|
||||
@@ -3335,7 +3466,7 @@ mod tests {
|
||||
fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() {
|
||||
let source =
|
||||
"{} 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 diagnostics = environment.analyze(&path("schema.dcdl"), None);
|
||||
assert!(
|
||||
@@ -3370,13 +3501,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_schema_preserves_fields_only_where_rest_is_explicit() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[entry(
|
||||
"main.dcdl",
|
||||
"{ web = { enabled = true; extension_value = 42; }; }",
|
||||
)],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([entry(
|
||||
"main.dcdl",
|
||||
"{ web = { enabled = true; extension_value = 42; }; }",
|
||||
)])
|
||||
.unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
@@ -3402,8 +3530,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unresolved_unknown_cannot_be_materialized() {
|
||||
let snapshot =
|
||||
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "Unknown")]).unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
|
||||
.unwrap_err();
|
||||
@@ -3413,11 +3540,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[entry("main.dcdl", "{ web = { enabled = 1; }; }")],
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot =
|
||||
ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = { enabled = 1; }; }")])
|
||||
.unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
@@ -3459,9 +3584,82 @@ mod tests {
|
||||
let first = text_entry("skills/debug-rust/SKILL.md", "first");
|
||||
let second = text_entry("skills/debug-rust/SKILL.md", "second");
|
||||
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_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]
|
||||
@@ -3476,9 +3674,7 @@ mod tests {
|
||||
"---\n",
|
||||
"# Debug Rust\n",
|
||||
);
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
3,
|
||||
[
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry(
|
||||
"main.dcdl",
|
||||
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(),
|
||||
}
|
||||
);
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[
|
||||
entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#),
|
||||
text_entry("skills/plain/SKILL.md", source),
|
||||
],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#),
|
||||
text_entry("skills/plain/SKILL.md", source),
|
||||
])
|
||||
.unwrap();
|
||||
let result = SnapshotEnvironment::new(snapshot)
|
||||
.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(),
|
||||
"opening YAML frontmatter delimiter has no closing delimiter"
|
||||
);
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[
|
||||
entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#),
|
||||
text_entry(
|
||||
"skills/broken/SKILL.md",
|
||||
"---\nname: [unterminated\n---\nbody\n",
|
||||
),
|
||||
],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#),
|
||||
text_entry(
|
||||
"skills/broken/SKILL.md",
|
||||
"---\nname: [unterminated\n---\nbody\n",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
|
||||
@@ -3557,7 +3747,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("profiles/base.dcdl", "{ slug = \"workspace-shadow\"; }"),
|
||||
]).unwrap();
|
||||
@@ -3583,13 +3773,10 @@ mod tests {
|
||||
);
|
||||
|
||||
// Intersections conflict on explicit builtin values, exactly like any other import.
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[entry(
|
||||
"main.dcdl",
|
||||
r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#,
|
||||
)],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([entry(
|
||||
"main.dcdl",
|
||||
r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#,
|
||||
)])
|
||||
.unwrap();
|
||||
assert!(
|
||||
SnapshotEnvironment::new(snapshot)
|
||||
@@ -3612,13 +3799,10 @@ mod tests {
|
||||
"builtin:companion",
|
||||
] {
|
||||
let source = format!("import {specifier:?}");
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[
|
||||
entry("main.dcdl", &source),
|
||||
entry("profiles/base.dcdl", "{ spoof = true; }"),
|
||||
],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("main.dcdl", &source),
|
||||
entry("profiles/base.dcdl", "{ spoof = true; }"),
|
||||
])
|
||||
.unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&contract)
|
||||
@@ -3684,7 +3868,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("cycle.dcdl", r#"import "./main.dcdl""#),
|
||||
]).unwrap();
|
||||
@@ -3718,7 +3902,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rejected_import_graph_edges_stay_rejected_after_diagnostics() {
|
||||
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(),
|
||||
);
|
||||
loader.load(Some("a.dcdl"), "./b.dcdl").unwrap();
|
||||
@@ -3760,7 +3944,7 @@ mod tests {
|
||||
entries.push(entry(&format!("{index}.dcdl"), &source));
|
||||
}
|
||||
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))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
@@ -3781,7 +3965,7 @@ mod tests {
|
||||
entries.push(text_entry(&format!("unused/{i}.txt"), ""));
|
||||
}
|
||||
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();
|
||||
assert!(diagnostics[0].message.contains("entry count"));
|
||||
let mut entries = vec![entry(
|
||||
@@ -3798,20 +3982,17 @@ mod tests {
|
||||
entries.push(text_entry(&format!("unused/{i}.txt"), &"a".repeat(bytes)));
|
||||
}
|
||||
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();
|
||||
assert!(diagnostics[0].message.contains("total bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_environment_evaluation_uses_only_snapshot_imports() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
3,
|
||||
[
|
||||
entry("profiles/main.dcdl", r#"import "./shared.dcdl""#),
|
||||
entry("profiles/shared.dcdl", "{ answer = 42; }"),
|
||||
],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("profiles/main.dcdl", r#"import "./shared.dcdl""#),
|
||||
entry("profiles/shared.dcdl", "{ answer = 42; }"),
|
||||
])
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::new(
|
||||
DEFAULT_SCHEMA_VERSION,
|
||||
@@ -3826,13 +4007,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[
|
||||
entry("workspace.dcdl", "{ answer = 42; }"),
|
||||
entry("unused.dcdl", "{ broken = ; }"),
|
||||
],
|
||||
)
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
entry("workspace.dcdl", "{ answer = 42; }"),
|
||||
entry("unused.dcdl", "{ broken = ; }"),
|
||||
])
|
||||
.unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1))
|
||||
@@ -3844,7 +4022,7 @@ mod tests {
|
||||
#[test]
|
||||
fn missing_import_and_cycles_are_structured_failures() {
|
||||
let missing =
|
||||
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", r#"import "./missing.dcdl""#)])
|
||||
ConfigTreeSnapshot::from_entries([entry("main.dcdl", r#"import "./missing.dcdl""#)])
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
|
||||
let diagnostics = SnapshotEnvironment::new(missing)
|
||||
@@ -3852,13 +4030,10 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert_eq!(diagnostics[0].kind, "import");
|
||||
|
||||
let cycle = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[
|
||||
entry("a.dcdl", r#"import "./b.dcdl""#),
|
||||
entry("b.dcdl", r#"import "./a.dcdl""#),
|
||||
],
|
||||
)
|
||||
let cycle = ConfigTreeSnapshot::from_entries([
|
||||
entry("a.dcdl", r#"import "./b.dcdl""#),
|
||||
entry("b.dcdl", r#"import "./a.dcdl""#),
|
||||
])
|
||||
.unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(cycle)
|
||||
.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)]
|
||||
pub struct BuiltinFlowSource {
|
||||
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 content: &'static str,
|
||||
}
|
||||
@@ -24,7 +21,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
match slug {
|
||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 5,
|
||||
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
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] {
|
||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 5,
|
||||
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}];
|
||||
@@ -64,7 +61,6 @@ mod tests {
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReview",
|
||||
"MergeRequestComplete",
|
||||
"new immutable revision",
|
||||
] {
|
||||
assert!(!source.content.contains(stale), "stale contract {stale}");
|
||||
}
|
||||
@@ -74,10 +70,6 @@ mod tests {
|
||||
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
||||
assert!(!builtin_flow_sources().is_empty());
|
||||
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| {
|
||||
panic!(
|
||||
"builtin Flow {} failed to compile: {:?}",
|
||||
@@ -88,7 +80,6 @@ mod tests {
|
||||
let selected =
|
||||
builtin_flow_source(source.slug).expect("builtin Flow must be selectable");
|
||||
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 instance_id: String,
|
||||
pub definition_id: String,
|
||||
pub definition_revision: u64,
|
||||
pub definition_digest: String,
|
||||
pub current_state: StateId,
|
||||
pub state_revision: u64,
|
||||
pub status: FlowInstanceStatus,
|
||||
pub active_attempt_id: Option<String>,
|
||||
}
|
||||
@@ -33,7 +31,6 @@ impl FlowInstance {
|
||||
pub fn start(
|
||||
instance_id: impl Into<String>,
|
||||
definition_id: impl Into<String>,
|
||||
definition_revision: u64,
|
||||
definition: &CompiledFlowDefinition,
|
||||
) -> Result<Self, FlowTransitionError> {
|
||||
let instance_id = instance_id.into();
|
||||
@@ -41,11 +38,6 @@ impl FlowInstance {
|
||||
ensure_non_empty("instance_id", &instance_id)
|
||||
.and_then(|_| ensure_non_empty("definition_id", &definition_id))
|
||||
.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(|| {
|
||||
FlowTransitionError::Invariant("compiled initial state is missing".to_string())
|
||||
})?;
|
||||
@@ -57,10 +49,8 @@ impl FlowInstance {
|
||||
Ok(Self {
|
||||
instance_id,
|
||||
definition_id,
|
||||
definition_revision,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status,
|
||||
active_attempt_id: None,
|
||||
})
|
||||
@@ -77,9 +67,7 @@ pub struct FlowTransitionRequest {
|
||||
pub struct FlowTransitionAttempt {
|
||||
pub attempt_id: String,
|
||||
pub instance_id: String,
|
||||
pub definition_revision: u64,
|
||||
pub definition_digest: String,
|
||||
pub checked_state_revision: u64,
|
||||
pub from_state: StateId,
|
||||
pub reason: String,
|
||||
pub transitions: Vec<TransitionCheckSnapshot>,
|
||||
@@ -174,7 +162,6 @@ pub enum FlowEventKind {
|
||||
TransitionRequested {
|
||||
attempt_id: String,
|
||||
state_id: StateId,
|
||||
state_revision: u64,
|
||||
reason: String,
|
||||
transitions: Vec<TransitionCheckSnapshot>,
|
||||
},
|
||||
@@ -201,7 +188,6 @@ pub enum FlowEventKind {
|
||||
StateEntered {
|
||||
attempt_id: String,
|
||||
state_id: StateId,
|
||||
state_revision: u64,
|
||||
status: FlowInstanceStatus,
|
||||
},
|
||||
}
|
||||
@@ -214,7 +200,7 @@ pub struct FlowRuntimeEvent {
|
||||
|
||||
/// 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
|
||||
/// and replaces it only after the corresponding session-log write succeeds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -240,12 +226,8 @@ impl FlowRuntimeState {
|
||||
"resolved Flow selector does not match compiled definition name".to_string(),
|
||||
));
|
||||
}
|
||||
let instance = FlowInstance::start(
|
||||
instance_id,
|
||||
source.flow_id.clone(),
|
||||
source.revision,
|
||||
&source.definition,
|
||||
)?;
|
||||
let instance =
|
||||
FlowInstance::start(instance_id, source.flow_id.clone(), &source.definition)?;
|
||||
let initial = source
|
||||
.definition
|
||||
.state(&source.definition.initial)
|
||||
@@ -257,7 +239,6 @@ impl FlowRuntimeState {
|
||||
event: FlowEventKind::StateEntered {
|
||||
attempt_id: String::new(),
|
||||
state_id: instance.current_state.clone(),
|
||||
state_revision: instance.state_revision,
|
||||
status: instance.status,
|
||||
},
|
||||
};
|
||||
@@ -282,8 +263,22 @@ impl FlowRuntimeState {
|
||||
if let Some(attempt) = &self.active_attempt {
|
||||
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 {
|
||||
attempt_id: attempt_id.into(),
|
||||
attempt_id,
|
||||
reason: reason.into(),
|
||||
};
|
||||
let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?;
|
||||
@@ -329,7 +324,7 @@ pub enum FlowTransitionError {
|
||||
NotActive,
|
||||
#[error("another transition attempt is already active")]
|
||||
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,
|
||||
#[error("invalid transition request: {0}")]
|
||||
InvalidRequest(String),
|
||||
@@ -337,7 +332,7 @@ pub enum FlowTransitionError {
|
||||
Invariant(String),
|
||||
}
|
||||
|
||||
pub fn begin_transition(
|
||||
fn begin_transition(
|
||||
instance: &mut FlowInstance,
|
||||
definition: &CompiledFlowDefinition,
|
||||
request: FlowTransitionRequest,
|
||||
@@ -374,9 +369,7 @@ pub fn begin_transition(
|
||||
let attempt = FlowTransitionAttempt {
|
||||
attempt_id: request.attempt_id,
|
||||
instance_id: instance.instance_id.clone(),
|
||||
definition_revision: instance.definition_revision,
|
||||
definition_digest: instance.definition_digest.clone(),
|
||||
checked_state_revision: instance.state_revision,
|
||||
from_state: instance.current_state.clone(),
|
||||
reason: request.reason,
|
||||
transitions,
|
||||
@@ -387,7 +380,6 @@ pub fn begin_transition(
|
||||
FlowEventKind::TransitionRequested {
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
state_id: attempt.from_state.clone(),
|
||||
state_revision: attempt.checked_state_revision,
|
||||
reason: attempt.reason.clone(),
|
||||
transitions: attempt.transitions.clone(),
|
||||
},
|
||||
@@ -398,20 +390,23 @@ pub fn begin_transition(
|
||||
Ok((attempt, events))
|
||||
}
|
||||
|
||||
pub fn resolve_transition(
|
||||
fn resolve_transition(
|
||||
instance: &mut FlowInstance,
|
||||
definition: &CompiledFlowDefinition,
|
||||
mut attempt: FlowTransitionAttempt,
|
||||
outcome: FlowVerifierOutcome,
|
||||
) -> 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(
|
||||
"attempt is not the active/latest attempt for this Flow instance".to_string(),
|
||||
));
|
||||
}
|
||||
if definition.content_digest != instance.definition_digest
|
||||
|| attempt.definition_digest != instance.definition_digest
|
||||
|| attempt.definition_revision != instance.definition_revision
|
||||
{
|
||||
return Err(FlowTransitionError::DefinitionMismatch);
|
||||
}
|
||||
@@ -447,7 +442,7 @@ pub fn resolve_transition(
|
||||
)
|
||||
}
|
||||
FlowVerifierOutcome::Completed { results } => {
|
||||
if instance.state_revision != attempt.checked_state_revision
|
||||
if instance.status != FlowInstanceStatus::Active
|
||||
|| instance.current_state != attempt.from_state
|
||||
{
|
||||
attempt.status = FlowAttemptStatus::Rejected;
|
||||
@@ -604,9 +599,6 @@ fn resolve_completed_results(
|
||||
results,
|
||||
});
|
||||
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 {
|
||||
FlowInstanceStatus::Cancelled
|
||||
} else if target_state.terminal {
|
||||
@@ -617,7 +609,6 @@ fn resolve_completed_results(
|
||||
events.push(FlowEventKind::StateEntered {
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
state_id: instance.current_state.clone(),
|
||||
state_revision: instance.state_revision,
|
||||
status: instance.status,
|
||||
});
|
||||
Ok(FlowTransitionResolution {
|
||||
@@ -735,16 +726,109 @@ mod tests {
|
||||
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]
|
||||
fn exactly_one_met_transition_enters_terminal_state() {
|
||||
let definition = definition();
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -770,7 +854,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(instance.current_state.as_str(), "done");
|
||||
assert_eq!(instance.state_revision, 1);
|
||||
|
||||
assert_eq!(instance.status, FlowInstanceStatus::Completed);
|
||||
assert!(resolution.rejection.is_none());
|
||||
assert!(matches!(
|
||||
@@ -788,10 +872,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -827,10 +909,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -852,7 +932,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(instance.current_state.as_str(), "work");
|
||||
assert_eq!(instance.state_revision, 0);
|
||||
|
||||
assert_eq!(instance.active_attempt_id, None);
|
||||
assert_eq!(
|
||||
resolution.rejection.unwrap().code,
|
||||
@@ -866,10 +946,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -901,15 +979,13 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
let attempt = begin(&mut instance);
|
||||
instance.state_revision = 1;
|
||||
instance.current_state = StateId::new("done").unwrap();
|
||||
let results = attempt
|
||||
.transitions
|
||||
.iter()
|
||||
@@ -938,10 +1014,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -967,7 +1041,7 @@ mod tests {
|
||||
FlowRejectionCode::MultipleConditionsMet
|
||||
);
|
||||
assert_eq!(instance.current_state.as_str(), "work");
|
||||
assert_eq!(instance.state_revision, 0);
|
||||
|
||||
assert!(
|
||||
resolution
|
||||
.events
|
||||
@@ -982,10 +1056,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -1015,10 +1087,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
|
||||
@@ -124,7 +124,6 @@ pub struct ResolvedFlowSource {
|
||||
pub selector: FlowSelector,
|
||||
pub workspace_id: String,
|
||||
pub flow_id: String,
|
||||
pub revision: u64,
|
||||
pub content_digest: String,
|
||||
pub definition: crate::CompiledFlowDefinition,
|
||||
}
|
||||
|
||||
@@ -66,10 +66,7 @@ impl<'a> CheckedTarget<'a> {
|
||||
} else {
|
||||
policy.is_readable_paths(logical, logical)
|
||||
}) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"checkout target denied",
|
||||
));
|
||||
return Err(crate::FsDenialReason::CheckoutTargetDenied.into_io_error());
|
||||
}
|
||||
let parent = if relative.as_os_str().is_empty() {
|
||||
None
|
||||
@@ -294,10 +291,7 @@ impl FsAccessPolicy for CheckedCreate<'_, '_> {
|
||||
if components.peek().is_some()
|
||||
&& !self.target.policy.is_writable_paths(&parent, &parent)
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Create parent denied",
|
||||
));
|
||||
return Err(crate::FsDenialReason::CheckoutCreateParentDenied.into_io_error());
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
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
|
||||
/// `openat2` so resolution and open are one kernel-enforced operation. Other
|
||||
/// 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;
|
||||
|
||||
if relative.is_absolute() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"path is outside provider root",
|
||||
));
|
||||
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
|
||||
}
|
||||
let relative = if relative.as_os_str().is_empty() {
|
||||
CString::new(".").expect("static path")
|
||||
@@ -122,10 +139,7 @@ pub fn open_beneath_no_symlinks_at(
|
||||
if fd < 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.raw_os_error() == Some(libc::ELOOP) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"symbolic links are not permitted by this provider",
|
||||
));
|
||||
return Err(crate::FsDenialReason::ProviderSymlinkDenied.into_io_error());
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
@@ -146,12 +160,9 @@ pub fn open_beneath_no_symlinks_at(
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> {
|
||||
let relative = path.strip_prefix(root).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"path is outside provider root",
|
||||
)
|
||||
})?;
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| crate::FsDenialReason::ProviderRootExceeded.into_io_error())?;
|
||||
let root = open_root_no_symlinks(root)?;
|
||||
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;
|
||||
|
||||
if relative.is_absolute() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"path is outside provider root",
|
||||
));
|
||||
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
|
||||
}
|
||||
let mut components = relative.components().peekable();
|
||||
let mut parent = root.try_clone()?;
|
||||
let mut file_name = None::<OsString>;
|
||||
while let Some(component) = components.next() {
|
||||
let std::path::Component::Normal(name) = component else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"path is outside provider root",
|
||||
));
|
||||
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
|
||||
};
|
||||
if components.peek().is_none() {
|
||||
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.
|
||||
pub job_id: String,
|
||||
pub purpose: String,
|
||||
/// Revision fenced by the structured result.
|
||||
pub input_revision: String,
|
||||
pub input_ref: String,
|
||||
/// Bounded immutable snapshot; must not contain credentials.
|
||||
pub input: Value,
|
||||
@@ -126,7 +124,6 @@ impl JobRequest {
|
||||
pub fn validate_fields(&self) -> Result<()> {
|
||||
validate_identifier("job_id", &self.job_id, 256)?;
|
||||
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("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
|
||||
self.profile_selector()?;
|
||||
@@ -157,6 +154,13 @@ impl JobRequest {
|
||||
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> {
|
||||
worker_input(self, attempt_id)
|
||||
}
|
||||
@@ -167,7 +171,8 @@ impl JobRequest {
|
||||
pub struct JobResultSubmission {
|
||||
pub job_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,
|
||||
}
|
||||
|
||||
@@ -202,7 +207,8 @@ pub enum JobAttemptState {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct JobOutcome {
|
||||
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: u8,
|
||||
pub state: JobState,
|
||||
@@ -263,11 +269,11 @@ pub fn worker_input_with_label(
|
||||
let input = serde_json::to_string(&request.input)
|
||||
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
|
||||
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.job_id,
|
||||
attempt_id,
|
||||
request.input_revision,
|
||||
request.input_digest()?,
|
||||
request.input_ref,
|
||||
input
|
||||
))
|
||||
|
||||
@@ -5,7 +5,6 @@ fn request() -> JobRequest {
|
||||
JobRequest {
|
||||
job_id: "check:r7".into(),
|
||||
purpose: "snapshot_check".into(),
|
||||
input_revision: "7".into(),
|
||||
input_ref: "snapshot://7".into(),
|
||||
input: json!({"title": "Check me"}),
|
||||
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]
|
||||
fn shared_lifecycle_enums_round_trip_all_host_states() {
|
||||
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() {
|
||||
let mut outcome = JobOutcome {
|
||||
job_id: "job-1".into(),
|
||||
input_revision: "7".into(),
|
||||
input_digest: request().input_digest().unwrap(),
|
||||
attempt_id: "job-1:attempt:2".into(),
|
||||
attempt: 2,
|
||||
state: JobState::Pending,
|
||||
@@ -66,7 +84,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
|
||||
assert_eq!(
|
||||
encoded,
|
||||
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",
|
||||
"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() {
|
||||
let request = request();
|
||||
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_eq!(
|
||||
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!({});
|
||||
assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err());
|
||||
|
||||
let input_digest = request.input_digest().unwrap();
|
||||
let submission = JobResultSubmission {
|
||||
job_id: request.job_id,
|
||||
attempt_id: "check:r7:attempt:1".into(),
|
||||
input_revision: request.input_revision,
|
||||
input_digest,
|
||||
result: json!({"ok": true}),
|
||||
};
|
||||
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 [
|
||||
|r: &mut JobRequest| r.job_id.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 = json!({"title": "changed"}),
|
||||
|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() {
|
||||
let mut r = request();
|
||||
r.job_id = "x".repeat(256);
|
||||
r.input_revision = "x".repeat(256);
|
||||
r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES);
|
||||
r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES);
|
||||
r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES);
|
||||
@@ -225,7 +242,6 @@ fn validation_enforces_utf8_bytes_and_metadata_bounds() {
|
||||
r.validate().unwrap();
|
||||
for mutate in [
|
||||
|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.input_ref.push('x'),
|
||||
|r: &mut JobRequest| r.instruction.push('x'),
|
||||
@@ -287,7 +303,7 @@ fn result_digest_and_failure_detail_bound_exact_utf8_encoding() {
|
||||
}
|
||||
|
||||
#[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 attempt = attempt_id(&request.job_id, 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_id: check:r7",
|
||||
"attempt_id: check:r7:attempt:2",
|
||||
"input_revision: 7",
|
||||
"input_digest: sha256:",
|
||||
"input_ref: snapshot://7",
|
||||
r#"input_json: {"title":"Check me"}"#,
|
||||
"structured Job result capability",
|
||||
|
||||
+113
-14
@@ -391,8 +391,7 @@ impl ResolvedMemoryFeatureConfig {
|
||||
return Err("disabled Memory feature must not carry 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");
|
||||
}
|
||||
@@ -473,8 +472,7 @@ impl ResolvedSubjektivFeatureConfig {
|
||||
return Err("disabled subjektiv feature must not carry 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");
|
||||
}
|
||||
@@ -705,10 +703,8 @@ pub fn is_normalized_workspace_memory_language(language: &str) -> bool {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceMemorySettingsSnapshot {
|
||||
/// Workspace that owns the settings revision.
|
||||
/// Workspace that owns these settings.
|
||||
pub workspace_id: String,
|
||||
/// Monotonic Workspace Memory settings revision.
|
||||
pub settings_revision: u64,
|
||||
/// Normalized language used for Memory extraction and consolidation output.
|
||||
pub language: String,
|
||||
}
|
||||
@@ -1155,7 +1151,8 @@ struct LegacyMemoryConfig {
|
||||
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;
|
||||
|
||||
/// 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
|
||||
&& version != MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
||||
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
||||
{
|
||||
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 {
|
||||
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)?);
|
||||
}
|
||||
|
||||
@@ -1240,6 +1241,32 @@ fn validate_persisted_worker_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(
|
||||
manifest: &mut serde_json::Value,
|
||||
) -> Result<(), serde_json::Error> {
|
||||
@@ -1385,6 +1412,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
|
||||
}
|
||||
feature.insert("memory".to_string(), resolved);
|
||||
migrate_legacy_manifest_authority(&mut snapshot)?;
|
||||
migrate_legacy_memory_settings_counter(&mut 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\
|
||||
[feature.memory.workspace_settings]\n\
|
||||
workspace_id = \"workspace-1\"\n\
|
||||
settings_revision = 7\n\
|
||||
language = \"日本語\"\n"
|
||||
);
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
@@ -1774,7 +1801,6 @@ model_id = "claude-sonnet-4-20250514"
|
||||
fn resolved_memory_execution_validation_fails_closed() {
|
||||
let snapshot = WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
let mut enabled = ResolvedMemoryFeatureConfig::default();
|
||||
@@ -1793,7 +1819,6 @@ model_id = "claude-sonnet-4-20250514"
|
||||
fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() {
|
||||
let snapshot = WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
let mut enabled = ResolvedSubjektivFeatureConfig::default();
|
||||
@@ -1816,6 +1841,80 @@ model_id = "claude-sonnet-4-20250514"
|
||||
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]
|
||||
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
|
||||
let mut manifest =
|
||||
@@ -1863,7 +1962,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
"Français"
|
||||
);
|
||||
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());
|
||||
|
||||
let mut disabled =
|
||||
@@ -1905,7 +2004,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
let restored = read_persisted_worker_manifest_snapshot(versioned).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"]["feature"].get("plugins").is_none());
|
||||
assert!(current["manifest"]["feature"].get("workers").is_none());
|
||||
@@ -1976,7 +2075,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
assert!(
|
||||
read_persisted_worker_manifest_snapshot(serde_json::json!({
|
||||
"schema_version": 4,
|
||||
"schema_version": 5,
|
||||
"manifest": manifest,
|
||||
}))
|
||||
.is_err()
|
||||
|
||||
@@ -1489,7 +1489,6 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for (field, value) in [
|
||||
("workspace_id", serde_json::json!("workspace-a")),
|
||||
("settings_revision", serde_json::json!(2)),
|
||||
("language", serde_json::json!("Japanese")),
|
||||
] {
|
||||
let artifact = serde_json::json!({
|
||||
|
||||
@@ -184,8 +184,6 @@ impl OperationCounts {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct MemorySettingsAudit {
|
||||
pub workspace_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub settings_revision: u64,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -108,8 +108,9 @@ pub struct EvidenceOrigin {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_definition_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub flow_definition_revision: Option<u64>,
|
||||
/// SHA-256 of the exact pinned Flow definition content, when available.
|
||||
/// 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.
|
||||
@@ -156,6 +157,33 @@ pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError>
|
||||
mod tests {
|
||||
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]
|
||||
fn splits_simple() {
|
||||
let doc = "---\nfoo: 1\n---\nbody here\n";
|
||||
|
||||
@@ -11,6 +11,7 @@ schemars = { workspace = true, features = ["chrono04"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
ticket = { path = "../ticket" }
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+109
-58
@@ -8,7 +8,7 @@ use std::{
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SCHEMA_VERSION: i64 = 12;
|
||||
const SCHEMA_VERSION: i64 = 13;
|
||||
const MAX_BODY_BYTES: usize = 16 * 1024;
|
||||
const DOMAIN_TABLES: [&str; 5] = [
|
||||
"merge_requests",
|
||||
@@ -103,7 +103,7 @@ pub struct ReviewRequestedEvent {
|
||||
pub sequence: u64,
|
||||
pub subject_ref: String,
|
||||
#[serde(default)]
|
||||
pub ticket_item_revision: String,
|
||||
pub ticket_content_digest: String,
|
||||
#[serde(default)]
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub requested_by: WorkerIdentity,
|
||||
@@ -118,7 +118,7 @@ pub struct ReviewEvent {
|
||||
pub request_event_id: String,
|
||||
pub subject_ref: String,
|
||||
#[serde(default)]
|
||||
pub ticket_item_revision: String,
|
||||
pub ticket_content_digest: String,
|
||||
#[serde(default)]
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub decision: ReviewDecision,
|
||||
@@ -249,7 +249,7 @@ pub enum MergeRequestEvidenceError {
|
||||
ReviewRequestMissing,
|
||||
ReviewRequestMismatch,
|
||||
ApprovalNotEffective,
|
||||
ItemRevisionMismatch,
|
||||
TicketContentMismatch,
|
||||
SourceSnapshotMismatch,
|
||||
RequirementApprovalMissing,
|
||||
}
|
||||
@@ -268,7 +268,7 @@ impl MergeRequestEvidenceError {
|
||||
Self::ReviewRequestMissing => "review_request_missing",
|
||||
Self::ReviewRequestMismatch => "review_request_mismatch",
|
||||
Self::ApprovalNotEffective => "approval_not_effective",
|
||||
Self::ItemRevisionMismatch => "item_revision_mismatch",
|
||||
Self::TicketContentMismatch => "ticket_content_mismatch",
|
||||
Self::SourceSnapshotMismatch => "source_snapshot_mismatch",
|
||||
Self::RequirementApprovalMissing => "requirement_approval_missing",
|
||||
}
|
||||
@@ -290,14 +290,14 @@ impl MergeRequestEvidenceError {
|
||||
"Reviewer approval does not match linked ReviewRequested evidence"
|
||||
}
|
||||
Self::ApprovalNotEffective => "Reviewer approval is not the effective review",
|
||||
Self::ItemRevisionMismatch => {
|
||||
"Reviewer approval does not attest the current Ticket revision"
|
||||
Self::TicketContentMismatch => {
|
||||
"Reviewer approval does not attest the current Ticket digest"
|
||||
}
|
||||
Self::SourceSnapshotMismatch => {
|
||||
"Reviewer approval does not attest the exact linked source set"
|
||||
}
|
||||
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)?;
|
||||
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.reviewer != review.reviewer
|
||||
|| 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
|
||||
/// source refs (recorded merged refs for completion); this function does not read
|
||||
/// 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.
|
||||
pub fn requirement_approval<'a>(
|
||||
requests: &'a [MergeRequest],
|
||||
item_revision: &str,
|
||||
content_digest: &str,
|
||||
subjects: &[MergeRequestReviewSubject],
|
||||
event_id: Option<&str>,
|
||||
) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> {
|
||||
@@ -436,8 +436,10 @@ pub fn requirement_approval<'a>(
|
||||
{
|
||||
return Err(MergeRequestEvidenceError::ApprovalNotEffective);
|
||||
}
|
||||
if review.ticket_item_revision != item_revision || item_revision.trim().is_empty() {
|
||||
return Err(MergeRequestEvidenceError::ItemRevisionMismatch);
|
||||
if review.ticket_content_digest != content_digest
|
||||
|| content_digest.trim().is_empty()
|
||||
{
|
||||
return Err(MergeRequestEvidenceError::TicketContentMismatch);
|
||||
}
|
||||
if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) {
|
||||
return Err(MergeRequestEvidenceError::SourceSnapshotMismatch);
|
||||
@@ -508,7 +510,7 @@ pub struct OpenMergeRequest {
|
||||
pub struct RequestMergeRequestReview {
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub ticket_item_revision: String,
|
||||
pub ticket_content_digest: String,
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub subject_ref: String,
|
||||
pub child_session_id: String,
|
||||
@@ -604,6 +606,7 @@ pub struct CompleteMergeRequest {
|
||||
pub struct TicketCompletionEvent {
|
||||
pub operation_id: String,
|
||||
pub ticket_id: String,
|
||||
/// Frozen historical payload only; not a content attestation or live API.
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
@@ -805,11 +808,11 @@ impl MergeRequestStore {
|
||||
}
|
||||
let mut c = self.lock()?;
|
||||
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)?;
|
||||
if current_revision != i.ticket_item_revision {
|
||||
if current_digest != i.ticket_content_digest {
|
||||
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)?
|
||||
@@ -848,7 +851,7 @@ impl MergeRequestStore {
|
||||
event_id: Uuid::now_v7().to_string(),
|
||||
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
|
||||
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,
|
||||
requested_by: WorkerIdentity {
|
||||
runtime_id: i.auth.runtime_id,
|
||||
@@ -944,12 +947,12 @@ impl MergeRequestStore {
|
||||
"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| {
|
||||
request.thread.into_iter().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => {
|
||||
Some((
|
||||
event.ticket_item_revision,
|
||||
event.ticket_content_digest,
|
||||
event.ticket_merge_request_subjects,
|
||||
))
|
||||
}
|
||||
@@ -982,7 +985,7 @@ impl MergeRequestStore {
|
||||
sequence: next_seq(&t, &ws, &mr)?,
|
||||
request_event_id: req,
|
||||
subject_ref: subject,
|
||||
ticket_item_revision,
|
||||
ticket_content_digest,
|
||||
ticket_merge_request_subjects,
|
||||
decision: i.decision,
|
||||
body: i.body,
|
||||
@@ -1056,13 +1059,12 @@ impl MergeRequestStore {
|
||||
}
|
||||
if let Some(review) = &review {
|
||||
let connection = self.lock()?;
|
||||
let current_revision =
|
||||
current_ticket_revision(&connection, &i.auth.workspace_id, &i.ticket_id)?
|
||||
let current_digest =
|
||||
current_ticket_content_digest(&connection, &i.auth.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
if review.ticket_content_digest != current_digest {
|
||||
b.push(
|
||||
"approval predates the current Ticket revision; fresh review is required"
|
||||
.into(),
|
||||
"approval predates the current Ticket digest; fresh review is required".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1147,12 +1149,12 @@ impl MergeRequestStore {
|
||||
));
|
||||
}
|
||||
let connection = self.lock()?;
|
||||
let current_revision =
|
||||
current_ticket_revision(&connection, &mr.workspace_id, &i.ticket_id)?
|
||||
let current_digest =
|
||||
current_ticket_content_digest(&connection, &mr.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
if review.ticket_content_digest != current_digest {
|
||||
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
|
||||
@@ -1227,12 +1229,12 @@ impl MergeRequestStore {
|
||||
"current effective review does not approve the source ref".into(),
|
||||
));
|
||||
}
|
||||
let current_revision =
|
||||
current_ticket_revision(&transaction, &mr.workspace_id, &i.ticket_id)?
|
||||
let current_digest =
|
||||
current_ticket_content_digest(&transaction, &mr.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
if review.ticket_content_digest != current_digest {
|
||||
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
|
||||
@@ -1655,34 +1657,13 @@ fn linked_merge_request_ids(
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn current_ticket_revision(
|
||||
fn current_ticket_content_digest(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<String>, MergeRequestError> {
|
||||
let event_index = connection
|
||||
.query_row(
|
||||
"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)
|
||||
ticket::sqlite_ticket_content_digest(connection, workspace_id, ticket_id)
|
||||
.map_err(|error| MergeRequestError::Operation(error.to_string()))
|
||||
}
|
||||
|
||||
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)? {
|
||||
SchemaState::Fresh => fresh(c),
|
||||
SchemaState::Current(SCHEMA_VERSION) => verify(c),
|
||||
SchemaState::Current(12) => migrate_v12_content_attestations(c),
|
||||
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
|
||||
"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)]
|
||||
enum SchemaState {
|
||||
Fresh,
|
||||
|
||||
@@ -24,7 +24,7 @@ fn subjects() -> Vec<MergeRequestReviewSubject> {
|
||||
fn add_review(
|
||||
request: &mut MergeRequest,
|
||||
event_id: &str,
|
||||
revision: &str,
|
||||
digest: &str,
|
||||
snapshot: &[MergeRequestReviewSubject],
|
||||
decision: ReviewDecision,
|
||||
) {
|
||||
@@ -37,7 +37,7 @@ fn add_review(
|
||||
event_id: format!("request-{event_id}"),
|
||||
sequence,
|
||||
subject_ref: subject.subject_ref.clone(),
|
||||
ticket_item_revision: revision.into(),
|
||||
ticket_content_digest: digest.into(),
|
||||
ticket_merge_request_subjects: snapshot.to_vec(),
|
||||
requested_by: actor(),
|
||||
reviewer: actor(),
|
||||
@@ -48,7 +48,7 @@ fn add_review(
|
||||
sequence: sequence + 1,
|
||||
request_event_id: requested.event_id.clone(),
|
||||
subject_ref: requested.subject_ref.clone(),
|
||||
ticket_item_revision: revision.into(),
|
||||
ticket_content_digest: digest.into(),
|
||||
ticket_merge_request_subjects: snapshot.to_vec(),
|
||||
decision,
|
||||
body: String::new(),
|
||||
@@ -84,7 +84,7 @@ fn merged_requests() -> Vec<MergeRequest> {
|
||||
add_review(
|
||||
&mut request,
|
||||
&approval_id,
|
||||
"revision-one",
|
||||
"digest-one",
|
||||
&snapshot,
|
||||
ReviewDecision::Approve,
|
||||
);
|
||||
@@ -109,22 +109,17 @@ fn merged_requests() -> Vec<MergeRequest> {
|
||||
}
|
||||
|
||||
#[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 snapshot = subjects();
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-two",
|
||||
&snapshot,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("integration-MR-1")),
|
||||
Err(MergeRequestEvidenceError::TicketContentMismatch)
|
||||
);
|
||||
add_review(
|
||||
&mut requests[0],
|
||||
"postmerge",
|
||||
"revision-two",
|
||||
"digest-two",
|
||||
&snapshot,
|
||||
ReviewDecision::Approve,
|
||||
);
|
||||
@@ -134,28 +129,23 @@ fn postmerge_latest_approval_attests_the_current_revision_without_replacing_inte
|
||||
"integration-MR-1"
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, None)
|
||||
requirement_approval(&requests, "digest-two", &snapshot, None)
|
||||
.unwrap()
|
||||
.event_id,
|
||||
"postmerge"
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, Some("postmerge"))
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("postmerge"))
|
||||
.unwrap()
|
||||
.event_id,
|
||||
"postmerge"
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
&snapshot,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
requirement_approval(&requests, "digest-one", &snapshot, Some("integration-MR-1")),
|
||||
Err(MergeRequestEvidenceError::ApprovalNotEffective)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, Some("unknown")),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("unknown")),
|
||||
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
|
||||
);
|
||||
}
|
||||
@@ -167,80 +157,70 @@ fn later_request_changes_prevents_older_requirement_approval_from_being_effectiv
|
||||
add_review(
|
||||
&mut requests[0],
|
||||
"approved",
|
||||
"revision-two",
|
||||
"digest-two",
|
||||
&snapshot,
|
||||
ReviewDecision::Approve,
|
||||
);
|
||||
add_review(
|
||||
&mut requests[0],
|
||||
"changes",
|
||||
"revision-two",
|
||||
"digest-two",
|
||||
&snapshot,
|
||||
ReviewDecision::RequestChanges,
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, None),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, None),
|
||||
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, Some("approved")),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("approved")),
|
||||
Err(MergeRequestEvidenceError::ApprovalNotEffective)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, Some("changes")),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("changes")),
|
||||
Err(MergeRequestEvidenceError::ApprovalNotApproved)
|
||||
);
|
||||
assert!(requests[0].integration_approval().is_ok());
|
||||
}
|
||||
|
||||
#[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 original = subjects();
|
||||
assert!(requirement_approval(&requests, "revision-one", &original, None).is_ok());
|
||||
assert!(requirement_approval(&requests, "digest-one", &original, None).is_ok());
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-two",
|
||||
&original,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
|
||||
requirement_approval(&requests, "digest-two", &original, Some("integration-MR-1")),
|
||||
Err(MergeRequestEvidenceError::TicketContentMismatch)
|
||||
);
|
||||
let mut changed = original.clone();
|
||||
changed[1].subject_ref = "changed-source-two".into();
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
&changed,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
requirement_approval(&requests, "digest-one", &changed, Some("integration-MR-1")),
|
||||
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
|
||||
);
|
||||
let mut reversed = original.clone();
|
||||
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
|
||||
// attestation cannot authorize completion against a smaller linked set.
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests[..1],
|
||||
"revision-one",
|
||||
"digest-one",
|
||||
&original[..1],
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-one", &original[..1], None),
|
||||
requirement_approval(&requests, "digest-one", &original[..1], None),
|
||||
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
"digest-one",
|
||||
&[original[0].clone(), original[0].clone()],
|
||||
None
|
||||
),
|
||||
@@ -249,24 +229,24 @@ fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation()
|
||||
}
|
||||
|
||||
#[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 snapshot = subjects();
|
||||
snapshot.pop();
|
||||
requests.pop();
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, None),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, None),
|
||||
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
|
||||
);
|
||||
add_review(
|
||||
&mut requests[0],
|
||||
"refreshed-set",
|
||||
"revision-two",
|
||||
"digest-two",
|
||||
&snapshot,
|
||||
ReviewDecision::Approve,
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, None)
|
||||
requirement_approval(&requests, "digest-two", &snapshot, None)
|
||||
.unwrap()
|
||||
.event_id,
|
||||
"refreshed-set"
|
||||
@@ -282,7 +262,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
|
||||
let original = subjects();
|
||||
for (case, expected) in [
|
||||
("missing", MergeRequestEvidenceError::ReviewRequestMissing),
|
||||
("revision", MergeRequestEvidenceError::ReviewRequestMismatch),
|
||||
("digest", MergeRequestEvidenceError::ReviewRequestMismatch),
|
||||
("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch),
|
||||
("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch),
|
||||
("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();
|
||||
}
|
||||
MergeRequestThreadEvent::ReviewRequested(requested) => match case {
|
||||
"revision" => requested.ticket_item_revision = "other-revision".into(),
|
||||
"digest" => requested.ticket_content_digest = "other-digest".into(),
|
||||
"snapshot" => {
|
||||
requested.ticket_merge_request_subjects.pop();
|
||||
}
|
||||
@@ -309,12 +289,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
&original,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
requirement_approval(&requests, "digest-one", &original, Some("integration-MR-1")),
|
||||
Err(expected),
|
||||
"{case}"
|
||||
);
|
||||
@@ -339,14 +314,14 @@ fn revoked_requirement_approval_is_rejected_and_discovery_uses_another_valid_mr(
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
"digest-one",
|
||||
&subjects(),
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ApprovalRevoked)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-one", &subjects(), None)
|
||||
requirement_approval(&requests, "digest-one", &subjects(), None)
|
||||
.unwrap()
|
||||
.event_id,
|
||||
"integration-MR-2"
|
||||
@@ -441,8 +416,8 @@ fn evidence_error_codes_are_stable_machine_diagnostics_separate_from_messages()
|
||||
"approval_not_effective",
|
||||
),
|
||||
(
|
||||
MergeRequestEvidenceError::ItemRevisionMismatch,
|
||||
"item_revision_mismatch",
|
||||
MergeRequestEvidenceError::TicketContentMismatch,
|
||||
"ticket_content_mismatch",
|
||||
),
|
||||
(
|
||||
MergeRequestEvidenceError::SourceSnapshotMismatch,
|
||||
|
||||
@@ -35,11 +35,24 @@ fn auth_repo(repository_id: &str) -> MergeRequestAuth {
|
||||
..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) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("db");
|
||||
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);
|
||||
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
||||
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();
|
||||
(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) {
|
||||
s.open_merge_request(OpenMergeRequest {
|
||||
merge_request_id: merge_request_id.into(),
|
||||
@@ -108,7 +336,7 @@ fn request_for(
|
||||
.collect();
|
||||
s.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: merge_request_id.into(),
|
||||
ticket_item_revision: "t".into(),
|
||||
ticket_content_digest: content_digest("Title"),
|
||||
ticket_merge_request_subjects,
|
||||
ticket_id: "T".into(),
|
||||
subject_ref: subject.into(),
|
||||
@@ -236,13 +464,13 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
|
||||
assert!(active);
|
||||
let json = serde_json::to_string(&mr).unwrap();
|
||||
for banned in [
|
||||
"revision_id",
|
||||
"digest_id",
|
||||
"attempt_id",
|
||||
"base_commit",
|
||||
"head_commit",
|
||||
"source_commit",
|
||||
"result_commit",
|
||||
"current_revision",
|
||||
"current_digest",
|
||||
] {
|
||||
assert!(!json.contains(banned), "{banned} in {json}")
|
||||
}
|
||||
@@ -375,7 +603,7 @@ fn review_revocation_invalidates_readiness() {
|
||||
}
|
||||
|
||||
#[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();
|
||||
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));",
|
||||
@@ -388,7 +616,7 @@ fn fresh_schema_uses_version_12_and_reopens_as_current() {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
12
|
||||
13
|
||||
);
|
||||
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 connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
[],
|
||||
.execute_batch(
|
||||
"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();
|
||||
connection
|
||||
@@ -750,7 +977,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
|
||||
readiness
|
||||
.blockers
|
||||
.iter()
|
||||
.any(|blocker| blocker.contains("Ticket revision"))
|
||||
.any(|blocker| blocker.contains("Ticket digest"))
|
||||
);
|
||||
|
||||
let stale_completion = CompleteMergeRequest {
|
||||
@@ -789,7 +1016,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
|
||||
.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
ticket_item_revision: "T:1".into(),
|
||||
ticket_content_digest: content_digest("Rescoped"),
|
||||
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "subject".into(),
|
||||
@@ -852,9 +1079,8 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
.unwrap();
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
[],
|
||||
.execute_batch(
|
||||
"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();
|
||||
connection
|
||||
@@ -873,11 +1099,11 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&before_refresh,
|
||||
"T:1",
|
||||
&content_digest("Rescoped"),
|
||||
&snapshot,
|
||||
Some(&integration_approval.event_id),
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
|
||||
Err(MergeRequestEvidenceError::TicketContentMismatch)
|
||||
);
|
||||
assert_eq!(before_refresh[0].merged_result().unwrap(), &merge);
|
||||
assert_eq!(
|
||||
@@ -899,7 +1125,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
ticket_item_revision: "T:1".into(),
|
||||
ticket_content_digest: content_digest("Rescoped"),
|
||||
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "subject".into(),
|
||||
@@ -927,7 +1153,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&refreshed,
|
||||
"T:1",
|
||||
&content_digest("Rescoped"),
|
||||
&snapshot,
|
||||
Some(&requirement_review.event_id),
|
||||
)
|
||||
@@ -935,7 +1161,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
&requirement_review
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(),
|
||||
requirement_approval(&refreshed, &content_digest("Rescoped"), &snapshot, None).unwrap(),
|
||||
&requirement_review
|
||||
);
|
||||
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 {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
ticket_item_revision: "t".into(),
|
||||
ticket_content_digest: content_digest("Title"),
|
||||
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "subject-one".into(),
|
||||
@@ -1058,7 +1284,13 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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
|
||||
);
|
||||
let first_request = requests
|
||||
@@ -1217,9 +1449,7 @@ fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_app
|
||||
match case {
|
||||
"source-mismatch" => altered.subject_ref = "different-source".into(),
|
||||
"not-approved" => altered.decision = ReviewDecision::RequestChanges,
|
||||
"request-mismatch" => {
|
||||
altered.ticket_item_revision = "different-revision".into()
|
||||
}
|
||||
"request-mismatch" => altered.ticket_content_digest = "different-digest".into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
connection.execute(
|
||||
|
||||
+45
-30
@@ -103,11 +103,15 @@ impl AuthenticatedInputSource {
|
||||
|
||||
/// Caller-owned identity for one state-changing Worker command.
|
||||
///
|
||||
/// A controller accepts command ids in strictly increasing order. Exact retries
|
||||
/// of an accepted id must retain the same command kind.
|
||||
/// Commands operate on the controller state at admission time, not a caller's
|
||||
/// 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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerCommandEnvelope {
|
||||
pub command_id: u64,
|
||||
}
|
||||
@@ -312,16 +316,15 @@ pub enum Method {
|
||||
/// are immutable and therefore cannot be cancelled here.
|
||||
CancelPendingSubmission {
|
||||
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 {
|
||||
expected_revision: u64,
|
||||
expected_submission_ids: Vec<String>,
|
||||
},
|
||||
/// Activate the next queued submission while the Worker is idle. This is an
|
||||
/// explicit recovery operation and never resumes a paused run implicitly.
|
||||
ContinuePending {
|
||||
expected_revision: u64,
|
||||
expected_head_id: String,
|
||||
},
|
||||
Resume {
|
||||
@@ -365,7 +368,7 @@ pub enum Method {
|
||||
ListCompletions {
|
||||
kind: CompletionKind,
|
||||
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.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
@@ -766,7 +769,6 @@ pub enum InternalWorkerKind {
|
||||
pub struct CompactionLifecycle {
|
||||
pub schema_version: u32,
|
||||
pub compaction_id: String,
|
||||
pub revision: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub internal_worker: Option<InternalWorkerRef>,
|
||||
pub state: CompactionLifecycleState,
|
||||
@@ -806,14 +808,13 @@ pub struct InternalWorkerRef {
|
||||
pub kind: InternalWorkerKind,
|
||||
}
|
||||
|
||||
/// Reconnect state for one visible Internal Worker. The revision fences live
|
||||
/// `Event::InternalWorker` updates that raced with parent snapshot assembly.
|
||||
/// Reconnect state for one visible Internal Worker. The parent serializes
|
||||
/// snapshot publication and subsequent live events on the same ordered stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct InternalWorkerSnapshot {
|
||||
pub worker: InternalWorkerRef,
|
||||
pub revision: u64,
|
||||
pub session: SessionSnapshot,
|
||||
/// Public execution metadata for this child. Older snapshots and service
|
||||
/// workers may omit it; clients must never substitute the parent's values.
|
||||
@@ -869,12 +870,11 @@ pub struct PendingSubmissionSummary {
|
||||
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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct PendingSubmissionsSnapshot {
|
||||
pub revision: u64,
|
||||
#[serde(default)]
|
||||
pub notification_count: u32,
|
||||
/// Ordered display-only previews of waiting notifications, each bounded like
|
||||
@@ -1356,7 +1356,7 @@ pub enum Event {
|
||||
Snapshot {
|
||||
session: SessionSnapshot,
|
||||
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.
|
||||
state: WorkerStateSnapshot,
|
||||
/// 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
|
||||
/// 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 {
|
||||
worker: InternalWorkerRef,
|
||||
revision: u64,
|
||||
event: Box<Event>,
|
||||
},
|
||||
/// Terminal removal fence for one parent-owned Internal Worker session.
|
||||
@@ -1383,7 +1383,6 @@ pub enum Event {
|
||||
/// the projection.
|
||||
InternalWorkerRemoved {
|
||||
worker: InternalWorkerRef,
|
||||
revision: u64,
|
||||
},
|
||||
/// Server-side segment log rotated to a fresh `SegmentStart`.
|
||||
///
|
||||
@@ -2276,6 +2275,28 @@ mod tests {
|
||||
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]
|
||||
fn lifecycle_methods_roundtrip_with_command_identity() {
|
||||
for method in [
|
||||
@@ -3048,7 +3069,6 @@ mod tests {
|
||||
CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state,
|
||||
started_at_ms: 1_700_000_000_000,
|
||||
@@ -3069,7 +3089,9 @@ mod tests {
|
||||
assert_eq!(parsed["event"], "compact_start");
|
||||
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
|
||||
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]
|
||||
@@ -3292,7 +3314,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 7,
|
||||
event: Box::new(Event::TextDone {
|
||||
text: "result".into(),
|
||||
}),
|
||||
@@ -3300,14 +3321,9 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::InternalWorker {
|
||||
worker,
|
||||
revision,
|
||||
event,
|
||||
} => {
|
||||
Event::InternalWorker { worker, event } => {
|
||||
assert_eq!(worker.session_id, "session-1");
|
||||
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"));
|
||||
}
|
||||
other => panic!("expected internal Worker event, got {other:?}"),
|
||||
@@ -3323,21 +3339,20 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 8,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
Event::InternalWorkerRemoved { worker, revision }
|
||||
if worker.session_id == "session-1" && revision == 8
|
||||
Event::InternalWorkerRemoved { worker }
|
||||
if worker.session_id == "session-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_notification_previews_are_optional_and_roundtrip() {
|
||||
let legacy = serde_json::json!({
|
||||
"revision": 1, "notification_count": 1,
|
||||
"notification_count": 1,
|
||||
"head_id": "notification-head", "submissions": []
|
||||
});
|
||||
let mut snapshot: PendingSubmissionsSnapshot =
|
||||
|
||||
@@ -322,7 +322,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
parent_session_id: Some("fixture-parent-session".into()),
|
||||
kind: InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
|
||||
@@ -250,7 +250,6 @@ pub struct WorkingDirectoryRepository {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<RepositorySelector>,
|
||||
@@ -291,9 +290,6 @@ pub struct BackendResourceHandle {
|
||||
pub operation: BackendResourceOperation,
|
||||
pub expires_at_unix_seconds: i64,
|
||||
pub nonce: String,
|
||||
pub revision: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation: Option<u64>,
|
||||
pub max_bytes: u64,
|
||||
pub content_type: String,
|
||||
pub redaction: ResourceRedactionPolicy,
|
||||
@@ -305,14 +301,14 @@ pub struct BackendResourceHandle {
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct RepositorySshCredentialCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub public_key_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||
pub host_trust_id: String,
|
||||
pub host_trust_revision: u64,
|
||||
pub host_key_fingerprint: String,
|
||||
pub access: RepositoryAccessMode,
|
||||
pub expires_at_epoch_seconds: u64,
|
||||
pub repository_id: String,
|
||||
@@ -326,7 +322,6 @@ pub struct RepositoryMaterializationContext {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub operation_id: String,
|
||||
pub config_revision: u64,
|
||||
pub config_projection_digest: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||
@@ -427,7 +422,7 @@ pub struct BackendJobExecutionBinding {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_revision: Option<String>,
|
||||
pub input_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subjektiv_consolidation: bool,
|
||||
}
|
||||
@@ -689,13 +684,11 @@ pub struct WorkerRetentionExecutionRequest {
|
||||
pub workspace_id: String,
|
||||
pub source_runtime_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub expected_worker_revision: String,
|
||||
pub source_created_at: String,
|
||||
pub removed_at: String,
|
||||
pub effective_profile: Option<String>,
|
||||
pub retention_class: Option<String>,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||
}
|
||||
@@ -718,7 +711,6 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub content_bytes: u64,
|
||||
pub content_file_count: u64,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
}
|
||||
@@ -727,7 +719,6 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub struct WorkerRetentionExecutionResult {
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
pub expected_worker_revision: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||
|
||||
@@ -41,6 +41,39 @@ fn canonical_positive_decimal(value: &str) -> bool {
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& 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> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.len() > DRIVE_TEXT_MAX_BYTES {
|
||||
@@ -110,9 +143,10 @@ pub struct DriveEntry {
|
||||
pub parent: Option<DriveEntryRef>,
|
||||
pub name: String,
|
||||
pub kind: DriveEntryKind,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub revision: String,
|
||||
#[serde(deserialize_with = "node_mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
/// 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.
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub size: Option<u32>,
|
||||
@@ -201,10 +235,10 @@ pub struct DriveReadChunkQuery {
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
/// Bind a multi-chunk read to the same file revision.
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub expected_revision: String,
|
||||
/// Require each chunk to match the same committed mutation observed in metadata.
|
||||
#[serde(deserialize_with = "mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
pub expected_mutation_id: String,
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub offset: u32,
|
||||
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
|
||||
@@ -219,8 +253,8 @@ pub struct DriveDownloadQuery {
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub expected_revision: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_mutation_id")]
|
||||
pub expected_mutation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -248,26 +282,26 @@ pub enum DriveMutation {
|
||||
},
|
||||
UpdateText {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
#[serde(deserialize_with = "mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
expected_mutation_id: String,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
text: String,
|
||||
content_type: String,
|
||||
},
|
||||
Relocate {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
#[serde(deserialize_with = "mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
expected_mutation_id: String,
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
},
|
||||
Delete {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
#[serde(deserialize_with = "mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
expected_mutation_id: String,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -287,8 +321,8 @@ pub enum DriveUploadOperation {
|
||||
Update,
|
||||
}
|
||||
/// Flat query metadata accompanies the binary body. The service rejects invalid
|
||||
/// combinations: create requires parent_id/name and forbids id/expected_revision;
|
||||
/// update requires id/expected_revision and forbids parent_id/name. Both targets
|
||||
/// combinations: create requires parent_id/name and forbids id/expected_mutation_id;
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -303,8 +337,8 @@ pub struct DriveUploadQuery {
|
||||
pub name: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub expected_revision: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_mutation_id")]
|
||||
pub expected_mutation_id: Option<String>,
|
||||
pub content_type: String,
|
||||
/// Values above the file limit still decode as u32 so the service returns
|
||||
/// typed Limit rather than a generic request-decoding error.
|
||||
@@ -422,7 +456,7 @@ impl DriveApiError {
|
||||
message: match code {
|
||||
Denied => "Drive access denied",
|
||||
NotFound => "Drive resource not found",
|
||||
Conflict => "Drive revision or name conflict",
|
||||
Conflict => "Drive content, location, or name conflict",
|
||||
Invalid => "Invalid Drive request",
|
||||
Limit => "Drive request exceeds a limit",
|
||||
StorageUnavailable => "Drive storage unavailable",
|
||||
@@ -614,7 +648,7 @@ mod tests {
|
||||
let value = json!({
|
||||
"entry":{"workspace_id":"a","node_id":"7"},
|
||||
"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",
|
||||
"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() {
|
||||
let valid = json!({"request_id":"request-a", "mutation":{
|
||||
"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();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), valid);
|
||||
@@ -675,9 +709,16 @@ mod tests {
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["id"] = json!("7");
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["expected_revision"] = json!("01");
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
for bad in [
|
||||
"".to_string(),
|
||||
"\ncontrol".to_string(),
|
||||
"x".repeat(129),
|
||||
"é".repeat(65),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
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.
|
||||
let mut boundary = valid;
|
||||
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2));
|
||||
@@ -695,18 +736,18 @@ mod tests {
|
||||
)
|
||||
.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 =
|
||||
serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap();
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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());
|
||||
for field in ["entry_workspace_id", "size", "sha256", "request_id"] {
|
||||
let mut missing = valid.clone();
|
||||
@@ -725,7 +766,7 @@ mod tests {
|
||||
"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;
|
||||
invalid["bytes"] = json!([0, 255]);
|
||||
assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err());
|
||||
@@ -763,7 +804,12 @@ mod tests {
|
||||
for (code, wire, status, message) in [
|
||||
(Denied, "denied", 403, "Drive access denied"),
|
||||
(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"),
|
||||
(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]
|
||||
fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
|
||||
let mut fixture: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
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);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("safe-number"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] =
|
||||
let mut document = fixture.clone();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["minimum"] =
|
||||
serde_json::json!(-1);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("safe-number"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
|
||||
let mut document = fixture.clone();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] =
|
||||
serde_json::json!(9_007_199_254_740_990_u64);
|
||||
let output = generate_repository_typescript(&document.to_string()).unwrap();
|
||||
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();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
|
||||
let mut document = fixture.clone();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["format"] =
|
||||
serde_json::json!("int64");
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
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"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
|
||||
@@ -82,9 +82,6 @@ pub struct WorkspaceConfigNode {
|
||||
pub struct WorkspaceConfigObserveResponse {
|
||||
pub connection_id: 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,
|
||||
/// Canonical commit entrypoints from this exact metadata snapshot.
|
||||
pub entrypoints: Vec<String>,
|
||||
@@ -121,9 +118,6 @@ pub struct WorkspaceConfigCommitRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigCommitResponse {
|
||||
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,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -178,11 +172,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 =
|
||||
serde_json::from_value(value.clone()).unwrap();
|
||||
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();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
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]
|
||||
fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() {
|
||||
for classification in [
|
||||
|
||||
@@ -51,7 +51,9 @@ pub enum LoggedSessionHistoryOrigin {
|
||||
FlowInstruction {
|
||||
selector: 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,
|
||||
state_id: String,
|
||||
},
|
||||
|
||||
@@ -3662,7 +3662,7 @@ mod tests {
|
||||
LoggedSessionHistoryOrigin::FlowInstruction {
|
||||
selector: "builtin:coder-review".into(),
|
||||
definition_id: "flow-definition".into(),
|
||||
definition_revision: 7,
|
||||
definition_digest: Some("sha256:flow-source".into()),
|
||||
instance_id: "flow-instance".into(),
|
||||
state_id: "implement".into(),
|
||||
},
|
||||
@@ -3695,7 +3695,7 @@ mod tests {
|
||||
origin: LoggedSessionHistoryOrigin::FlowInstruction {
|
||||
selector: "builtin:coder-review".into(),
|
||||
definition_id: "flow-definition".into(),
|
||||
definition_revision: 7,
|
||||
definition_digest: Some("sha256:flow-source".into()),
|
||||
instance_id: "flow-instance".into(),
|
||||
state_id: "implement".into(),
|
||||
},
|
||||
|
||||
@@ -1156,7 +1156,7 @@ mod tests {
|
||||
)))],
|
||||
extensions: vec![SessionExtension::new(
|
||||
"flow.runtime.v1",
|
||||
serde_json::json!({ "state": "implement", "revision": 0 }),
|
||||
serde_json::json!({ "state": "implement" }),
|
||||
)],
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
|
||||
@@ -77,7 +77,6 @@ impl SystemReminder {
|
||||
pub struct PromptRenderProvenance {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
pub config_revision: u64,
|
||||
pub source_digest: String,
|
||||
pub projection_digest: String,
|
||||
pub logical_name: String,
|
||||
@@ -176,10 +175,14 @@ pub enum SystemItem {
|
||||
},
|
||||
|
||||
/// 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 {
|
||||
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")]
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
@@ -292,22 +295,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 {
|
||||
body: "current Subject context".to_string(),
|
||||
behavior_revision: 7,
|
||||
behavior_md: "exact behavior".to_string(),
|
||||
prompt_provenance: None,
|
||||
};
|
||||
let raw = serde_json::to_string(&item).unwrap();
|
||||
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed.history_text(), "current Subject context");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision: 7,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior")
|
||||
);
|
||||
}
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -13,7 +13,10 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -69,7 +72,10 @@ impl JobSnapshot {
|
||||
pub fn outcome(&self) -> job::JobOutcome {
|
||||
job::JobOutcome {
|
||||
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: self.attempt.number,
|
||||
state: match self.state {
|
||||
@@ -135,8 +141,8 @@ pub enum JobStoreError {
|
||||
ConcurrentLimit,
|
||||
#[error("result or lifecycle operation is not bound to the current Job attempt")]
|
||||
AttemptMismatch,
|
||||
#[error("result input revision does not match the immutable request")]
|
||||
RevisionMismatch,
|
||||
#[error("result input digest does not match the immutable request")]
|
||||
DigestMismatch,
|
||||
#[error("accepted Job result cannot be changed")]
|
||||
ResultConflict,
|
||||
#[error("invalid Job transition: {0}")]
|
||||
@@ -176,8 +182,11 @@ impl JobStore {
|
||||
tx.execute_batch(GRANT_SCHEMA)?;
|
||||
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||
}
|
||||
1 => {
|
||||
tx.execute_batch(GRANT_SCHEMA)?;
|
||||
1 | 2 => {
|
||||
if version == 1 {
|
||||
tx.execute_batch(GRANT_SCHEMA)?;
|
||||
}
|
||||
legacy_migrations::migrate_legacy_jobs(&tx)?;
|
||||
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||
}
|
||||
SCHEMA_VERSION => {}
|
||||
@@ -370,8 +379,8 @@ impl JobStore {
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let snapshot = require_snapshot(&tx, &bound.job_id)?;
|
||||
check_attempt(&snapshot, &bound.attempt_id)?;
|
||||
if snapshot.request.input_revision != bound.input_revision {
|
||||
return Err(JobStoreError::RevisionMismatch);
|
||||
if snapshot.request.input_digest()? != bound.input_digest {
|
||||
return Err(JobStoreError::DigestMismatch);
|
||||
}
|
||||
let (digest, encoded) =
|
||||
job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?;
|
||||
@@ -527,13 +536,13 @@ fn insert_attempt(
|
||||
number: u8,
|
||||
) -> Result<(), JobStoreError> {
|
||||
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')",
|
||||
params![
|
||||
request.job_id,
|
||||
number,
|
||||
job::attempt_id(&request.job_id, number),
|
||||
request.input_revision
|
||||
request.input_digest()?
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -669,7 +678,7 @@ CREATE TABLE job_attempts (
|
||||
job_id TEXT NOT NULL REFERENCES job_intents(job_id),
|
||||
number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255),
|
||||
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')),
|
||||
result_json 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
|
||||
BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END;
|
||||
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;
|
||||
CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts
|
||||
WHEN OLD.result_json IS NOT NULL AND
|
||||
@@ -705,7 +714,6 @@ mod tests {
|
||||
JobRequest {
|
||||
job_id: id.into(),
|
||||
purpose: "fixture".into(),
|
||||
input_revision: "revision-1".into(),
|
||||
input_ref: "fixture:input".into(),
|
||||
input: json!({"immutable": [1, 2, 3]}),
|
||||
instruction: "Produce a structured result".into(),
|
||||
@@ -725,7 +733,10 @@ mod tests {
|
||||
JobResultSubmission {
|
||||
job_id: snapshot.request.job_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,
|
||||
}
|
||||
}
|
||||
@@ -762,10 +773,10 @@ mod tests {
|
||||
for sql in [
|
||||
"UPDATE job_intents SET request_json = '{}' 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_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')",
|
||||
] {
|
||||
assert!(
|
||||
@@ -818,9 +829,6 @@ mod tests {
|
||||
assert_eq!(store.reserve(original.clone()).unwrap(), snapshot);
|
||||
let mut variants = Vec::new();
|
||||
let mut changed = original.clone();
|
||||
changed.input_revision = "revision-2".into();
|
||||
variants.push(changed);
|
||||
let mut changed = original.clone();
|
||||
changed.input = json!({"changed": true});
|
||||
variants.push(changed);
|
||||
let mut changed = original.clone();
|
||||
@@ -917,12 +925,16 @@ mod tests {
|
||||
Err(JobStoreError::AttemptMismatch)
|
||||
));
|
||||
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)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
prior,
|
||||
("failed".into(), "timeout".into(), "revision-1".into())
|
||||
(
|
||||
"failed".into(),
|
||||
"timeout".into(),
|
||||
original.input_digest().unwrap()
|
||||
)
|
||||
);
|
||||
store
|
||||
.finish(
|
||||
@@ -960,10 +972,10 @@ mod tests {
|
||||
Err(JobStoreError::AttemptMismatch)
|
||||
));
|
||||
let mut invalid = valid.clone();
|
||||
invalid.input_revision = "revision-2".into();
|
||||
invalid.input_digest = "wrong-digest".into();
|
||||
assert!(matches!(
|
||||
store.accept(&invalid),
|
||||
Err(JobStoreError::RevisionMismatch)
|
||||
Err(JobStoreError::DigestMismatch)
|
||||
));
|
||||
let mut invalid = valid.clone();
|
||||
invalid.result = json!("x".repeat(32));
|
||||
@@ -1537,7 +1549,7 @@ mod tests {
|
||||
let absent = JobResultSubmission {
|
||||
job_id: "missing".into(),
|
||||
attempt_id: "attempt".into(),
|
||||
input_revision: "r".into(),
|
||||
input_digest: request("missing").input_digest().unwrap(),
|
||||
result: json!({}),
|
||||
};
|
||||
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(),
|
||||
job_id: job_id.into(),
|
||||
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,
|
||||
});
|
||||
let grant = match (
|
||||
@@ -295,7 +298,10 @@ impl StandaloneJobs {
|
||||
store: store.clone(),
|
||||
job_id: 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,
|
||||
});
|
||||
let (cancel, mut cancellation) = watch::channel(false);
|
||||
@@ -451,14 +457,14 @@ struct BoundResultSink {
|
||||
store: Arc<Mutex<Option<JobStore>>>,
|
||||
job_id: String,
|
||||
attempt_id: String,
|
||||
input_revision: String,
|
||||
input_digest: String,
|
||||
deadline: tokio::time::Instant,
|
||||
}
|
||||
impl worker::job::JobResultSink for BoundResultSink {
|
||||
fn submit(&self, submission: JobResultSubmission) -> Result<(), String> {
|
||||
if submission.job_id != self.job_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());
|
||||
}
|
||||
@@ -482,7 +488,7 @@ struct BoundAttemptFence {
|
||||
store: Arc<Mutex<Option<JobStore>>>,
|
||||
job_id: String,
|
||||
attempt_id: String,
|
||||
input_revision: String,
|
||||
input_digest: String,
|
||||
deadline: tokio::time::Instant,
|
||||
}
|
||||
impl JobAttemptFence for BoundAttemptFence {
|
||||
@@ -499,7 +505,7 @@ impl JobAttemptFence for BoundAttemptFence {
|
||||
if snapshot.state != JobState::Pending
|
||||
|| snapshot.attempt.state != JobAttemptState::Dispatched
|
||||
|| 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());
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ fn request(id: &str) -> JobRequest {
|
||||
JobRequest {
|
||||
job_id: id.into(),
|
||||
purpose: "test".into(),
|
||||
input_revision: "r1".into(),
|
||||
input_ref: "fixture:input".into(),
|
||||
input: serde_json::json!({"data":42}),
|
||||
instruction: "Return a structured result".into(),
|
||||
@@ -25,7 +24,7 @@ fn sink(jobs: &StandaloneJobs, id: &str, attempt: &str) -> BoundResultSink {
|
||||
store: jobs.store.clone(),
|
||||
job_id: id.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),
|
||||
}
|
||||
}
|
||||
@@ -33,7 +32,7 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission {
|
||||
JobResultSubmission {
|
||||
job_id: id.into(),
|
||||
attempt_id: attempt.into(),
|
||||
input_revision: "r1".into(),
|
||||
input_digest: request(id).input_digest().unwrap(),
|
||||
result: serde_json::json!({"valid":true}),
|
||||
}
|
||||
}
|
||||
@@ -99,7 +98,7 @@ type CapturedDomainAttempt = (JobSnapshot, Arc<dyn JobAttemptFence>);
|
||||
#[derive(Default)]
|
||||
struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>);
|
||||
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 {
|
||||
let mut intent = request(id);
|
||||
@@ -244,7 +243,7 @@ async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_f
|
||||
}
|
||||
|
||||
#[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 jobs =
|
||||
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 [
|
||||
("foreign-job", domain_grant("another-job")),
|
||||
(
|
||||
"foreign-revision",
|
||||
json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}),
|
||||
"foreign-digest",
|
||||
json!({"consumer":"test-domain","job_id":"foreign-digest","input_digest":"r0"}),
|
||||
),
|
||||
(
|
||||
"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();
|
||||
@@ -378,7 +377,7 @@ async fn bound_domain_does_not_change_root_generic_result_only_execution() {
|
||||
}
|
||||
|
||||
#[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 jobs =
|
||||
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());
|
||||
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());
|
||||
let mut expired = sink(&jobs, "first", &first.attempt.attempt_id);
|
||||
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 SESSIONS_DIR: &str = "sessions";
|
||||
const WORKER_DIR: &str = "worker";
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
const SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StandaloneCwdIdentity {
|
||||
@@ -79,7 +79,6 @@ pub enum StandaloneShutdownReason {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StandaloneWorkerRecord {
|
||||
pub schema_version: u32,
|
||||
pub revision: u64,
|
||||
pub worker_id: WorkerId,
|
||||
/// User-facing Worker name resolved from the profile.
|
||||
pub worker_name: String,
|
||||
@@ -194,7 +193,6 @@ impl StandaloneWorkerStore {
|
||||
let now = now_unix_ms()?;
|
||||
let record = StandaloneWorkerRecord {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
revision: 1,
|
||||
worker_id: allocation.worker_id,
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
storage_key,
|
||||
@@ -326,13 +324,12 @@ impl StandaloneWorkerStore {
|
||||
active_segment_id: Option<SegmentId>,
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let mut next = record.clone();
|
||||
next.revision = next.revision.saturating_add(1);
|
||||
next.updated_at_unix_ms = now_unix_ms()?;
|
||||
next.active_session_id = active_session_id;
|
||||
next.active_segment_id = active_segment_id;
|
||||
next.status = StandaloneWorkerStatus::Active;
|
||||
next.shutdown_reason = None;
|
||||
self.commit_record(Some(record.revision), &next)?;
|
||||
self.commit_record(Some(record), &next)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
@@ -344,13 +341,12 @@ impl StandaloneWorkerStore {
|
||||
reason: StandaloneShutdownReason,
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let mut next = record.clone();
|
||||
next.revision = next.revision.saturating_add(1);
|
||||
next.updated_at_unix_ms = now_unix_ms()?;
|
||||
next.active_session_id = active_session_id;
|
||||
next.active_segment_id = active_segment_id;
|
||||
next.status = StandaloneWorkerStatus::Stopped;
|
||||
next.shutdown_reason = Some(reason);
|
||||
self.commit_record(Some(record.revision), &next)?;
|
||||
self.commit_record(Some(record), &next)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
@@ -405,7 +401,7 @@ impl StandaloneWorkerStore {
|
||||
|
||||
fn commit_record(
|
||||
&self,
|
||||
expected_revision: Option<u64>,
|
||||
expected_record: Option<&StandaloneWorkerRecord>,
|
||||
next: &StandaloneWorkerRecord,
|
||||
) -> Result<(), StandaloneStoreError> {
|
||||
let dir = self.worker_dir(next.worker_id);
|
||||
@@ -421,19 +417,21 @@ impl StandaloneWorkerStore {
|
||||
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)?;
|
||||
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)?;
|
||||
if current.revision != expected {
|
||||
let _ = fs::remove_file(&marker);
|
||||
return Err(StandaloneStoreError::RevisionConflict {
|
||||
id: next.worker_id,
|
||||
expected,
|
||||
found: current.revision,
|
||||
});
|
||||
// The create-new commit marker excludes other writers across this
|
||||
// comparison and replacement. Compare the observed metadata itself,
|
||||
// including its manifest, pointer and lifecycle state, not a counter.
|
||||
if serde_json::to_value(¤t).map_err(StandaloneStoreError::Json)?
|
||||
!= serde_json::to_value(expected).map_err(StandaloneStoreError::Json)?
|
||||
{
|
||||
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",
|
||||
))
|
||||
})?;
|
||||
// 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(|| {
|
||||
serde_json::Error::io(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
@@ -770,14 +781,8 @@ pub enum StandaloneStoreError {
|
||||
LeaseOwnershipLost,
|
||||
#[error("standalone Worker {0} must be stopped before deletion")]
|
||||
DeleteActive(WorkerId),
|
||||
#[error(
|
||||
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})"
|
||||
)]
|
||||
RevisionConflict {
|
||||
id: WorkerId,
|
||||
expected: u64,
|
||||
found: u64,
|
||||
},
|
||||
#[error("standalone Worker {0} metadata changed since it was read")]
|
||||
MetadataChanged(WorkerId),
|
||||
#[error("system clock is before the Unix epoch or out of range")]
|
||||
Clock,
|
||||
#[error("standalone metadata serialization failed")]
|
||||
@@ -816,7 +821,6 @@ permission = "write"
|
||||
let manifest = test_manifest();
|
||||
let record = StandaloneWorkerRecord {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
revision: 6,
|
||||
worker_id,
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
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]
|
||||
fn lease_liveness_requires_positive_live_or_stale_evidence() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -352,7 +352,7 @@ fn operation_error(error: subjektiv::api::OperationError) -> SubjektivHostError
|
||||
} else if message.contains("candidate_decision_conflict") {
|
||||
"candidate_decision_conflict"
|
||||
} else {
|
||||
"revision_conflict"
|
||||
"change_conflict"
|
||||
}
|
||||
.into(),
|
||||
message,
|
||||
@@ -655,7 +655,7 @@ impl LocalSubjectHost {
|
||||
worker_id: Some(self.worker_id.to_string()),
|
||||
flow_selector: None,
|
||||
flow_definition_id: None,
|
||||
flow_definition_revision: None,
|
||||
flow_definition_fingerprint: None,
|
||||
};
|
||||
anchor.origin = Some(origin.clone());
|
||||
source.origin = Some(origin);
|
||||
@@ -835,8 +835,7 @@ impl SubjektivHost for LocalSubjectHost {
|
||||
job::DEFAULT_MAX_RESULT_BYTES,
|
||||
)
|
||||
.map_err(operation_error)?;
|
||||
let subject = self
|
||||
.catalog
|
||||
self.catalog
|
||||
.store
|
||||
.subject(&self.binding.subject_id)
|
||||
.map_err(domain_error)?
|
||||
@@ -847,7 +846,7 @@ impl SubjektivHost for LocalSubjectHost {
|
||||
};
|
||||
let request=job::JobRequest {
|
||||
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}),
|
||||
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)),
|
||||
|
||||
@@ -487,7 +487,7 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
|
||||
source: manifest::ProfileRegistrySource::User,
|
||||
name: "user-standalone".to_string(),
|
||||
path: None,
|
||||
provenance: Some("user-config-revision-7".to_string()),
|
||||
provenance: Some("user-config-digest".to_string()),
|
||||
},
|
||||
profile: Some(manifest::ProfileMetadata {
|
||||
name: Some("User standalone".to_string()),
|
||||
|
||||
@@ -91,7 +91,6 @@ fn request(id: &str) -> JobRequest {
|
||||
JobRequest {
|
||||
job_id: id.into(),
|
||||
purpose: "generic_test".into(),
|
||||
input_revision: "r1".into(),
|
||||
input_ref: "test:immutable".into(),
|
||||
input: serde_json::json!({"test_input":42}),
|
||||
instruction: "Return a structured validity result".into(),
|
||||
|
||||
@@ -273,7 +273,7 @@ impl ScriptedClient {
|
||||
"SubmitMemorySurface",
|
||||
json!({"points":[{
|
||||
"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();
|
||||
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()]);
|
||||
let surface = store.resident_surface(&subject.id).unwrap();
|
||||
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_eq!(
|
||||
snapshot.memory_refs,
|
||||
vec![subjektiv::MemoryRevisionRef {
|
||||
vec![subjektiv::MemoryChangeRef {
|
||||
memory_id: memory.id.clone(),
|
||||
revision: 1
|
||||
change_id: memory.change_id.clone()
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.built_from_store_revision,
|
||||
accepted["surface"]["store_revision"].as_u64().unwrap()
|
||||
snapshot.built_from_memory_fingerprint,
|
||||
accepted["surface"]["memory_fingerprint"].as_str().unwrap()
|
||||
);
|
||||
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();
|
||||
assert_eq!(memory.len(), 1);
|
||||
assert_eq!(memory[0].claim, claim);
|
||||
assert_eq!(memory[0].revision, 1);
|
||||
assert!(!memory[0].change_id.is_empty());
|
||||
assert_ne!(
|
||||
store.resident_surface(&subject.id).unwrap().availability,
|
||||
subjektiv::SurfaceAvailability::Ready
|
||||
|
||||
@@ -13,6 +13,7 @@ manifest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
server-api.workspace = true
|
||||
thiserror.workspace = true
|
||||
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
|
||||
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
|
||||
historical column name. Existing Backend placement and snapshot manifest keys
|
||||
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
|
||||
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
|
||||
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
|
||||
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`,
|
||||
`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)]
|
||||
struct SubjektivQueryCursor {
|
||||
subject_id: String,
|
||||
store_revision: u64,
|
||||
memory_fingerprint: String,
|
||||
query: Option<String>,
|
||||
kinds: Vec<String>,
|
||||
states: Vec<String>,
|
||||
@@ -33,10 +33,10 @@ struct SubjektivQueryCursor {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubjektivRevisionCursor {
|
||||
struct SubjektivChangeCursor {
|
||||
subject_id: String,
|
||||
memory_id: String,
|
||||
max_revision: u64,
|
||||
head_change_id: String,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ struct SubjektivRevisionCursor {
|
||||
struct SubjektivEvidenceCursor {
|
||||
subject_id: String,
|
||||
memory_id: String,
|
||||
revision: u64,
|
||||
/// Candidate/derivation reference offset within the immutable revision.
|
||||
change_id: String,
|
||||
/// Candidate/derivation reference offset within the immutable change_id.
|
||||
offset: usize,
|
||||
/// Nested offsets keep compatible historical candidates with more anchors
|
||||
/// resumable without admitting new over-limit records.
|
||||
@@ -92,14 +92,14 @@ pub fn subjektiv_surface_prepare_response(
|
||||
server_api::SubjektivSurfacePrepareResponse {
|
||||
generation_id: generation.id,
|
||||
current_snapshot_id: None,
|
||||
store_revision: generation.store_revision,
|
||||
memory_fingerprint: generation.memory_fingerprint,
|
||||
active_memory_count: generation.active_memory_count,
|
||||
materials: generation
|
||||
.materials
|
||||
.into_iter()
|
||||
.map(|material| server_api::SubjektivSurfaceMaterial {
|
||||
memory_id: material.memory_id,
|
||||
revision: material.revision,
|
||||
change_id: material.change_id.clone(),
|
||||
kind: material.kind,
|
||||
body_md: material.body_md,
|
||||
why_useful: material.why_useful,
|
||||
@@ -120,7 +120,7 @@ pub fn subjektiv_candidate_summary(
|
||||
candidate_id: candidate.id,
|
||||
kind: candidate.kind,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -137,30 +137,26 @@ pub fn subjektiv_candidate(
|
||||
source: candidate.source,
|
||||
evidence: candidate.evidence,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subjektiv_proposal_metadata(
|
||||
proposal: crate::RevisionProposal,
|
||||
) -> server_api::SubjektivMemoryRevisionProposalMetadata {
|
||||
server_api::SubjektivMemoryRevisionProposalMetadata {
|
||||
proposal: crate::ChangeProposal,
|
||||
) -> server_api::SubjektivMemoryChangeProposalMetadata {
|
||||
server_api::SubjektivMemoryChangeProposalMetadata {
|
||||
memory_id: proposal.memory_id,
|
||||
expected_revision: proposal.expected_revision,
|
||||
expected_change_id: proposal.expected_change_id.clone(),
|
||||
intent: match proposal.intent {
|
||||
crate::RevisionProposalIntent::Revise => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Revise
|
||||
crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
|
||||
crate::ChangeProposalIntent::Resolve => {
|
||||
server_api::SubjektivMemoryChangeIntent::Resolve
|
||||
}
|
||||
crate::RevisionProposalIntent::Resolve => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Resolve
|
||||
}
|
||||
crate::RevisionProposalIntent::Retract => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Retract
|
||||
}
|
||||
crate::RevisionProposalIntent::Reopen => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Reopen
|
||||
crate::ChangeProposalIntent::Retract => {
|
||||
server_api::SubjektivMemoryChangeIntent::Retract
|
||||
}
|
||||
crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
|
||||
},
|
||||
change_reason: proposal.change_reason,
|
||||
}
|
||||
@@ -172,15 +168,13 @@ pub fn subjektiv_candidate_decision(
|
||||
let decision = match input.decision {
|
||||
server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => {
|
||||
let target = match target {
|
||||
server_api::SubjektivMemoryApplyTarget::Create => {
|
||||
crate::MemoryRevisionTarget::Create
|
||||
}
|
||||
server_api::SubjektivMemoryApplyTarget::Create => crate::MemoryChangeTarget::Create,
|
||||
server_api::SubjektivMemoryApplyTarget::Revise {
|
||||
memory_id,
|
||||
expected_revision,
|
||||
} => crate::MemoryRevisionTarget::Revise {
|
||||
expected_change_id,
|
||||
} => crate::MemoryChangeTarget::Revise {
|
||||
memory_id,
|
||||
expected_revision,
|
||||
expected_change_id,
|
||||
},
|
||||
};
|
||||
crate::CandidateDecision::Apply {
|
||||
@@ -196,9 +190,9 @@ pub fn subjektiv_candidate_decision(
|
||||
derived_from: memory
|
||||
.derived_from
|
||||
.into_iter()
|
||||
.map(|reference| crate::MemoryRevisionRef {
|
||||
.map(|reference| crate::MemoryChangeRef {
|
||||
memory_id: reference.memory_id,
|
||||
revision: reference.revision,
|
||||
change_id: reference.change_id.clone(),
|
||||
})
|
||||
.collect(),
|
||||
change_reason: memory.change_reason,
|
||||
@@ -225,9 +219,9 @@ pub fn subjektiv_candidate_decision(
|
||||
},
|
||||
affected_memory: affected_memory
|
||||
.into_iter()
|
||||
.map(|reference| crate::MemoryRevisionRef {
|
||||
.map(|reference| crate::MemoryChangeRef {
|
||||
memory_id: reference.memory_id,
|
||||
revision: reference.revision,
|
||||
change_id: reference.change_id.clone(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
@@ -281,17 +275,17 @@ pub fn subjektiv_candidate_decision_response(
|
||||
.into_iter()
|
||||
.map(|reference| server_api::SubjektivMemoryAffectedRef {
|
||||
memory_id: reference.memory_id,
|
||||
revision: reference.revision,
|
||||
change_id: reference.change_id.clone(),
|
||||
operation,
|
||||
})
|
||||
.collect(),
|
||||
memory: receipt
|
||||
.memory
|
||||
.map(|memory| server_api::SubjektivMemoryRevisionRef {
|
||||
.map(|memory| server_api::SubjektivMemoryChangeRef {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -316,10 +310,19 @@ pub fn subjektiv_memory_query(
|
||||
.filter(|value| !value.is_empty());
|
||||
let kinds = canonical_subjektiv_kinds(input.kinds)?;
|
||||
let states = canonical_subjektiv_states(input.states)?;
|
||||
let subject = store
|
||||
let _subject = store
|
||||
.subject(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.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;
|
||||
if let Some(cursor) = input.cursor {
|
||||
let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?;
|
||||
@@ -333,7 +336,7 @@ pub fn subjektiv_memory_query(
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if cursor.store_revision != subject.store_revision {
|
||||
if cursor.memory_fingerprint != memory_fingerprint {
|
||||
return Err(OperationError::Conflict(
|
||||
"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 state_filter = states.iter().cloned().collect::<HashSet<_>>();
|
||||
let records = store
|
||||
.list_memories(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
let records = all_records
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
kind_filter.contains(record.kind.as_str())
|
||||
@@ -369,7 +370,7 @@ pub fn subjektiv_memory_query(
|
||||
.iter()
|
||||
.map(|record| server_api::SubjektivMemoryQueryItem {
|
||||
id: record.id.clone(),
|
||||
revision: record.revision,
|
||||
change_id: record.change_id.clone(),
|
||||
kind: record.kind.clone(),
|
||||
state: api_memory_state(record.state),
|
||||
claim: record.claim.clone(),
|
||||
@@ -383,7 +384,7 @@ pub fn subjektiv_memory_query(
|
||||
"query",
|
||||
&SubjektivQueryCursor {
|
||||
subject_id: subject_id.to_string(),
|
||||
store_revision: subject.store_revision,
|
||||
memory_fingerprint: memory_fingerprint,
|
||||
query,
|
||||
kinds,
|
||||
states,
|
||||
@@ -407,18 +408,18 @@ pub fn subjektiv_memory_read(
|
||||
let current = store
|
||||
.scoped_memory(subject_id, &input.memory_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let record = match input.revision {
|
||||
Some(0) => {
|
||||
return Err(
|
||||
OperationError::InvalidInput("revision must be a positive integer".into()).into(),
|
||||
);
|
||||
let record = match input.change_id.clone() {
|
||||
Some(id) if id.trim().is_empty() => {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"change_id must be nonempty".into(),
|
||||
));
|
||||
}
|
||||
Some(revision) => store
|
||||
.scoped_memory_revision(subject_id, &input.memory_id, revision)
|
||||
Some(change_id) => store
|
||||
.scoped_memory_change(subject_id, &input.memory_id, change_id.clone())
|
||||
.map_err(subjektiv_store_error)?
|
||||
.ok_or_else(|| {
|
||||
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
|
||||
))
|
||||
})?,
|
||||
@@ -433,9 +434,9 @@ pub fn subjektiv_memory_read(
|
||||
}
|
||||
let body_offset = input.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(
|
||||
"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());
|
||||
}
|
||||
@@ -477,10 +478,10 @@ pub fn subjektiv_memory_read(
|
||||
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
|
||||
if cursor.subject_id != subject_id
|
||||
|| cursor.memory_id != record.id
|
||||
|| cursor.revision != record.revision
|
||||
|| cursor.change_id != record.change_id
|
||||
{
|
||||
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());
|
||||
}
|
||||
@@ -570,9 +571,9 @@ pub fn subjektiv_memory_read(
|
||||
}
|
||||
let derived_index = evidence_offset - record.source_candidate_ids.len();
|
||||
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(),
|
||||
revision: reference.revision,
|
||||
change_id: reference.change_id.clone(),
|
||||
});
|
||||
next_evidence_offset += 1;
|
||||
} else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 {
|
||||
@@ -592,7 +593,7 @@ pub fn subjektiv_memory_read(
|
||||
&SubjektivEvidenceCursor {
|
||||
subject_id: subject_id.to_string(),
|
||||
memory_id: record.id.clone(),
|
||||
revision: record.revision,
|
||||
change_id: record.change_id.clone(),
|
||||
offset: next_evidence_offset,
|
||||
evidence_offset: next_nested_evidence_offset,
|
||||
source_ref_offset: next_nested_source_ref_offset,
|
||||
@@ -603,8 +604,8 @@ pub fn subjektiv_memory_read(
|
||||
|
||||
let mut response = server_api::SubjektivMemoryReadResponse {
|
||||
memory_id: record.id,
|
||||
revision: record.revision,
|
||||
current_revision: current.revision,
|
||||
change_id: record.change_id.clone(),
|
||||
current_change_id: current.change_id.clone(),
|
||||
kind: record.kind,
|
||||
state: api_memory_state(record.state),
|
||||
claim: record.claim,
|
||||
@@ -635,36 +636,41 @@ pub fn subjektiv_memory_read(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn subjektiv_memory_list_revisions(
|
||||
pub fn subjektiv_memory_list_changes(
|
||||
store: &crate::SubjektivStore,
|
||||
subject_id: &str,
|
||||
input: server_api::SubjektivMemoryListRevisionsRequest,
|
||||
) -> OperationResult<server_api::SubjektivMemoryListRevisionsResponse> {
|
||||
input: server_api::SubjektivMemoryListChangesRequest,
|
||||
) -> OperationResult<server_api::SubjektivMemoryListChangesResponse> {
|
||||
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
|
||||
let current = store
|
||||
.scoped_memory(subject_id, &input.memory_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let (max_revision, offset) = if let Some(cursor) = input.cursor {
|
||||
let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?;
|
||||
let (head_change_id, offset) = if let Some(cursor) = input.cursor {
|
||||
let cursor: SubjektivChangeCursor = decode_subjektiv_cursor("changes", &cursor)?;
|
||||
if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id {
|
||||
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());
|
||||
}
|
||||
(cursor.max_revision, cursor.offset)
|
||||
(cursor.head_change_id, cursor.offset)
|
||||
} else {
|
||||
(current.revision, 0)
|
||||
(current.change_id.clone(), 0)
|
||||
};
|
||||
let records = store
|
||||
.list_memory_revisions(subject_id, &input.memory_id)
|
||||
.list_memory_changes(subject_id, &input.memory_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.into_iter()
|
||||
.filter(|record| record.revision <= max_revision)
|
||||
.skip_while(|record| record.change_id != head_change_id)
|
||||
.collect::<Vec<_>>();
|
||||
if records.is_empty() {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"subjektiv history cursor refers to an unknown change".into(),
|
||||
));
|
||||
}
|
||||
if offset > records.len() {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"subjektiv revision cursor offset is invalid".into(),
|
||||
"subjektiv change_id cursor offset is invalid".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -672,8 +678,8 @@ pub fn subjektiv_memory_list_revisions(
|
||||
let has_more = end < records.len();
|
||||
let items = records[offset..end]
|
||||
.iter()
|
||||
.map(|record| server_api::SubjektivMemoryRevisionItem {
|
||||
revision: record.revision,
|
||||
.map(|record| server_api::SubjektivMemoryChangeItem {
|
||||
change_id: record.change_id.clone(),
|
||||
kind: record.kind.clone(),
|
||||
state: api_memory_state(record.state),
|
||||
claim: record.claim.clone(),
|
||||
@@ -684,19 +690,19 @@ pub fn subjektiv_memory_list_revisions(
|
||||
let next_cursor = has_more
|
||||
.then(|| {
|
||||
encode_subjektiv_cursor(
|
||||
"revisions",
|
||||
&SubjektivRevisionCursor {
|
||||
"changes",
|
||||
&SubjektivChangeCursor {
|
||||
subject_id: subject_id.to_string(),
|
||||
memory_id: input.memory_id.clone(),
|
||||
max_revision,
|
||||
head_change_id,
|
||||
offset: end,
|
||||
},
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(server_api::SubjektivMemoryListRevisionsResponse {
|
||||
Ok(server_api::SubjektivMemoryListChangesResponse {
|
||||
memory_id: input.memory_id,
|
||||
current_revision: current.revision,
|
||||
current_change_id: current.change_id.clone(),
|
||||
items,
|
||||
next_cursor,
|
||||
has_more,
|
||||
@@ -708,25 +714,25 @@ pub fn subjektiv_memory_validate_proposal(
|
||||
subject_id: &str,
|
||||
input: server_api::SubjektivMemoryValidateProposalRequest,
|
||||
) -> OperationResult<server_api::SubjektivMemoryProposalValidationResponse> {
|
||||
if input.expected_revision == 0 {
|
||||
if input.expected_change_id.trim().is_empty() {
|
||||
return Err(
|
||||
OperationError::InvalidInput("expected_revision must be positive".into()).into(),
|
||||
OperationError::InvalidInput("expected_change_id must be nonempty".into()).into(),
|
||||
);
|
||||
}
|
||||
let current = store
|
||||
.scoped_memory(subject_id, &input.memory_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
if current.revision != input.expected_revision {
|
||||
if current.change_id != input.expected_change_id {
|
||||
return Err(OperationError::Conflict(format!(
|
||||
"revision_conflict: Memory `{}` expected {}, current {}",
|
||||
input.memory_id, input.expected_revision, current.revision
|
||||
"change_conflict: Memory `{}` expected {}, current {}",
|
||||
input.memory_id, input.expected_change_id, current.change_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
validate_subjektiv_proposal_transition(current.state, input.intent)?;
|
||||
Ok(server_api::SubjektivMemoryProposalValidationResponse {
|
||||
memory_id: input.memory_id,
|
||||
current_revision: current.revision,
|
||||
current_change_id: current.change_id.clone(),
|
||||
kind: current.kind,
|
||||
state: api_memory_state(current.state),
|
||||
intent: input.intent,
|
||||
@@ -821,10 +827,10 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
.proposal
|
||||
.as_ref()
|
||||
.map(|proposal| {
|
||||
crate::RevisionProposal::new(
|
||||
crate::ChangeProposal::new(
|
||||
domain_proposal_intent(proposal.intent),
|
||||
proposal.memory_id.clone(),
|
||||
proposal.expected_revision,
|
||||
proposal.expected_change_id.clone(),
|
||||
proposal.change_reason.clone(),
|
||||
)
|
||||
})
|
||||
@@ -836,7 +842,7 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
.map_err(subjektiv_store_error)?;
|
||||
if current.kind != input.kind {
|
||||
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());
|
||||
}
|
||||
@@ -859,7 +865,7 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging);
|
||||
if let Some(proposal) = proposal.clone() {
|
||||
staging
|
||||
.attach_revision_proposal(proposal)
|
||||
.attach_change_proposal(proposal)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
}
|
||||
let (staged, _) = store
|
||||
@@ -873,7 +879,7 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
.as_ref()
|
||||
.map(|proposal| server_api::SubjektivMemoryProposalTarget {
|
||||
memory_id: proposal.memory_id.clone(),
|
||||
expected_revision: proposal.expected_revision,
|
||||
expected_change_id: proposal.expected_change_id.clone(),
|
||||
}),
|
||||
intent: proposal
|
||||
.as_ref()
|
||||
@@ -883,10 +889,10 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
|
||||
pub fn validate_subjektiv_proposal_transition(
|
||||
state: crate::MemoryState,
|
||||
intent: server_api::SubjektivMemoryRevisionIntent,
|
||||
intent: server_api::SubjektivMemoryChangeIntent,
|
||||
) -> OperationResult<()> {
|
||||
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 {
|
||||
Revise => matches!(state, Active | Resolved),
|
||||
Resolve => state == Active,
|
||||
@@ -979,32 +985,24 @@ pub fn subjektiv_memory_state_name(state: crate::MemoryState) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn domain_proposal_intent(
|
||||
intent: server_api::SubjektivMemoryRevisionIntent,
|
||||
) -> crate::RevisionProposalIntent {
|
||||
intent: server_api::SubjektivMemoryChangeIntent,
|
||||
) -> crate::ChangeProposalIntent {
|
||||
match intent {
|
||||
server_api::SubjektivMemoryRevisionIntent::Revise => crate::RevisionProposalIntent::Revise,
|
||||
server_api::SubjektivMemoryRevisionIntent::Resolve => {
|
||||
crate::RevisionProposalIntent::Resolve
|
||||
}
|
||||
server_api::SubjektivMemoryRevisionIntent::Retract => {
|
||||
crate::RevisionProposalIntent::Retract
|
||||
}
|
||||
server_api::SubjektivMemoryRevisionIntent::Reopen => crate::RevisionProposalIntent::Reopen,
|
||||
server_api::SubjektivMemoryChangeIntent::Revise => crate::ChangeProposalIntent::Revise,
|
||||
server_api::SubjektivMemoryChangeIntent::Resolve => crate::ChangeProposalIntent::Resolve,
|
||||
server_api::SubjektivMemoryChangeIntent::Retract => crate::ChangeProposalIntent::Retract,
|
||||
server_api::SubjektivMemoryChangeIntent::Reopen => crate::ChangeProposalIntent::Reopen,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_proposal_intent(
|
||||
intent: crate::RevisionProposalIntent,
|
||||
) -> server_api::SubjektivMemoryRevisionIntent {
|
||||
intent: crate::ChangeProposalIntent,
|
||||
) -> server_api::SubjektivMemoryChangeIntent {
|
||||
match intent {
|
||||
crate::RevisionProposalIntent::Revise => server_api::SubjektivMemoryRevisionIntent::Revise,
|
||||
crate::RevisionProposalIntent::Resolve => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Resolve
|
||||
}
|
||||
crate::RevisionProposalIntent::Retract => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Retract
|
||||
}
|
||||
crate::RevisionProposalIntent::Reopen => server_api::SubjektivMemoryRevisionIntent::Reopen,
|
||||
crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
|
||||
crate::ChangeProposalIntent::Resolve => server_api::SubjektivMemoryChangeIntent::Resolve,
|
||||
crate::ChangeProposalIntent::Retract => server_api::SubjektivMemoryChangeIntent::Retract,
|
||||
crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1189,6 +1187,7 @@ pub fn bounded_subjektiv_evidence_origin(
|
||||
&mut origin.worker_id,
|
||||
&mut origin.flow_selector,
|
||||
&mut origin.flow_definition_id,
|
||||
&mut origin.flow_definition_fingerprint,
|
||||
] {
|
||||
if let Some(value) = field.take() {
|
||||
*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 { .. } => {
|
||||
OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}"))
|
||||
}
|
||||
crate::SubjektivError::RevisionConflict { .. }
|
||||
crate::SubjektivError::MemoryChangeConflict { .. }
|
||||
| crate::SubjektivError::SubjectBehaviorConflict { .. }
|
||||
| crate::SubjektivError::SurfaceGenerationConflict(_) => {
|
||||
OperationError::Conflict(format!("revision_conflict: {error}"))
|
||||
OperationError::Conflict(format!("change_conflict: {error}"))
|
||||
}
|
||||
crate::SubjektivError::CandidateResolved(_)
|
||||
| crate::SubjektivError::DecisionRequestConflict(_) => {
|
||||
|
||||
@@ -28,14 +28,13 @@ pub fn execute(
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Response::ResidentContext(server_api::SubjektivResidentContextOutput {
|
||||
behavior_md: resident.subject.behavior_md,
|
||||
behavior_revision: resident.subject.behavior_revision,
|
||||
memory_surface: resident_summary_output(resident.surface),
|
||||
})
|
||||
}
|
||||
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::ListRevisions(input) => {
|
||||
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?)
|
||||
Op::ListChanges(input) => {
|
||||
Response::ListChanges(subjektiv_memory_list_changes(store, subject_id, input)?)
|
||||
}
|
||||
Op::ValidateProposal(input) => {
|
||||
context.body()?;
|
||||
@@ -169,7 +168,7 @@ pub fn execute(
|
||||
if resident.availability == crate::SurfaceAvailability::Ready {
|
||||
response.current_snapshot_id = resident
|
||||
.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);
|
||||
}
|
||||
Response::SurfacePrepared(response)
|
||||
@@ -194,9 +193,9 @@ pub fn execute(
|
||||
memory_refs: point
|
||||
.memory_refs
|
||||
.into_iter()
|
||||
.map(|r| crate::MemoryRevisionRef {
|
||||
.map(|r| crate::MemoryChangeRef {
|
||||
memory_id: r.memory_id,
|
||||
revision: r.revision,
|
||||
change_id: r.change_id,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -206,7 +205,7 @@ pub fn execute(
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -222,7 +221,7 @@ pub fn execute(
|
||||
)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
}
|
||||
let revision = store
|
||||
let memory_fingerprint = store
|
||||
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let status = if let Some(job) = job {
|
||||
@@ -230,7 +229,7 @@ pub fn execute(
|
||||
.validate_job_surface_outcome(
|
||||
subject_id,
|
||||
&input.generation_id,
|
||||
revision,
|
||||
memory_fingerprint.clone(),
|
||||
"failed",
|
||||
None,
|
||||
Some(&input.reason_code),
|
||||
@@ -247,7 +246,7 @@ pub fn execute(
|
||||
"failed"
|
||||
};
|
||||
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
|
||||
store_revision: revision,
|
||||
memory_fingerprint,
|
||||
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 {
|
||||
memory_id: id.into(),
|
||||
revision,
|
||||
change_id,
|
||||
offset,
|
||||
byte_offset,
|
||||
limit: Some(1),
|
||||
@@ -86,7 +91,7 @@ fn neutral_scope_reopen_and_context_attenuation() {
|
||||
}
|
||||
|
||||
#[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 (_manager, store) = open(temp.path(), "local-random-scope");
|
||||
let subject = store
|
||||
@@ -148,7 +153,12 @@ fn shared_query_cursor_and_utf8_fixed_revision_projection() {
|
||||
let Response::Read(page) = execute(
|
||||
&store,
|
||||
&context,
|
||||
read(&first.id, Some(1), offset, byte_offset),
|
||||
read(
|
||||
&first.id,
|
||||
Some(first.change_id.clone()),
|
||||
offset,
|
||||
byte_offset,
|
||||
),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!()
|
||||
@@ -247,7 +257,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
|
||||
entry_range: Some([1, 1]),
|
||||
kind: memory::schema::EvidenceKind::new("model_output"),
|
||||
origin: None,
|
||||
excerpt: Some("Use immutable revisions".into()),
|
||||
excerpt: Some("Use immutable changes".into()),
|
||||
summary: None,
|
||||
}],
|
||||
source_refs: vec![memory::schema::SourceEvidenceRef {
|
||||
@@ -319,7 +329,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
|
||||
kind: CandidateKind::Lesson,
|
||||
state: server_api::SubjektivMemoryState::Active,
|
||||
claim: "Preserve correction history".into(),
|
||||
body_md: "Use immutable revisions".into(),
|
||||
body_md: "Use immutable changes".into(),
|
||||
why_useful: "Allows auditing".into(),
|
||||
staleness: None,
|
||||
derived_from: vec![],
|
||||
@@ -341,3 +351,104 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
|
||||
)
|
||||
.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 {
|
||||
availability: String,
|
||||
generation_id: String,
|
||||
store_revision: u64,
|
||||
memory_fingerprint: String,
|
||||
#[serde(default)]
|
||||
snapshot_id: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -105,7 +105,7 @@ pub fn validate_result(
|
||||
.validate_job_surface_outcome(
|
||||
subject_id,
|
||||
&result.surface.generation_id,
|
||||
result.surface.store_revision,
|
||||
result.surface.memory_fingerprint,
|
||||
&result.surface.availability,
|
||||
result.surface.snapshot_id.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
|
||||
/// 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.
|
||||
pub fn with_dispositions(
|
||||
store: &SubjektivStore,
|
||||
|
||||
@@ -22,7 +22,7 @@ fn escaped_ids_use_exact_t704_complete_result_budget() {
|
||||
.collect();
|
||||
let result = serde_json::json!({
|
||||
"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!(
|
||||
@@ -119,7 +119,7 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
|
||||
.publish_surface_generation(&subject.id, &generation.id, vec![])
|
||||
.unwrap();
|
||||
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!(
|
||||
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
|
||||
.unwrap_err()
|
||||
@@ -170,11 +170,11 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
|
||||
let failure = store
|
||||
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
|
||||
.unwrap();
|
||||
let revision = store
|
||||
let memory_fingerprint = store
|
||||
.fail_surface_generation(&subject.id, &failure.id, "model_failure")
|
||||
.unwrap();
|
||||
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();
|
||||
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
|
||||
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),
|
||||
#[error("ticket target authority is unavailable")]
|
||||
TargetAuthorityUnavailable,
|
||||
#[error("stale ticket item revision: expected `{expected}`, found `{actual}`")]
|
||||
StaleItemRevision { expected: String, actual: String },
|
||||
#[error("stale ticket content: expected `{expected}`, found `{actual}`")]
|
||||
StaleContent { expected: String, actual: String },
|
||||
#[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")]
|
||||
StaleWorkflowState { expected: String, actual: String },
|
||||
#[error("invalid ticket workflow transition `{from}` -> `{to}`")]
|
||||
@@ -473,7 +473,7 @@ impl TicketStateChange {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketCompletion {
|
||||
pub expected_item_revision: String,
|
||||
pub expected_content_digest: String,
|
||||
pub expected_state: TicketWorkflowState,
|
||||
pub operation_key: String,
|
||||
pub reason: String,
|
||||
@@ -483,7 +483,7 @@ pub struct TicketCompletion {
|
||||
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
|
||||
/// reapplying the recorded state, even after later edits or 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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketStateUpdate {
|
||||
pub expected_item_revision: String,
|
||||
pub expected_content_digest: String,
|
||||
pub expected_state: TicketWorkflowState,
|
||||
pub state: TicketWorkflowState,
|
||||
pub operation_key: String,
|
||||
@@ -505,7 +505,7 @@ pub struct TicketStateUpdate {
|
||||
impl From<TicketCompletion> for TicketStateUpdate {
|
||||
fn from(request: TicketCompletion) -> Self {
|
||||
Self {
|
||||
expected_item_revision: request.expected_item_revision,
|
||||
expected_content_digest: request.expected_content_digest,
|
||||
expected_state: request.expected_state,
|
||||
state: TicketWorkflowState::Done,
|
||||
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
|
||||
/// Request review records. Only persisted item_edit event indexes advance it.
|
||||
pub fn ticket_item_revision(ticket: &Ticket) -> String {
|
||||
let index = ticket
|
||||
.events
|
||||
/// SHA-256 of the actual editable Ticket requirements: identity, title, body,
|
||||
/// and ordered repository targets. Comments, timestamps, workflow state, and
|
||||
/// event order are not requirements. Restoring identical content restores its
|
||||
/// digest; state CAS and operation receipts are independent preconditions.
|
||||
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()
|
||||
.filter(|event| event.kind == TicketEventKind::Other("item_edit".to_owned()))
|
||||
.filter_map(|event| event.attributes.get("event_sequence")?.parse::<i64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
format!("{}:{index}", ticket.meta.id)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -2402,7 +2454,7 @@ impl SqliteTicketBackend {
|
||||
]
|
||||
.map(|key| self.event_attributes.get(key));
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"ticket.state-update.v1\0");
|
||||
digest.update(b"ticket.state-update.v2\0");
|
||||
digest.update(
|
||||
serde_json::to_vec(&(ticket_id, request, body, source))
|
||||
.map_err(|error| TicketError::Conflict(error.to_string()))?,
|
||||
@@ -2437,7 +2489,7 @@ impl SqliteTicketBackend {
|
||||
body: MarkdownText,
|
||||
) -> Result<Ticket> {
|
||||
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 {
|
||||
from: request.expected_state.to_string(),
|
||||
to: request.state.to_string(),
|
||||
@@ -2457,7 +2509,7 @@ impl SqliteTicketBackend {
|
||||
.attributes
|
||||
.get("fingerprint_version")
|
||||
.map(String::as_str)
|
||||
!= Some("state-update-v1")
|
||||
!= Some("state-update-v2")
|
||||
|| event.attributes.get("request_fingerprint") != Some(&fingerprint)
|
||||
{
|
||||
return Err(TicketError::OperationFingerprintMismatch {
|
||||
@@ -2471,10 +2523,10 @@ impl SqliteTicketBackend {
|
||||
// Exact recorded replay is independent of today's live role/assignment.
|
||||
// Parent transport authenticates identity before restoring receipt context.
|
||||
self.require_active_source_assignment(conn, ticket_id)?;
|
||||
let actual = ticket_item_revision(&previous);
|
||||
if actual != request.expected_item_revision {
|
||||
return Err(TicketError::StaleItemRevision {
|
||||
expected: request.expected_item_revision.clone(),
|
||||
let actual = ticket_content_digest(&previous);
|
||||
if actual != request.expected_content_digest {
|
||||
return Err(TicketError::StaleContent {
|
||||
expected: request.expected_content_digest.clone(),
|
||||
actual,
|
||||
});
|
||||
}
|
||||
@@ -2526,11 +2578,11 @@ impl SqliteTicketBackend {
|
||||
("request_fingerprint".to_owned(), fingerprint),
|
||||
(
|
||||
"fingerprint_version".to_owned(),
|
||||
"state-update-v1".to_owned(),
|
||||
"state-update-v2".to_owned(),
|
||||
),
|
||||
(
|
||||
"expected_item_revision".to_owned(),
|
||||
request.expected_item_revision.clone(),
|
||||
"expected_content_digest".to_owned(),
|
||||
request.expected_content_digest.clone(),
|
||||
),
|
||||
]),
|
||||
},
|
||||
@@ -3449,7 +3501,7 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
let previous = self.load_ticket(conn, &ticket_id)?;
|
||||
let request = TicketStateUpdate {
|
||||
expected_item_revision: ticket_item_revision(&previous),
|
||||
expected_content_digest: ticket_content_digest(&previous),
|
||||
expected_state: from,
|
||||
state: to,
|
||||
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 previous = self.load_ticket(conn, &ticket_id)?;
|
||||
let request = TicketStateUpdate {
|
||||
expected_item_revision: ticket_item_revision(&previous),
|
||||
expected_content_digest: ticket_content_digest(&previous),
|
||||
expected_state: previous.meta.workflow_state,
|
||||
state: TicketWorkflowState::Closed,
|
||||
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?,
|
||||
@@ -5626,13 +5678,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 backend = backend(&tmp);
|
||||
let (reference, ticket) = backend
|
||||
.create_with_snapshot(NewTicket::new("Revision format"))
|
||||
.create_with_snapshot(NewTicket::new("Content digest"))
|
||||
.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
|
||||
.add_event(
|
||||
reference.id.clone().into(),
|
||||
@@ -5648,7 +5709,10 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_item_revision(&first), format!("{}:2", reference.id));
|
||||
assert_ne!(
|
||||
ticket_content_digest(&first),
|
||||
ticket_content_digest(&ticket)
|
||||
);
|
||||
backend
|
||||
.add_event(
|
||||
reference.id.clone().into(),
|
||||
@@ -5664,13 +5728,23 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.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;
|
||||
reordered.events.reverse();
|
||||
assert_eq!(
|
||||
ticket_item_revision(&reordered),
|
||||
format!("{}:4", reference.id)
|
||||
);
|
||||
assert_eq!(ticket_content_digest(&reordered), digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5983,18 +6057,18 @@ mod tests {
|
||||
assert_eq!(event.from.as_deref(), Some("planning"));
|
||||
assert_eq!(event.reason.as_deref(), Some("finished"));
|
||||
assert_eq!(
|
||||
event.attributes.get("expected_item_revision"),
|
||||
Some(&ticket_item_revision(&ticket))
|
||||
event.attributes.get("expected_content_digest"),
|
||||
Some(&ticket_content_digest(&ticket))
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.attributes
|
||||
.get("fingerprint_version")
|
||||
.map(String::as_str),
|
||||
Some("state-update-v1")
|
||||
Some("state-update-v2")
|
||||
);
|
||||
let request = TicketStateUpdate {
|
||||
expected_item_revision: ticket_item_revision(&ticket),
|
||||
expected_content_digest: ticket_content_digest(&ticket),
|
||||
expected_state: TicketWorkflowState::Planning,
|
||||
state: TicketWorkflowState::Done,
|
||||
operation_key: event.attributes["operation_key"].clone(),
|
||||
@@ -6023,8 +6097,8 @@ mod tests {
|
||||
assert_eq!(close.body, resolution);
|
||||
assert_eq!(closed.resolution, Some(resolution));
|
||||
assert_eq!(
|
||||
close.attributes.get("expected_item_revision"),
|
||||
Some(&ticket_item_revision(&done))
|
||||
close.attributes.get("expected_content_digest"),
|
||||
Some(&ticket_content_digest(&done))
|
||||
);
|
||||
assert_ne!(
|
||||
close.attributes["operation_key"],
|
||||
@@ -6175,7 +6249,7 @@ mod tests {
|
||||
|
||||
fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion {
|
||||
TicketCompletion {
|
||||
expected_item_revision: ticket_item_revision(ticket),
|
||||
expected_content_digest: ticket_content_digest(ticket),
|
||||
expected_state: ticket.meta.workflow_state,
|
||||
operation_key: key.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]
|
||||
fn completion_from_all_states_needs_no_git_targets_or_merge_request() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -6316,7 +6436,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
backend.complete(&reference.id, request),
|
||||
Err(TicketError::StaleItemRevision { .. })
|
||||
Err(TicketError::StaleContent { .. })
|
||||
));
|
||||
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited);
|
||||
let mut request = completion_request(&edited, "complete");
|
||||
@@ -6440,7 +6560,7 @@ mod tests {
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
expected_item_revision: "other-revision".to_owned(),
|
||||
expected_content_digest: "other-content".to_owned(),
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
@@ -6562,13 +6682,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 backend = backend(&tmp);
|
||||
let (reference, ticket) = backend
|
||||
.create_with_snapshot(NewTicket::new("Revision"))
|
||||
.create_with_snapshot(NewTicket::new("Content"))
|
||||
.unwrap();
|
||||
let revision = ticket_item_revision(&ticket);
|
||||
let digest = ticket_content_digest(&ticket);
|
||||
backend
|
||||
.add_event(
|
||||
reference.id.clone().into(),
|
||||
@@ -6576,11 +6696,11 @@ mod tests {
|
||||
)
|
||||
.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
|
||||
.complete(&reference.id, completion_request(&ticket, "complete"))
|
||||
.unwrap();
|
||||
assert_eq!(ticket_item_revision(&completed), revision);
|
||||
assert_eq!(ticket_content_digest(&completed), digest);
|
||||
backend
|
||||
.edit_item(
|
||||
reference.id.clone().into(),
|
||||
@@ -6601,7 +6721,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(ticket_item_revision(&restored), revision);
|
||||
assert_eq!(ticket_content_digest(&restored), digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6618,7 +6738,7 @@ mod tests {
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
expected_item_revision: "".to_owned(),
|
||||
expected_content_digest: "".to_owned(),
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
@@ -7081,8 +7201,8 @@ mod tests {
|
||||
assert_eq!(close.from.as_deref(), Some("planning"));
|
||||
assert_eq!(close.to.as_deref(), Some("closed"));
|
||||
assert_eq!(
|
||||
close.attributes.get("expected_item_revision"),
|
||||
Some(&format!("{}:0", record.meta.id))
|
||||
close.attributes.get("expected_content_digest"),
|
||||
Some(&ticket_content_digest(&record))
|
||||
);
|
||||
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};
|
||||
let cases = [
|
||||
(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()),
|
||||
"checkout_denied",
|
||||
|
||||
+87
-10
@@ -38,17 +38,24 @@ pub enum ToolsError {
|
||||
impl From<ToolsError> for ToolError {
|
||||
fn from(err: ToolsError) -> Self {
|
||||
match &err {
|
||||
ToolsError::WorkdirSession(
|
||||
workdir::WorkdirError::NotFound(_)
|
||||
| workdir::WorkdirError::Io { .. }
|
||||
| workdir::WorkdirError::Unavailable(_)
|
||||
| workdir::WorkdirError::OperationFailed
|
||||
| workdir::WorkdirError::Transport(_)
|
||||
| workdir::WorkdirError::Conflict(_)
|
||||
| workdir::WorkdirError::OutcomeUnknown(_),
|
||||
) => ToolError::ExecutionFailed(err.to_string()),
|
||||
ToolsError::WorkdirSession(error) => {
|
||||
// Diagnostic context is transparent to the established normal-tool
|
||||
// classification. Preserve the original display, including unknown
|
||||
// outcome guidance, while classifying through nested wrappers.
|
||||
match error.classification_source() {
|
||||
workdir::WorkdirError::NotFound(_)
|
||||
| workdir::WorkdirError::Io { .. }
|
||||
| workdir::WorkdirError::Unavailable(_)
|
||||
| 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::WorkdirSession(_)
|
||||
| ToolsError::NotRead(_)
|
||||
| ToolsError::ExternallyModified(_)
|
||||
| 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]
|
||||
fn remote_workdir_content_conflict_is_retryable_without_host_path() {
|
||||
let transport = WorkdirTransportError::from_workdir_error(
|
||||
|
||||
@@ -139,7 +139,8 @@ impl FileTarget {
|
||||
|
||||
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
|
||||
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::Denied { .. }
|
||||
| WorkdirError::OutOfScope { .. }
|
||||
|
||||
+158
-1
@@ -94,7 +94,9 @@ pub(crate) async fn execute_write(
|
||||
.await
|
||||
{
|
||||
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()),
|
||||
}
|
||||
};
|
||||
@@ -190,6 +192,161 @@ mod tests {
|
||||
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]
|
||||
async fn write_existing_requires_prior_read() {
|
||||
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.
|
||||
|
||||
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
|
||||
revision, authority snapshot generation, and Worker target. Identical-prefix ABA
|
||||
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches
|
||||
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even
|
||||
for File queries where the wire field remains optional for legacy callers.
|
||||
must echo that ID and match the active query context and Worker target. The first
|
||||
input/cursor edit cancels the pending input watch; returning to identical text
|
||||
cannot revive it. Authority snapshots and Worker/view switches discard pending
|
||||
queries and visible candidates, rather than advancing another counter.
|
||||
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
|
||||
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
|
||||
/// is invalidated (cursor moved out, whitespace landed inside the
|
||||
/// token, the sigil was deleted, or the candidate was confirmed).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct CompletionSnapshot {
|
||||
input_revision: u64,
|
||||
generation: u64,
|
||||
input: crate::input::CompletionInputWatch,
|
||||
target: 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 {
|
||||
invocation: protocol::FeatureInvocation,
|
||||
request_id: String,
|
||||
@@ -229,7 +237,6 @@ impl ActionbarNotice {
|
||||
|
||||
pub struct InternalWorkerView {
|
||||
pub worker: InternalWorkerRef,
|
||||
pub revision: u64,
|
||||
pub app: Box<App>,
|
||||
}
|
||||
|
||||
@@ -242,7 +249,7 @@ pub struct WorkerViewTab {
|
||||
pub struct App {
|
||||
pub worker_name: String,
|
||||
pub connected: bool,
|
||||
/// Latest authoritative revisioned live execution state.
|
||||
/// Latest authoritative live execution state.
|
||||
pub worker_state: WorkerStateSnapshot,
|
||||
next_command_id: u64,
|
||||
/// 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
|
||||
/// replayable conversation rows during segment rotation.
|
||||
run_error_messages: Vec<String>,
|
||||
/// Current compaction identity/revision used to fence snapshot/live updates.
|
||||
active_compaction: Option<(String, u64)>,
|
||||
/// Current compaction operation; terminal IDs cannot reopen on delayed events.
|
||||
active_compaction: Option<String>,
|
||||
terminal_compactions: std::collections::HashSet<String>,
|
||||
pub compaction_progress: Option<protocol::InFlightCompaction>,
|
||||
/// Presentation-only Internal Worker projections keyed by session identity.
|
||||
/// 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.
|
||||
selected_internal_worker_session_id: Option<String>,
|
||||
/// 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 mode: Mode,
|
||||
pub cache: FileCache,
|
||||
@@ -323,7 +331,6 @@ pub struct App {
|
||||
/// typed FeatureInvoke chip.
|
||||
selected_feature_invocations: HashMap<String, protocol::FeatureInvocationDescriptor>,
|
||||
pending_feature_edit: Option<PendingFeatureEdit>,
|
||||
completion_generation: u64,
|
||||
completion_target: String,
|
||||
/// Dedicated main-view rewind picker state.
|
||||
pub rewind_picker: Option<RewindPickerState>,
|
||||
@@ -397,11 +404,12 @@ impl App {
|
||||
shutdown_confirm: None,
|
||||
blocks: Vec::new(),
|
||||
active_compaction: None,
|
||||
terminal_compactions: std::collections::HashSet::new(),
|
||||
compaction_progress: None,
|
||||
run_error_messages: Vec::new(),
|
||||
internal_workers: Vec::new(),
|
||||
selected_internal_worker_session_id: None,
|
||||
removed_internal_workers: HashMap::new(),
|
||||
removed_internal_workers: std::collections::HashSet::new(),
|
||||
scroll: Scroll::default(),
|
||||
mode: Mode::Normal,
|
||||
cache: FileCache::new(),
|
||||
@@ -409,7 +417,6 @@ impl App {
|
||||
completion: None,
|
||||
selected_feature_invocations: HashMap::new(),
|
||||
pending_feature_edit: None,
|
||||
completion_generation: 0,
|
||||
completion_target: protocol::new_submission_request_id(),
|
||||
rewind_picker: None,
|
||||
rewind_request_pending: false,
|
||||
@@ -542,7 +549,7 @@ impl App {
|
||||
/// Cycle the presentation-only transcript/task view. Input and control
|
||||
/// methods continue to target the parent Worker regardless of selection.
|
||||
pub fn cycle_worker_view(&mut self) -> bool {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
if self.internal_workers.is_empty() {
|
||||
self.selected_internal_worker_session_id = None;
|
||||
return false;
|
||||
@@ -604,8 +611,7 @@ impl App {
|
||||
|
||||
fn completion_snapshot(&self) -> CompletionSnapshot {
|
||||
CompletionSnapshot {
|
||||
input_revision: self.input.revision(),
|
||||
generation: self.completion_generation,
|
||||
input: self.input.watch_completion_input(),
|
||||
target: self.completion_target.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) {
|
||||
if self.completion_target != target {
|
||||
self.completion_target = target;
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.selected_feature_invocations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Authority/snapshot/target changes revoke in-flight queries and visible candidates.
|
||||
fn invalidate_completion_generation(&mut self) {
|
||||
self.completion_generation = self.completion_generation.wrapping_add(1);
|
||||
fn cancel_completion_requests(&mut self) {
|
||||
self.completion = None;
|
||||
self.pending_feature_edit = None;
|
||||
}
|
||||
@@ -672,7 +677,7 @@ impl App {
|
||||
&& state.prefix_start == start
|
||||
&& state.prefix == prefix
|
||||
&& state.context == context
|
||||
&& state.snapshot == snapshot
|
||||
&& state.snapshot.same_context(&snapshot)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
@@ -722,7 +727,7 @@ impl App {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -736,7 +741,7 @@ impl App {
|
||||
if self
|
||||
.completion
|
||||
.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();
|
||||
return None;
|
||||
@@ -885,7 +890,7 @@ impl App {
|
||||
if self
|
||||
.completion
|
||||
.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();
|
||||
return false;
|
||||
@@ -942,7 +947,7 @@ impl App {
|
||||
if self
|
||||
.completion
|
||||
.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();
|
||||
return false;
|
||||
@@ -1248,22 +1253,23 @@ impl App {
|
||||
|
||||
pub fn continue_pending_method(&self) -> Option<Method> {
|
||||
Some(Method::ContinuePending {
|
||||
expected_revision: self.pending_submissions.revision,
|
||||
expected_head_id: self.pending_submissions.head_id.clone()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_pending_method(&self) -> Method {
|
||||
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 {
|
||||
Method::CancelPendingSubmission {
|
||||
submission_id,
|
||||
expected_revision: self.pending_submissions.revision,
|
||||
}
|
||||
Method::CancelPendingSubmission { submission_id }
|
||||
}
|
||||
|
||||
pub fn next_queued_input_preview(&self) -> Option<&str> {
|
||||
@@ -1802,14 +1808,13 @@ impl App {
|
||||
});
|
||||
}
|
||||
Event::CompactStart { lifecycle } => {
|
||||
let should_apply = match &self.active_compaction {
|
||||
None => true,
|
||||
Some((id, revision)) => {
|
||||
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||
}
|
||||
};
|
||||
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
|
||||
&& self
|
||||
.active_compaction
|
||||
.as_ref()
|
||||
.is_none_or(|id| id == &lifecycle.compaction_id);
|
||||
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() {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
@@ -1818,15 +1823,16 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::CompactDone { lifecycle } => {
|
||||
let should_apply = match &self.active_compaction {
|
||||
None => true,
|
||||
Some((id, revision)) => {
|
||||
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||
}
|
||||
};
|
||||
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
|
||||
&& self
|
||||
.active_compaction
|
||||
.as_ref()
|
||||
.is_none_or(|id| id == &lifecycle.compaction_id);
|
||||
if !should_apply {
|
||||
return None;
|
||||
}
|
||||
self.terminal_compactions
|
||||
.insert(lifecycle.compaction_id.clone());
|
||||
self.active_compaction = None;
|
||||
let new_segment_id = lifecycle
|
||||
.new_segment_id
|
||||
@@ -1852,15 +1858,16 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::CompactFailed { lifecycle } => {
|
||||
let should_apply = match &self.active_compaction {
|
||||
None => true,
|
||||
Some((id, revision)) => {
|
||||
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||
}
|
||||
};
|
||||
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
|
||||
&& self
|
||||
.active_compaction
|
||||
.as_ref()
|
||||
.is_none_or(|id| id == &lifecycle.compaction_id);
|
||||
if !should_apply {
|
||||
return None;
|
||||
}
|
||||
self.terminal_compactions
|
||||
.insert(lifecycle.compaction_id.clone());
|
||||
self.active_compaction = None;
|
||||
let error = lifecycle
|
||||
.error
|
||||
@@ -1915,14 +1922,10 @@ impl App {
|
||||
self.replace_internal_worker_snapshots(internal_workers);
|
||||
return self.refresh_completion();
|
||||
}
|
||||
Event::InternalWorker {
|
||||
worker,
|
||||
revision,
|
||||
event,
|
||||
} => self.apply_internal_worker_event(worker, revision, *event),
|
||||
Event::InternalWorkerRemoved { worker, revision } => {
|
||||
self.remove_internal_worker(worker, revision)
|
||||
Event::InternalWorker { worker, event } => {
|
||||
self.apply_internal_worker_event(worker, *event)
|
||||
}
|
||||
Event::InternalWorkerRemoved { worker } => self.remove_internal_worker(worker),
|
||||
Event::WorkerState { snapshot } => {
|
||||
self.rewind_refresh_fence = false;
|
||||
self.apply_worker_state_snapshot(&snapshot);
|
||||
@@ -1956,7 +1959,7 @@ impl App {
|
||||
&& self.pending_feature_edit.as_ref().is_some_and(|edit| {
|
||||
edit.invocation.name == prefix
|
||||
&& 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;
|
||||
@@ -1981,7 +1984,7 @@ impl App {
|
||||
// stale argument/provider replies cannot cross invocation scope.
|
||||
if let Some(state) = self.completion.as_mut()
|
||||
&& request_id.as_ref() == Some(&state.request_id)
|
||||
&& state.snapshot == snapshot
|
||||
&& state.snapshot.same_context(&snapshot)
|
||||
&& state.kind == kind
|
||||
&& state.prefix == prefix
|
||||
&& state.context == context
|
||||
@@ -2100,7 +2103,7 @@ impl App {
|
||||
);
|
||||
}
|
||||
Event::Shutdown => {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.mark_orphan_compacts_incomplete();
|
||||
self.quit = true;
|
||||
}
|
||||
@@ -2309,7 +2312,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn enter_command_mode(&mut self) {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.input_mode = CommandInputMode::Command;
|
||||
self.completion = None;
|
||||
self.command_completion_selected = None;
|
||||
@@ -2317,7 +2320,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn exit_command_mode(&mut self) {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.input_mode = CommandInputMode::Composer;
|
||||
self.command_input.clear();
|
||||
self.command_completion_selected = None;
|
||||
@@ -2445,7 +2448,7 @@ impl App {
|
||||
}
|
||||
|
||||
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
|
||||
// back into view before presenting diagnostics or the picker.
|
||||
self.selected_internal_worker_session_id = None;
|
||||
@@ -2791,21 +2794,12 @@ impl App {
|
||||
app.replace_internal_worker_snapshots(snapshot.internal_workers);
|
||||
InternalWorkerView {
|
||||
worker: snapshot.worker,
|
||||
revision: snapshot.revision,
|
||||
app: Box::new(app),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_internal_worker_event(
|
||||
&mut self,
|
||||
worker: InternalWorkerRef,
|
||||
revision: u64,
|
||||
event: Event,
|
||||
) {
|
||||
if self
|
||||
.removed_internal_workers
|
||||
.contains_key(&worker.session_id)
|
||||
{
|
||||
fn apply_internal_worker_event(&mut self, worker: InternalWorkerRef, event: Event) {
|
||||
if self.removed_internal_workers.contains(&worker.session_id) {
|
||||
return;
|
||||
}
|
||||
let index = self
|
||||
@@ -2819,21 +2813,16 @@ impl App {
|
||||
app.mode = self.mode;
|
||||
self.internal_workers.push(InternalWorkerView {
|
||||
worker: worker.clone(),
|
||||
revision: 0,
|
||||
app: Box::new(app),
|
||||
});
|
||||
self.internal_workers.last_mut().unwrap()
|
||||
};
|
||||
if revision <= target.revision {
|
||||
return;
|
||||
}
|
||||
target.worker = worker;
|
||||
target.revision = revision;
|
||||
let _ = target.app.handle_worker_event(event);
|
||||
}
|
||||
|
||||
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) {
|
||||
self.invalidate_completion_generation();
|
||||
fn remove_internal_worker(&mut self, worker: InternalWorkerRef) {
|
||||
self.cancel_completion_requests();
|
||||
let session_id = worker.session_id;
|
||||
let Some(index) = self
|
||||
.internal_workers
|
||||
@@ -2843,20 +2832,14 @@ impl App {
|
||||
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
|
||||
self.selected_internal_worker_session_id = None;
|
||||
}
|
||||
self.removed_internal_workers
|
||||
.entry(session_id)
|
||||
.and_modify(|current| *current = (*current).max(revision))
|
||||
.or_insert(revision);
|
||||
self.removed_internal_workers.insert(session_id);
|
||||
return;
|
||||
};
|
||||
if revision <= self.internal_workers[index].revision {
|
||||
return;
|
||||
}
|
||||
self.internal_workers.remove(index);
|
||||
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
|
||||
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(
|
||||
@@ -2918,7 +2901,7 @@ impl App {
|
||||
session: &protocol::SessionSnapshot,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
) {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.run_error_messages.clear();
|
||||
self.turn_index = 0;
|
||||
self.blocks.clear();
|
||||
@@ -4102,7 +4085,6 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 3,
|
||||
notification_count: 0,
|
||||
notification_previews: vec![],
|
||||
head_id: Some("submission-1".into()),
|
||||
@@ -4497,34 +4479,25 @@ mod completion_flow_tests {
|
||||
};
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 2,
|
||||
event: Box::new(Event::TextDelta {
|
||||
text: "child output".into(),
|
||||
}),
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker,
|
||||
revision: 1,
|
||||
event: Box::new(Event::TextDelta {
|
||||
text: "stale".into(),
|
||||
text: " next".into(),
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(app.blocks.is_empty());
|
||||
assert_eq!(app.internal_workers.len(), 1);
|
||||
assert_eq!(app.internal_workers[0].revision, 2);
|
||||
assert!(
|
||||
app.internal_workers[0].app.blocks.iter().any(
|
||||
|block| matches!(block, Block::AssistantText { text } if text == "child output")
|
||||
)
|
||||
);
|
||||
assert!(app.internal_workers[0].app.blocks.iter().any(
|
||||
|block| matches!(block, Block::AssistantText { text } if text == "child output next")
|
||||
));
|
||||
}
|
||||
|
||||
fn test_internal_worker_snapshot(
|
||||
session_id: &str,
|
||||
name: &str,
|
||||
revision: u64,
|
||||
) -> InternalWorkerSnapshot {
|
||||
fn test_internal_worker_snapshot(session_id: &str, name: &str) -> InternalWorkerSnapshot {
|
||||
InternalWorkerSnapshot {
|
||||
worker: InternalWorkerRef {
|
||||
session_id: session_id.into(),
|
||||
@@ -4532,7 +4505,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: Some("parent".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision,
|
||||
status: WorkerStatus::Idle,
|
||||
greeting: None,
|
||||
session: protocol::SessionSnapshot {
|
||||
@@ -4548,7 +4520,7 @@ mod completion_flow_tests {
|
||||
#[test]
|
||||
fn internal_worker_snapshot_uses_child_greeting_metadata() {
|
||||
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();
|
||||
greeting.worker_name = "research".into();
|
||||
greeting.model = "child-model".into();
|
||||
@@ -4570,7 +4542,7 @@ mod completion_flow_tests {
|
||||
#[test]
|
||||
fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() {
|
||||
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();
|
||||
greeting.worker_name = "research".into();
|
||||
greeting.model = "child-model".into();
|
||||
@@ -4582,7 +4554,6 @@ mod completion_flow_tests {
|
||||
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 1,
|
||||
event: Box::new(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
@@ -4596,7 +4567,6 @@ mod completion_flow_tests {
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker,
|
||||
revision: 2,
|
||||
event: Box::new(Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
tokens: 14_000,
|
||||
@@ -4626,7 +4596,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: Some("parent".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
app: Box::new(App::new(name.into())),
|
||||
});
|
||||
}
|
||||
@@ -4664,7 +4633,7 @@ mod completion_flow_tests {
|
||||
|
||||
let mut app = App::new("parent".into());
|
||||
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(app.internal_workers[0].app.as_mut(), "child selection");
|
||||
@@ -4687,7 +4656,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: Some("parent".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
app: Box::new(App::new("old".into())),
|
||||
});
|
||||
app.cycle_worker_view();
|
||||
@@ -4711,7 +4679,7 @@ mod completion_flow_tests {
|
||||
|
||||
let mut app = App::new("parent".into());
|
||||
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
|
||||
"child", "child", 1,
|
||||
"child", "child",
|
||||
)]);
|
||||
let child = app.internal_workers[0].app.as_mut();
|
||||
child.scroll.follow_tail = false;
|
||||
@@ -4733,11 +4701,9 @@ mod completion_flow_tests {
|
||||
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
|
||||
"child",
|
||||
"renamed-child",
|
||||
2,
|
||||
)]);
|
||||
|
||||
let view = &app.internal_workers[0];
|
||||
assert_eq!(view.revision, 2);
|
||||
assert_eq!(view.app.worker_name, "renamed-child");
|
||||
assert!(!view.app.scroll.follow_tail);
|
||||
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() {
|
||||
let mut app = App::new("parent".into());
|
||||
app.replace_internal_worker_snapshots(vec![
|
||||
test_internal_worker_snapshot("child-a", "alpha", 1),
|
||||
test_internal_worker_snapshot("child-b", "beta", 1),
|
||||
test_internal_worker_snapshot("child-a", "alpha"),
|
||||
test_internal_worker_snapshot("child-b", "beta"),
|
||||
]);
|
||||
app.task_pane_scroll = 3;
|
||||
|
||||
@@ -4793,10 +4759,8 @@ mod completion_flow_tests {
|
||||
};
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 2,
|
||||
event: Box::new(Event::InternalWorker {
|
||||
worker: nested,
|
||||
revision: 1,
|
||||
event: Box::new(Event::TextDone {
|
||||
text: "nested".into(),
|
||||
}),
|
||||
@@ -4809,11 +4773,9 @@ mod completion_flow_tests {
|
||||
|
||||
app.handle_worker_event(Event::InternalWorkerRemoved {
|
||||
worker: worker.clone(),
|
||||
revision: 3,
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker,
|
||||
revision: 4,
|
||||
event: Box::new(Event::TextDone {
|
||||
text: "late".into(),
|
||||
}),
|
||||
@@ -4836,7 +4798,7 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[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 worker = InternalWorkerRef {
|
||||
session_id: "child-session".into(),
|
||||
@@ -4846,18 +4808,13 @@ mod completion_flow_tests {
|
||||
};
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 4,
|
||||
event: Box::new(Event::TextDone {
|
||||
text: "current".into(),
|
||||
}),
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorkerRemoved {
|
||||
worker,
|
||||
revision: 3,
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorkerRemoved { worker });
|
||||
|
||||
assert_eq!(app.internal_workers.len(), 1);
|
||||
assert_eq!(app.internal_workers[0].revision, 4);
|
||||
assert!(app.internal_workers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4870,7 +4827,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: None,
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
app: Box::new(App::new("old".into())),
|
||||
});
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
@@ -4888,7 +4844,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 4,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
@@ -4903,7 +4858,6 @@ mod completion_flow_tests {
|
||||
|
||||
assert_eq!(app.internal_workers.len(), 1);
|
||||
assert_eq!(app.internal_workers[0].worker.session_id, "replacement");
|
||||
assert_eq!(app.internal_workers[0].revision, 4);
|
||||
assert_eq!(
|
||||
app.internal_workers[0].app.worker_status,
|
||||
WorkerStatus::Running
|
||||
@@ -4955,7 +4909,6 @@ mod completion_flow_tests {
|
||||
protocol::CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-test".into(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state,
|
||||
started_at_ms: 1,
|
||||
@@ -4975,7 +4928,6 @@ mod completion_flow_tests {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||
lifecycle.revision = 2;
|
||||
lifecycle.new_segment_id = Some(id.to_string());
|
||||
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]
|
||||
fn compact_failed_replaces_live_block() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -4997,7 +4968,6 @@ mod completion_flow_tests {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
|
||||
lifecycle.revision = 2;
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
app.handle_worker_event(Event::CompactFailed { lifecycle });
|
||||
|
||||
@@ -5662,7 +5632,7 @@ mod completion_correlation_tests {
|
||||
let first = app.refresh_completion().unwrap();
|
||||
app.input.insert_char('x');
|
||||
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"));
|
||||
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
||||
let second = app.refresh_completion().unwrap();
|
||||
@@ -5734,7 +5704,7 @@ mod completion_correlation_tests {
|
||||
}
|
||||
|
||||
#[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());
|
||||
app.handle_worker_event(authority_snapshot("Writable: /tmp"));
|
||||
crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run");
|
||||
@@ -5748,8 +5718,8 @@ mod completion_correlation_tests {
|
||||
.unwrap();
|
||||
assert_ne!(request_id(&first), request_id(&denied));
|
||||
assert_ne!(request_id(&first), request_id(¤t));
|
||||
app.handle_worker_event(reply(&first, "old permission generation"));
|
||||
app.handle_worker_event(reply(&denied, "intermediate generation"));
|
||||
app.handle_worker_event(reply(&first, "old permission context"));
|
||||
app.handle_worker_event(reply(&denied, "intermediate context"));
|
||||
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
||||
app.handle_worker_event(reply(¤t, "資料/current"));
|
||||
assert_eq!(
|
||||
@@ -5789,7 +5759,6 @@ mod completion_correlation_tests {
|
||||
parent_session_id: Some("parent".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle),
|
||||
}),
|
||||
|
||||
@@ -674,7 +674,7 @@ mod tests {
|
||||
|
||||
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
|
||||
BackendWorkerSummary {
|
||||
restore_observation_token: Some("observed-worker-generation".into()),
|
||||
restore_observation_token: Some("observed-worker-state".into()),
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
resource_key: "W-1".to_string(),
|
||||
@@ -953,7 +953,6 @@ mod tests {
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".into(),
|
||||
settings_revision: 1,
|
||||
language: "English".into(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
@@ -1107,7 +1106,7 @@ mod tests {
|
||||
target,
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(client::BackendWorkerRestoreRequest {
|
||||
expected_observation_token: "observed-worker-generation".into(),
|
||||
expected_observation_token: "observed-worker-state".into(),
|
||||
request_id: "resume-operation".into(),
|
||||
}),
|
||||
|_| Ok(false),
|
||||
@@ -1142,7 +1141,7 @@ mod tests {
|
||||
target,
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(client::BackendWorkerRestoreRequest {
|
||||
expected_observation_token: "observed-worker-generation".into(),
|
||||
expected_observation_token: "observed-worker-state".into(),
|
||||
request_id: "resume-operation".into(),
|
||||
}),
|
||||
|_| Ok(false),
|
||||
|
||||
@@ -2625,7 +2625,6 @@ mod tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 2,
|
||||
notification_count: 0,
|
||||
notification_previews: vec![],
|
||||
head_id: Some("submission-1".into()),
|
||||
@@ -2646,7 +2645,6 @@ mod tests {
|
||||
assert!(matches!(
|
||||
continue_next,
|
||||
Some(Method::ContinuePending {
|
||||
expected_revision: 2,
|
||||
ref expected_head_id,
|
||||
}) if expected_head_id == "submission-1"
|
||||
));
|
||||
@@ -2659,7 +2657,6 @@ mod tests {
|
||||
assert!(matches!(
|
||||
cancel,
|
||||
Some(Method::CancelPendingSubmission {
|
||||
expected_revision: 2,
|
||||
ref submission_id,
|
||||
}) if submission_id == "submission-1"
|
||||
));
|
||||
@@ -2671,8 +2668,8 @@ mod tests {
|
||||
assert!(matches!(
|
||||
clear,
|
||||
Some(Method::ClearPendingSubmissions {
|
||||
expected_revision: 2
|
||||
})
|
||||
ref expected_submission_ids
|
||||
}) if expected_submission_ids == &["submission-1"]
|
||||
));
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
}
|
||||
@@ -2682,7 +2679,6 @@ mod tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 2,
|
||||
notification_count: 0,
|
||||
notification_previews: vec![],
|
||||
head_id: Some("submission-1".into()),
|
||||
@@ -3355,7 +3351,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
@@ -3471,7 +3466,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
@@ -3497,7 +3491,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
@@ -3537,7 +3530,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
|
||||
+55
-25
@@ -11,6 +11,14 @@
|
||||
//! back to their original captured content so the Worker sees the full
|
||||
//! pasted text (without the placeholder label).
|
||||
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
@@ -281,9 +289,19 @@ struct SelectedInvocation {
|
||||
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 {
|
||||
/// Monotonic semantic edit/cursor revision, including ABA changes.
|
||||
revision: u64,
|
||||
pending_completion: RefCell<Option<Arc<AtomicBool>>>,
|
||||
atoms: Vec<Atom>,
|
||||
selected_invocations: Vec<SelectedInvocation>,
|
||||
removed_attachment_stages: Vec<String>,
|
||||
@@ -300,7 +318,7 @@ pub struct InputBuffer {
|
||||
impl Default for InputBuffer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
revision: 0,
|
||||
pending_completion: RefCell::new(None),
|
||||
atoms: Vec::new(),
|
||||
selected_invocations: 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 {
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
pub(crate) fn watch_completion_input(&self) -> CompletionInputWatch {
|
||||
CompletionInputWatch(Arc::clone(
|
||||
self.pending_completion
|
||||
.borrow_mut()
|
||||
.get_or_insert_with(|| Arc::new(AtomicBool::new(false))),
|
||||
))
|
||||
}
|
||||
|
||||
fn bump_revision(&mut self) {
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
fn cancel_completion_on_edit(&mut self) {
|
||||
if let Some(pending) = self.pending_completion.get_mut().take() {
|
||||
pending.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
@@ -326,7 +356,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.selected_invocations.clear();
|
||||
self.atoms.clear();
|
||||
self.cursor = 0;
|
||||
@@ -349,7 +379,7 @@ impl InputBuffer {
|
||||
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
|
||||
/// and placing the cursor at the end of the restored input.
|
||||
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.selected_invocations.clear();
|
||||
self.atoms.clear();
|
||||
for segment in segments {
|
||||
@@ -400,7 +430,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
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.atoms.insert(self.cursor, Atom::Char(c));
|
||||
self.cursor += 1;
|
||||
@@ -445,7 +475,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn insert_paste(&mut self, content: String) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let measurement = measure_paste(&content);
|
||||
if measurement.presentation() == PastePresentation::Text {
|
||||
self.insert_str(&content);
|
||||
@@ -469,7 +499,7 @@ impl InputBuffer {
|
||||
|
||||
#[cfg(test)]
|
||||
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.atoms.insert(self.cursor, Atom::UploadedFile(file));
|
||||
self.cursor += 1;
|
||||
@@ -493,7 +523,7 @@ impl InputBuffer {
|
||||
start: usize,
|
||||
descriptor: protocol::FeatureInvocationDescriptor,
|
||||
) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.selected_invocations
|
||||
.retain(|selection| selection.start != start);
|
||||
self.selected_invocations.push(SelectedInvocation {
|
||||
@@ -508,7 +538,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn retry_attachment_stage(&mut self, id: &str) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
for atom in &mut self.atoms {
|
||||
if let Atom::AttachmentStage {
|
||||
id: stage_id,
|
||||
@@ -563,7 +593,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let inserted = atoms.len();
|
||||
self.adjust_selection(start, end, inserted);
|
||||
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
|
||||
@@ -669,7 +699,7 @@ impl InputBuffer {
|
||||
id: &str,
|
||||
file: Option<protocol::UploadedFileRef>,
|
||||
) -> bool {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let Some(index) = self.atoms.iter().position(
|
||||
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
|
||||
) else {
|
||||
@@ -965,12 +995,12 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
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
|
||||
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
|
||||
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 {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
@@ -993,7 +1023,7 @@ impl InputBuffer {
|
||||
|
||||
/// Move forward by one word. Mirror of [`move_word_left`].
|
||||
pub fn move_word_right(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
while self.cursor < self.atoms.len()
|
||||
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
|
||||
{
|
||||
@@ -1009,7 +1039,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn move_start(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
@@ -1058,13 +1088,13 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let (ranges, line, _) = self.logical_line_and_col();
|
||||
self.cursor = ranges[line].0;
|
||||
}
|
||||
|
||||
pub fn move_end(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let (ranges, line, _) = self.logical_line_and_col();
|
||||
self.cursor = ranges[line].1;
|
||||
}
|
||||
@@ -1072,7 +1102,7 @@ impl InputBuffer {
|
||||
/// Move one logical line up, preserving column (atom count from
|
||||
/// current line start). No-op if already on the first line.
|
||||
pub fn move_up(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let (ranges, line, col) = self.logical_line_and_col();
|
||||
if line == 0 {
|
||||
return;
|
||||
@@ -1083,7 +1113,7 @@ impl InputBuffer {
|
||||
|
||||
/// Move one logical line down, preserving column.
|
||||
pub fn move_down(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let (ranges, line, col) = self.logical_line_and_col();
|
||||
let Some(&(start, end)) = ranges.get(line + 1) else {
|
||||
return;
|
||||
|
||||
@@ -2187,7 +2187,6 @@ mod tests {
|
||||
fn set_pending_submission(app: &mut App, id: &str) {
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 1,
|
||||
notification_count: 0,
|
||||
notification_previews: vec![],
|
||||
head_id: Some(id.into()),
|
||||
@@ -2320,7 +2319,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(protocol::Event::TextDelta {
|
||||
text: "child transcript".into(),
|
||||
}),
|
||||
|
||||
@@ -148,8 +148,9 @@ impl CheckoutSearchRequest {
|
||||
if !std::path::Path::new(self.operation.path().as_str())
|
||||
.starts_with(self.output_root.as_str())
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
"checkout search must stay beneath its output root".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
|
||||
"checkout search must stay beneath its output root",
|
||||
));
|
||||
}
|
||||
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"));
|
||||
// A second actual scoped wrapper has its own cwd and nonrecursive layer.
|
||||
// Logical rules avoid requesting resolved authorization from a wrapper
|
||||
// that conservatively does not expose host-side resolution authority.
|
||||
// Default resolved authorization must survive every wrapper.
|
||||
std::fs::create_dir(root.path().join("child/keep/deep")).unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("child/keep/deep/secret"),
|
||||
"needle hidden nested",
|
||||
)
|
||||
.unwrap();
|
||||
let mut own_rule = rule("keep", false);
|
||||
own_rule.symlink_policy = manifest::SymlinkPolicy::Logical;
|
||||
let second = stacked.scope(scope(vec![own_rule], "keep")).await.unwrap();
|
||||
let second = stacked
|
||||
.scope(scope(vec![rule("keep", false)], "keep"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = second
|
||||
.tool_session()
|
||||
.grep(grep(GrepOutputMode::Content))
|
||||
@@ -751,7 +751,15 @@ async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_e
|
||||
)))
|
||||
.await
|
||||
.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 [
|
||||
(".ignore", "root-ignore-visible.txt"),
|
||||
|
||||
@@ -497,18 +497,24 @@ impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirOperationError {
|
||||
code: crate::http::WorkdirTransportErrorCode,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
denial_reason: Option<crate::WorkdirDenialReason>,
|
||||
}
|
||||
|
||||
impl ExternalWorkdirOperationError {
|
||||
/// Collapse provider failures to a closed error code. Provider-authored
|
||||
/// diagnostics never cross the External Workdir trust boundary.
|
||||
/// Keep only closed error codes and typed refusal reasons. Provider-authored
|
||||
/// text never crosses the External Workdir trust boundary.
|
||||
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 {
|
||||
WorkdirTransportError {
|
||||
code: self.code,
|
||||
denial_reason: self.denial_reason,
|
||||
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]
|
||||
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
|
||||
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
code: crate::http::WorkdirTransportErrorCode::Internal,
|
||||
denial_reason: None,
|
||||
message: "/home/operator/private provider-secret\ncontrol".to_string(),
|
||||
});
|
||||
let frame = ExternalWorkdirProviderFrame::current(
|
||||
|
||||
@@ -357,9 +357,17 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let classification = match &error {
|
||||
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||
error => error,
|
||||
};
|
||||
assert_ne!(
|
||||
error.denial_reason(),
|
||||
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
classification,
|
||||
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
|
||||
),
|
||||
"unexpected error for {path}: {error:?}"
|
||||
@@ -375,6 +383,10 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.denial_reason(),
|
||||
Some(crate::WorkdirDenialReason::ExternalRootSymlink)
|
||||
);
|
||||
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||
}
|
||||
|
||||
@@ -408,9 +420,17 @@ async fn external_read_write_provider_rejects_symlink_mutation_escape() {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let classification = match &error {
|
||||
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||
error => error,
|
||||
};
|
||||
assert_ne!(
|
||||
error.denial_reason(),
|
||||
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
classification,
|
||||
WorkdirError::SymlinkOutOfScope { .. }
|
||||
| WorkdirError::OutOfScope(_)
|
||||
| WorkdirError::Denied(_)
|
||||
@@ -559,7 +579,15 @@ async fn external_grep_honors_gitignore_above_a_nested_search_root() {
|
||||
)
|
||||
.await
|
||||
.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")]
|
||||
|
||||
@@ -269,6 +269,9 @@ impl WorkdirTransportErrorCode {
|
||||
pub struct WorkdirTransportError {
|
||||
pub code: WorkdirTransportErrorCode,
|
||||
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 {
|
||||
@@ -276,6 +279,11 @@ impl WorkdirTransportError {
|
||||
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
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(_) => (
|
||||
Code::OutcomeUnknown,
|
||||
"Workdir operation may have effects; inspect before retrying",
|
||||
@@ -289,6 +297,7 @@ impl WorkdirTransportError {
|
||||
return Self {
|
||||
code: Code::Unsupported,
|
||||
message: format!("Workdir capability {capability:?} is not available"),
|
||||
denial_reason: None,
|
||||
};
|
||||
}
|
||||
WorkdirError::UnsupportedOperation(_) => {
|
||||
@@ -346,12 +355,13 @@ impl WorkdirTransportError {
|
||||
Self {
|
||||
code,
|
||||
message: message.to_string(),
|
||||
denial_reason: error.denial_reason(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_workdir_error(self) -> WorkdirError {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
match self.code {
|
||||
let error = match self.code {
|
||||
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
|
||||
Code::Conflict => WorkdirError::Conflict(self.message),
|
||||
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
|
||||
@@ -359,7 +369,11 @@ impl WorkdirTransportError {
|
||||
),
|
||||
Code::Unsupported => WorkdirError::UnsupportedOperation(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::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
|
||||
path: "<remote>".into(),
|
||||
@@ -390,7 +404,18 @@ impl WorkdirTransportError {
|
||||
},
|
||||
Code::Transport => WorkdirError::Transport(self.message),
|
||||
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(),
|
||||
)),
|
||||
Ok(_) => Err(Self::mismatch("checkout_execute")),
|
||||
Err(WorkdirError::Unavailable(_) | WorkdirError::Transport(_)) if mutation => {
|
||||
Err(WorkdirError::OutcomeUnknown(
|
||||
Err(error)
|
||||
if mutation
|
||||
&& matches!(
|
||||
error.classification_source(),
|
||||
WorkdirError::Unavailable(_) | WorkdirError::Transport(_)
|
||||
) =>
|
||||
{
|
||||
let outcome = WorkdirError::OutcomeUnknown(
|
||||
"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),
|
||||
}
|
||||
@@ -949,6 +987,7 @@ mod tests {
|
||||
] {
|
||||
let transport = WorkdirTransportError {
|
||||
code,
|
||||
denial_reason: None,
|
||||
message: if code == WorkdirTransportErrorCode::Conflict {
|
||||
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
||||
.to_string()
|
||||
|
||||
+154
-3
@@ -211,7 +211,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
}) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkdirError::Denied(
|
||||
Err(WorkdirError::denied(
|
||||
WorkdirDenialReason::ScopeResolutionUnavailable,
|
||||
"Workdir provider cannot establish resolved scope authority".to_string(),
|
||||
))
|
||||
}
|
||||
@@ -221,7 +222,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
&self,
|
||||
_request: WorkdirScopeOverlapRequest,
|
||||
) -> Result<bool, WorkdirError> {
|
||||
Err(WorkdirError::Denied(
|
||||
Err(WorkdirError::denied(
|
||||
WorkdirDenialReason::ScopeComparisonUnavailable,
|
||||
"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>;
|
||||
|
||||
/// 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)]
|
||||
pub enum WorkdirError {
|
||||
#[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")]
|
||||
SessionClosed,
|
||||
@@ -398,6 +515,40 @@ pub enum 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 {
|
||||
Self::Io {
|
||||
path: path.to_path_buf(),
|
||||
|
||||
+312
-25
@@ -213,6 +213,51 @@ struct ScopeAccess {
|
||||
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 {
|
||||
fn new(
|
||||
scope: Arc<Scope>,
|
||||
@@ -238,8 +283,8 @@ impl ScopeAccess {
|
||||
)
|
||||
})?;
|
||||
let relative = path.strip_prefix(&self.root).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
policy_permission_denied(
|
||||
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||
"path is outside provider root",
|
||||
)
|
||||
})?;
|
||||
@@ -288,8 +333,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
if logical.starts_with(&self.root) {
|
||||
Ok(logical.to_path_buf())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
Err(policy_permission_denied(
|
||||
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||
"path is outside provider root",
|
||||
))
|
||||
}
|
||||
@@ -394,8 +439,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
)
|
||||
})?;
|
||||
let relative = resolved.strip_prefix(&self.root).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
policy_permission_denied(
|
||||
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||
"path is outside provider root",
|
||||
)
|
||||
})?;
|
||||
@@ -491,8 +536,8 @@ impl SearchAccess {
|
||||
if self.read_allowed(logical, resolved) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
Err(policy_permission_denied(
|
||||
crate::WorkdirDenialReason::CheckoutSearchPathDenied,
|
||||
"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> {
|
||||
if !self.enumeration_allowed(logical, resolved) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
return Err(policy_permission_denied(
|
||||
crate::WorkdirDenialReason::CheckoutEnumerationDenied,
|
||||
"checkout directory enumeration denied",
|
||||
));
|
||||
}
|
||||
@@ -648,7 +693,8 @@ impl ExternalWorkdirRoot {
|
||||
let selected_metadata = std::fs::symlink_metadata(directory)
|
||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||
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(),
|
||||
));
|
||||
}
|
||||
@@ -678,7 +724,8 @@ impl ExternalWorkdirRoot {
|
||||
if (selected_metadata.dev(), selected_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(),
|
||||
));
|
||||
}
|
||||
@@ -691,7 +738,8 @@ impl ExternalWorkdirRoot {
|
||||
let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
|
||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||
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(),
|
||||
));
|
||||
}
|
||||
@@ -1334,7 +1382,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let logical = self.inner.root.join(request.path.as_str());
|
||||
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(),
|
||||
));
|
||||
}
|
||||
@@ -1353,10 +1402,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
|
||||
};
|
||||
if !parent_allows {
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"Workdir path `{}` exceeds the provider attachment scope",
|
||||
request.path
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::AttachmentScopeExceeded,
|
||||
format!(
|
||||
"Workdir path `{}` exceeds the provider attachment scope",
|
||||
request.path
|
||||
),
|
||||
));
|
||||
}
|
||||
let allowed = request.rules.iter().any(|rule| {
|
||||
if request.permission == WorkdirToolScopePermission::Write
|
||||
@@ -1383,10 +1435,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
if allowed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkdirError::Denied(format!(
|
||||
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
||||
request.path
|
||||
)))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::DelegatedScopeExceeded,
|
||||
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 relative = Path::new(logical.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(|| {
|
||||
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> {
|
||||
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()?;
|
||||
if let Some(spill_dir) = request.spill_dir.as_deref()
|
||||
&& !self
|
||||
@@ -1794,6 +1857,15 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
let telemetry = self.inner.command_telemetry.clone();
|
||||
let command_environment = self.inner.command_environment.clone();
|
||||
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 output = run_command(
|
||||
cwd,
|
||||
@@ -1802,6 +1874,7 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
telemetry,
|
||||
command_environment,
|
||||
cancel_rx,
|
||||
resources,
|
||||
)
|
||||
.await;
|
||||
let _ = completion_tx.send(true);
|
||||
@@ -2075,7 +2148,10 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
||||
WorkdirError::Io { source, .. }
|
||||
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!(
|
||||
"I/O operation failed for {logical}: {}",
|
||||
@@ -2131,6 +2207,7 @@ async fn run_command(
|
||||
telemetry: CommandTelemetry,
|
||||
command_environment: BTreeMap<String, String>,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
let stdout = 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 stderr_path = stderr_path.to_path_buf();
|
||||
Some(
|
||||
tokio::task::spawn_blocking(move || {
|
||||
spawn_resource_owned_blocking(resources.clone(), move || {
|
||||
persist_command_output(&stdout_path, &stderr_path, &spill_dir)
|
||||
})
|
||||
.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)]
|
||||
struct CommandOutputDecoder {
|
||||
read_offset: u64,
|
||||
@@ -2561,6 +2650,58 @@ mod tests {
|
||||
use std::fs;
|
||||
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]
|
||||
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
|
||||
let path = WorkdirPath::new("missing").unwrap();
|
||||
@@ -4065,6 +4206,152 @@ mod tests {
|
||||
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]
|
||||
async fn closing_session_releases_runtime_resources() {
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -166,7 +166,12 @@ impl RoutedSessionState {
|
||||
for handle in handles {
|
||||
match self.session.command_status(handle.clone()).await {
|
||||
Ok(status) => self.mark_terminal(&handle, status),
|
||||
Err(WorkdirError::UnknownCommand(_)) => {
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.classification_source(),
|
||||
WorkdirError::UnknownCommand(_)
|
||||
) =>
|
||||
{
|
||||
self.active_commands
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
|
||||
+150
-46
@@ -240,8 +240,18 @@ impl WorkdirScopeLease {
|
||||
.expect("scoped command set mutex poisoned")
|
||||
.remove(&command_id);
|
||||
}
|
||||
(Ok(()), Err(WorkdirError::UnknownCommand(_)))
|
||||
| (Err(WorkdirError::UnknownCommand(_)), Err(WorkdirError::UnknownCommand(_))) => {
|
||||
(cancel, Err(error))
|
||||
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(
|
||||
&command_id,
|
||||
CommandStatus::Cancelled,
|
||||
@@ -595,9 +605,10 @@ impl ScopedWorkdirSession {
|
||||
if self.capabilities.supports(required) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkdirError::Denied(format!(
|
||||
"scoped Workdir tools do not permit {operation}"
|
||||
)))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ScopedCapabilityDenied,
|
||||
format!("scoped Workdir tools do not permit {operation}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,9 +623,12 @@ impl ScopedWorkdirSession {
|
||||
.iter()
|
||||
.any(|rule| rule_allows_path(rule, path, permission))
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::LogicalScopeExceeded,
|
||||
format!(
|
||||
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -626,9 +640,17 @@ impl ScopedWorkdirSession {
|
||||
}
|
||||
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
|
||||
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(
|
||||
@@ -739,9 +761,10 @@ impl ScopedWorkdirSession {
|
||||
})
|
||||
.await?
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"path `{path}` is leased to child Workdir tools"
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
|
||||
format!("path `{path}` is leased to child Workdir tools"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -792,8 +815,9 @@ impl ScopedWorkdirSession {
|
||||
) -> Result<WorkdirSessionCapabilities, WorkdirError> {
|
||||
self.ensure_active()?;
|
||||
if rules.is_empty() {
|
||||
return Err(WorkdirError::Denied(
|
||||
"workdir tool scope requires at least one logical scope rule".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::EmptyScope,
|
||||
"workdir tool scope requires at least one logical scope rule",
|
||||
));
|
||||
}
|
||||
let writable = rules
|
||||
@@ -804,22 +828,25 @@ impl ScopedWorkdirSession {
|
||||
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|
||||
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
"parent Workdir session cannot scope the requested capabilities".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ParentCapabilityDenied,
|
||||
"parent Workdir session cannot scope the requested capabilities",
|
||||
));
|
||||
}
|
||||
if command {
|
||||
if !writable {
|
||||
return Err(WorkdirError::Denied(
|
||||
"command execution requires a writable scoped path".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::CommandRequiresWritableScope,
|
||||
"command execution requires a writable scoped path",
|
||||
));
|
||||
}
|
||||
if !self
|
||||
.capabilities
|
||||
.supports(WorkdirSessionCapability::Command)
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
"parent Workdir session does not support Command".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ParentCapabilityDenied,
|
||||
"parent Workdir session does not support Command",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -829,10 +856,13 @@ impl ScopedWorkdirSession {
|
||||
.iter()
|
||||
.any(|parent| rule_contains_rule(parent, requested))
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"logical workdir scope `{}` exceeds the parent tool scope",
|
||||
requested.target
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ParentScopeExceeded,
|
||||
format!(
|
||||
"logical workdir scope `{}` exceeds the parent tool scope",
|
||||
requested.target
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -866,10 +896,13 @@ impl ScopedWorkdirSession {
|
||||
.iter()
|
||||
.any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read))
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"scoped tool cwd `{}` is outside the readable scope",
|
||||
request.cwd
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::CwdOutsideReadableScope,
|
||||
format!(
|
||||
"scoped tool cwd `{}` is outside the readable scope",
|
||||
request.cwd
|
||||
),
|
||||
));
|
||||
}
|
||||
self.ensure_scope_targets_are_authorized(&request.rules)
|
||||
.await?;
|
||||
@@ -913,10 +946,13 @@ impl ScopedWorkdirSession {
|
||||
})
|
||||
.await?
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
||||
requested.target
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
|
||||
format!(
|
||||
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
||||
requested.target
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -994,6 +1030,51 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
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(
|
||||
&self,
|
||||
mut request: crate::CheckoutSearchRequest,
|
||||
@@ -1446,7 +1527,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
) -> Result<(), WorkdirError> {
|
||||
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
|
||||
}
|
||||
@@ -1509,11 +1593,17 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
@@ -1529,22 +1619,34 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
}
|
||||
|
||||
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> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
))
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
&self,
|
||||
_request: CommandOutputRequest,
|
||||
) -> 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> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
))
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), WorkdirError> {
|
||||
@@ -1712,6 +1814,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::LocalWorkdirSession;
|
||||
|
||||
include!("scope_delegation_tests.rs");
|
||||
|
||||
fn fs_path(path: &str) -> FsPath {
|
||||
FsPath::new(path).unwrap()
|
||||
}
|
||||
@@ -2226,7 +2330,7 @@ mod tests {
|
||||
assert!(matches!(
|
||||
child.read(read("link")).await,
|
||||
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!(
|
||||
child.read(read("escape")).await,
|
||||
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))
|
||||
.await,
|
||||
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!(
|
||||
parent
|
||||
@@ -2364,7 +2468,7 @@ mod tests {
|
||||
})
|
||||
.await,
|
||||
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!(
|
||||
child.scope(expanded).await,
|
||||
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"))
|
||||
.await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("leased to child Workdir tools")
|
||||
if message.message.contains("leased to child Workdir tools")
|
||||
));
|
||||
assert_eq!(
|
||||
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 provider: String,
|
||||
pub source: server_api::RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<RepositorySelector>,
|
||||
@@ -121,7 +120,7 @@ impl std::fmt::Debug for SensitiveString {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySshCredentialCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub public_key_fingerprint: String,
|
||||
#[serde(skip, default)]
|
||||
pub private_key: SensitiveString,
|
||||
}
|
||||
@@ -130,7 +129,7 @@ pub struct RepositorySshCredentialCandidate {
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||
pub host_trust_id: String,
|
||||
pub host_trust_revision: u64,
|
||||
pub host_key_fingerprint: String,
|
||||
pub access: server_api::RepositoryAccessMode,
|
||||
pub expires_at_epoch_seconds: u64,
|
||||
pub repository_id: String,
|
||||
@@ -146,7 +145,6 @@ pub struct RepositoryMaterializationContext {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub operation_id: String,
|
||||
pub config_revision: u64,
|
||||
pub config_projection_digest: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||
@@ -191,10 +189,9 @@ pub struct RepositoryRefObservationRequest {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositoryRefObservation {
|
||||
pub repository_id: String,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub selector: String,
|
||||
pub revision_ref: String,
|
||||
pub resolved_ref: String,
|
||||
pub observed_at_epoch_seconds: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ impl ConfigBundle {
|
||||
pub fn computed_digest(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
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!("created_at\0{}", self.metadata.created_at));
|
||||
lines.push(format!(
|
||||
@@ -82,7 +81,7 @@ impl ConfigBundle {
|
||||
if let Some(prompt_catalog) = &self.prompt_catalog {
|
||||
lines.push(format!(
|
||||
"prompt_catalog\0{}\0{}\0{}\0{}",
|
||||
prompt_catalog.config_revision,
|
||||
prompt_catalog.source_digest,
|
||||
prompt_catalog.schema_fingerprint,
|
||||
prompt_catalog.toolchain_fingerprint,
|
||||
prompt_catalog.catalog_digest
|
||||
@@ -100,12 +99,8 @@ impl ConfigBundle {
|
||||
}
|
||||
if let Some(handle) = &self.profile_source_archive_handle {
|
||||
lines.push(format!(
|
||||
"profile_archive_handle\0{}\0{}\0{}\0{}\0{}",
|
||||
handle.workspace_id,
|
||||
handle.resource_id,
|
||||
handle.digest,
|
||||
handle.revision,
|
||||
handle.max_bytes
|
||||
"profile_archive_handle\0{}\0{}\0{}\0{}",
|
||||
handle.workspace_id, handle.resource_id, handle.digest, handle.max_bytes
|
||||
));
|
||||
for (selector, path) in handle
|
||||
.profile_source_graph
|
||||
@@ -140,7 +135,6 @@ impl ConfigBundle {
|
||||
id: self.metadata.id.clone(),
|
||||
digest: self.metadata.digest.clone(),
|
||||
digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(),
|
||||
revision: self.metadata.revision.clone(),
|
||||
workspace_id: self.metadata.workspace_id.clone(),
|
||||
created_at: self.metadata.created_at.clone(),
|
||||
provenance: self.metadata.provenance.clone(),
|
||||
@@ -182,7 +176,6 @@ impl From<ConfigBundle> for server_api::WorkspaceRuntimeConfigResponse {
|
||||
prompt_catalog: prompt_catalog.map(|catalog| {
|
||||
server_api::WorkspaceRuntimePromptCatalog {
|
||||
templates: catalog.templates,
|
||||
config_revision: catalog.config_revision,
|
||||
source_digest: catalog.source_digest,
|
||||
schema_fingerprint: catalog.schema_fingerprint,
|
||||
toolchain_fingerprint: catalog.toolchain_fingerprint,
|
||||
@@ -200,7 +193,6 @@ impl From<ConfigBundleMetadata> for server_api::WorkspaceRuntimeConfigMetadata {
|
||||
Self {
|
||||
id: metadata.id,
|
||||
digest: metadata.digest,
|
||||
revision: metadata.revision,
|
||||
workspace_id: metadata.workspace_id,
|
||||
created_at: metadata.created_at,
|
||||
provenance: metadata.provenance.into(),
|
||||
@@ -303,8 +295,6 @@ impl From<BackendResourceHandle> for server_api::WorkspaceRuntimeResourceHandle
|
||||
operation: handle.operation.into(),
|
||||
expires_at_unix_seconds: handle.expires_at_unix_seconds,
|
||||
nonce: handle.nonce,
|
||||
revision: handle.revision,
|
||||
generation: handle.generation,
|
||||
max_bytes: handle.max_bytes,
|
||||
content_type: handle.content_type,
|
||||
redaction: handle.redaction.into(),
|
||||
@@ -344,7 +334,6 @@ impl From<ResourceRedactionPolicy> for server_api::WorkspaceRuntimeResourceRedac
|
||||
pub struct ConfigBundleMetadata {
|
||||
pub id: String,
|
||||
pub digest: String,
|
||||
pub revision: String,
|
||||
pub workspace_id: String,
|
||||
pub created_at: String,
|
||||
pub provenance: ConfigBundleProvenance,
|
||||
@@ -402,7 +391,6 @@ pub struct ConfigBundleSummary {
|
||||
pub id: String,
|
||||
pub digest: String,
|
||||
pub digest_algorithm: String,
|
||||
pub revision: String,
|
||||
pub workspace_id: String,
|
||||
pub created_at: String,
|
||||
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_non_empty("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 created_at", &bundle.metadata.created_at)?;
|
||||
validate_non_empty(
|
||||
@@ -430,7 +417,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
|
||||
&bundle.metadata.provenance.source,
|
||||
)?;
|
||||
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 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 digest", handle.digest.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 audit correlation id",
|
||||
@@ -766,7 +751,6 @@ mod tests {
|
||||
metadata: ConfigBundleMetadata {
|
||||
id: "bundle-1".to_string(),
|
||||
digest: String::new(),
|
||||
revision: "rev-1".to_string(),
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
created_at: "2026-06-26T00:00:00Z".to_string(),
|
||||
provenance: ConfigBundleProvenance {
|
||||
@@ -823,7 +807,6 @@ mod tests {
|
||||
bundle.prompt_catalog = Some(
|
||||
worker::EffectivePromptCatalog::new(
|
||||
std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]),
|
||||
7,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -866,8 +849,6 @@ mod tests {
|
||||
operation: crate::resource::BackendResourceOperation::FetchArchive,
|
||||
expires_at_unix_seconds: 4_102_444_800,
|
||||
nonce: "nonce-not-for-browser".to_string(),
|
||||
revision: "rev-1".to_string(),
|
||||
generation: Some(1),
|
||||
max_bytes: 128,
|
||||
content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
|
||||
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
|
||||
@@ -693,7 +693,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn observe_workspace_prompt_projection(
|
||||
&self,
|
||||
|
||||
@@ -21,7 +21,9 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
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 LEGACY_SCHEMA_VERSION: u32 = 7;
|
||||
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"),
|
||||
)
|
||||
})?;
|
||||
let staging = migration_sibling(root, "schema-v9-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
|
||||
let staging = migration_sibling(root, "schema-v10-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
|
||||
if staging.exists() || backup.exists() {
|
||||
return Err(runtime_store_corrupt(
|
||||
root,
|
||||
@@ -639,12 +641,15 @@ fn plan_runtime_store_migration(
|
||||
}
|
||||
if !matches!(
|
||||
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(
|
||||
&runtime_path,
|
||||
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(
|
||||
identity_document: serde_json::Value,
|
||||
mut identity_document: serde_json::Value,
|
||||
source_schema_version: u32,
|
||||
mapping: Option<&LegacyWorkerIdentityMapping>,
|
||||
identity_path: &Path,
|
||||
) -> 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 {
|
||||
let identity: WorkerIdentityRecord =
|
||||
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)?;
|
||||
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")?;
|
||||
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)?;
|
||||
return Ok(WorkerAggregateRecord::from_v8(identity, execution));
|
||||
}
|
||||
@@ -1016,6 +1050,114 @@ fn migrate_worker_record(
|
||||
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 {
|
||||
format!("worker-runtime-{worker_id}")
|
||||
}
|
||||
@@ -1216,6 +1358,9 @@ fn migrate_runtime_document(
|
||||
serde_json::Value::from(SCHEMA_VERSION),
|
||||
);
|
||||
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");
|
||||
let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
|
||||
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> {
|
||||
let staging = migration_sibling(root, "schema-v9-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
|
||||
for suffix in ["schema-v9-staging", "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 staging_exists = migration_directory_exists(&staging)?;
|
||||
let backup_exists = migration_directory_exists(&backup)?;
|
||||
@@ -1528,8 +1679,8 @@ fn migrate_runtime_store(
|
||||
if !plan.migration_required {
|
||||
return Ok(plan);
|
||||
}
|
||||
let staging = migration_sibling(root, "schema-v9-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
|
||||
let staging = migration_sibling(root, "schema-v10-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
|
||||
if staging.exists() || backup.exists() {
|
||||
return Err(runtime_store_corrupt(
|
||||
root,
|
||||
@@ -2875,7 +3026,7 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
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::write(staging.join("partial"), b"partial").unwrap();
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
@@ -2890,7 +3041,7 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
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();
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
|
||||
@@ -2904,10 +3055,10 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
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();
|
||||
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();
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
|
||||
@@ -2922,10 +3073,10 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
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::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();
|
||||
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
@@ -2942,7 +3093,7 @@ mod tests {
|
||||
let root = temp.path().join("runtime-store");
|
||||
write_empty_schema_v8_store(&root);
|
||||
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);
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
|
||||
@@ -2950,6 +3101,120 @@ mod tests {
|
||||
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]
|
||||
fn bounded_worker_record_read_rejects_oversize_before_parse() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -3047,7 +3312,7 @@ mod tests {
|
||||
));
|
||||
assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists());
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -413,7 +413,8 @@ pub struct RuntimeHttpWorkspacePromptProjectionRequest {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpWorkspacePromptProjectionResponse {
|
||||
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.
|
||||
@@ -576,10 +577,10 @@ async fn post_workspace_verification_challenge(
|
||||
})?;
|
||||
if challenge.workspace_id != verified.workspace_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_identity_revision != verified.issuer_identity_revision
|
||||
|| challenge.workspace_trust_generation != verified.trust_generation
|
||||
|| challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint
|
||||
|| challenge.workspace_trust_id != verified.trust_id
|
||||
|| challenge.runtime_id != auth.signer.runtime_id()
|
||||
|| challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|
||||
{
|
||||
@@ -599,6 +600,13 @@ async fn post_workspace_verification_challenge(
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -616,17 +624,17 @@ async fn post_workspace_verification_acknowledgement(
|
||||
})?;
|
||||
if acknowledgement.workspace_id != verified.workspace_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_identity_revision != verified.issuer_identity_revision
|
||||
|| acknowledgement.workspace_trust_generation != verified.trust_generation
|
||||
|| acknowledgement.workspace_public_key_fingerprint
|
||||
!= verified.issuer_public_key_fingerprint
|
||||
|| acknowledgement.workspace_trust_id != verified.trust_id
|
||||
|| acknowledgement.runtime_id != auth.signer.runtime_id()
|
||||
|| acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|
||||
|| acknowledgement.expires_at <= unix_now_i64()
|
||||
|| acknowledgement.binding_revision == 0
|
||||
|| acknowledgement.runtime_identity_revision == 0
|
||||
|| acknowledgement.workspace_identity_revision == 0
|
||||
|| acknowledgement.workspace_trust_generation == 0
|
||||
|| acknowledgement.binding_id.is_empty()
|
||||
|| acknowledgement.workspace_public_key_fingerprint.is_empty()
|
||||
|| acknowledgement.workspace_trust_id.is_empty()
|
||||
|| acknowledgement.workspace_nonce.is_empty()
|
||||
|| acknowledgement.runtime_nonce.is_empty()
|
||||
|| 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(),
|
||||
workspace_id: acknowledgement.workspace_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_identity_revision: acknowledgement.workspace_identity_revision,
|
||||
workspace_trust_generation: acknowledgement.workspace_trust_generation,
|
||||
workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(),
|
||||
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
|
||||
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
|
||||
runtime_identity_revision: acknowledgement.runtime_identity_revision,
|
||||
workspace_nonce: acknowledgement.workspace_nonce.clone(),
|
||||
expires_at: acknowledgement.expires_at,
|
||||
};
|
||||
@@ -689,17 +696,23 @@ async fn post_workspace_verification_acknowledgement(
|
||||
)
|
||||
})?;
|
||||
auth.verifications
|
||||
.record(WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: acknowledgement.workspace_id.clone(),
|
||||
runtime_id: acknowledgement.runtime_id.clone(),
|
||||
binding_revision: acknowledgement.binding_revision,
|
||||
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
||||
workspace_identity_revision: acknowledgement.workspace_identity_revision,
|
||||
workspace_trust_generation: acknowledgement.workspace_trust_generation,
|
||||
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
|
||||
runtime_identity_revision: acknowledgement.runtime_identity_revision,
|
||||
verified_at: unix_now_i64(),
|
||||
})
|
||||
.acknowledge(
|
||||
&acknowledgement.challenge_id,
|
||||
WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: acknowledgement.workspace_id.clone(),
|
||||
runtime_id: acknowledgement.runtime_id.clone(),
|
||||
binding_id: acknowledgement.binding_id.clone(),
|
||||
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
||||
workspace_public_key_fingerprint: acknowledgement
|
||||
.workspace_public_key_fingerprint
|
||||
.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| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -711,7 +724,7 @@ async fn post_workspace_verification_acknowledgement(
|
||||
challenge_id: acknowledgement.challenge_id,
|
||||
workspace_id: acknowledgement.workspace_id,
|
||||
runtime_id: acknowledgement.runtime_id,
|
||||
binding_revision: acknowledgement.binding_revision,
|
||||
binding_id: acknowledgement.binding_id.clone(),
|
||||
accepted_at: unix_now_i64(),
|
||||
}))
|
||||
}
|
||||
@@ -810,14 +823,16 @@ async fn observe_workspace_prompt_projection(
|
||||
));
|
||||
}
|
||||
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
|
||||
.runtime
|
||||
.observe_workspace_prompt_projection(request.projection)
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse {
|
||||
workspace_id,
|
||||
config_revision,
|
||||
source_digest,
|
||||
projection_digest,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -951,10 +966,12 @@ async fn observe_repository_ref(
|
||||
async fn list_working_directories(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
||||
let working_directories = state
|
||||
.runtime
|
||||
.list_working_directories()
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
// Inventory providers may inspect the filesystem even for read-only requests.
|
||||
let working_directories =
|
||||
tokio::task::spawn_blocking(move || state.runtime.list_working_directories())
|
||||
.await
|
||||
.map_err(RuntimeHttpRestError::working_directory_join)?
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpWorkingDirectoriesResponse {
|
||||
working_directories,
|
||||
}))
|
||||
@@ -989,10 +1006,11 @@ async fn get_working_directory(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Path(working_directory_id): Path<String>,
|
||||
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
|
||||
let working_directory = state
|
||||
.runtime
|
||||
.working_directory(&working_directory_id)
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
let working_directory =
|
||||
tokio::task::spawn_blocking(move || state.runtime.working_directory(&working_directory_id))
|
||||
.await
|
||||
.map_err(RuntimeHttpRestError::working_directory_join)?
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
||||
working_directory,
|
||||
}))
|
||||
@@ -1130,10 +1148,16 @@ async fn cleanup_working_directory(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Path(working_directory_id): Path<String>,
|
||||
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
|
||||
let working_directory = state
|
||||
.runtime
|
||||
.cleanup_working_directory(&working_directory_id)
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
// Keep Runtime's occupancy check and deletion together, but do not run
|
||||
// synchronous filesystem/Git work on the HTTP executor.
|
||||
let working_directory = tokio::task::spawn_blocking(move || {
|
||||
state
|
||||
.runtime
|
||||
.cleanup_working_directory(&working_directory_id)
|
||||
})
|
||||
.await
|
||||
.map_err(RuntimeHttpRestError::working_directory_join)?
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
||||
working_directory,
|
||||
}))
|
||||
@@ -2099,7 +2123,7 @@ async fn require_runtime_auth(
|
||||
let body_digest = workspace_request_body_digest(&body);
|
||||
let expected = WorkspaceCapabilityExpectation {
|
||||
workspace_id: &claims.issuer_workspace_id,
|
||||
binding_revision: claims.binding_revision,
|
||||
binding_id: &claims.binding_id,
|
||||
runtime_id: workspace_auth.signer.runtime_id(),
|
||||
worker_id: expected_worker_id.as_deref(),
|
||||
operation: required_permission,
|
||||
@@ -2136,13 +2160,13 @@ async fn require_runtime_auth(
|
||||
.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_identity_revision != verified.issuer_identity_revision
|
||||
|| record.workspace_trust_generation != verified.trust_generation
|
||||
|| record.workspace_public_key_fingerprint
|
||||
!= verified.issuer_public_key_fingerprint
|
||||
|| record.workspace_trust_id != verified.trust_id
|
||||
|| record.runtime_public_key_fingerprint
|
||||
!= workspace_auth.signer.public_key_fingerprint()
|
||||
|| record.runtime_identity_revision == 0
|
||||
{
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -2350,6 +2374,7 @@ impl RuntimeHttpWorkdirError {
|
||||
Self {
|
||||
status,
|
||||
payload: WorkdirTransportError {
|
||||
denial_reason: None,
|
||||
code,
|
||||
message: message.into(),
|
||||
},
|
||||
@@ -2443,6 +2468,16 @@ impl RuntimeHttpRestError {
|
||||
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 {
|
||||
Self::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -2762,9 +2797,8 @@ mod tests {
|
||||
key_id: "workspace-key".to_string(),
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: workspace_identity.public_key.clone(),
|
||||
public_key_fingerprint: workspace_fingerprint,
|
||||
identity_revision: 1,
|
||||
trust_generation: 1,
|
||||
public_key_fingerprint: workspace_fingerprint.clone(),
|
||||
trust_id: "trust-test".into(),
|
||||
state: WorkspaceIssuerTrustState::Active,
|
||||
registered_at_unix: 1,
|
||||
updated_at_unix: 1,
|
||||
@@ -2785,12 +2819,11 @@ mod tests {
|
||||
challenge_id: "challenge-1".to_string(),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
binding_revision: 4,
|
||||
binding_id: "binding-test".into(),
|
||||
workspace_key_id: "workspace-key".to_string(),
|
||||
workspace_identity_revision: 1,
|
||||
workspace_trust_generation: 1,
|
||||
workspace_public_key_fingerprint: workspace_fingerprint.clone(),
|
||||
workspace_trust_id: "trust-test".into(),
|
||||
runtime_public_key_fingerprint: runtime_fingerprint,
|
||||
runtime_identity_revision: 1,
|
||||
workspace_nonce: "workspace-nonce".to_string(),
|
||||
expires_at: unix_now_i64() + 60,
|
||||
};
|
||||
@@ -2799,9 +2832,9 @@ mod tests {
|
||||
issuer: "https://backend.test".to_string(),
|
||||
issuer_workspace_id: "workspace-a".to_string(),
|
||||
issuer_key_id: "workspace-key".to_string(),
|
||||
issuer_identity_revision: 1,
|
||||
trust_generation: 1,
|
||||
binding_revision: 4,
|
||||
issuer_public_key_fingerprint: workspace_fingerprint.clone(),
|
||||
trust_id: "trust-test".into(),
|
||||
binding_id: "binding-test".into(),
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
worker_id: None,
|
||||
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
|
||||
@@ -2850,14 +2883,15 @@ mod tests {
|
||||
challenge_id: verification_response.challenge_id.clone(),
|
||||
workspace_id: verification_response.workspace_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_identity_revision: verification_response.workspace_identity_revision,
|
||||
workspace_trust_generation: verification_response.workspace_trust_generation,
|
||||
workspace_public_key_fingerprint: verification_response
|
||||
.workspace_public_key_fingerprint
|
||||
.clone(),
|
||||
workspace_trust_id: verification_response.workspace_trust_id.clone(),
|
||||
runtime_public_key_fingerprint: verification_response
|
||||
.runtime_public_key_fingerprint
|
||||
.clone(),
|
||||
runtime_identity_revision: verification_response.runtime_identity_revision,
|
||||
workspace_nonce: verification_response.workspace_nonce.clone(),
|
||||
runtime_nonce: verification_response.runtime_nonce.clone(),
|
||||
response_digest: workspace_request_body_digest(&response_body),
|
||||
@@ -2954,13 +2988,11 @@ mod tests {
|
||||
workspace_id: "workspace-b".to_string(),
|
||||
source_runtime_id: "runtime-a".to_string(),
|
||||
worker_id: retention_worker_id,
|
||||
expected_worker_revision: "revision".to_string(),
|
||||
source_created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||
removed_at: "2025-01-02T00:00:00Z".to_string(),
|
||||
effective_profile: None,
|
||||
retention_class: None,
|
||||
policy_id: "policy".to_string(),
|
||||
policy_revision: 1,
|
||||
session_disposition: SessionDisposition::Purge,
|
||||
diagnostics_disposition: DiagnosticsDisposition::Retain,
|
||||
};
|
||||
@@ -2998,6 +3030,141 @@ mod tests {
|
||||
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")]
|
||||
#[test]
|
||||
fn runtime_protocol_replaces_serialized_tracked_source() {
|
||||
@@ -3087,7 +3254,6 @@ mod tests {
|
||||
metadata: ConfigBundleMetadata {
|
||||
id: "http-test-bundle".to_string(),
|
||||
digest: String::new(),
|
||||
revision: "test".to_string(),
|
||||
workspace_id: "test-workspace".to_string(),
|
||||
created_at: "test".to_string(),
|
||||
provenance: ConfigBundleProvenance {
|
||||
@@ -3144,7 +3310,6 @@ mod tests {
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
@@ -4341,34 +4506,378 @@ mod tests {
|
||||
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]
|
||||
async fn cleanup_cause_codes_survive_http_error_serialization() {
|
||||
let cases = [
|
||||
("mount_present", StatusCode::CONFLICT),
|
||||
("mount_check_unavailable", StatusCode::SERVICE_UNAVAILABLE),
|
||||
("permission_denied", StatusCode::FORBIDDEN),
|
||||
("resource_busy", StatusCode::CONFLICT),
|
||||
("storage_unavailable", StatusCode::SERVICE_UNAVAILABLE),
|
||||
("ownership_unknown", StatusCode::CONFLICT),
|
||||
("changes_present", StatusCode::CONFLICT),
|
||||
("changes_unknown", StatusCode::CONFLICT),
|
||||
("working_directory_not_found", StatusCode::NOT_FOUND),
|
||||
(
|
||||
"working_directory_cleanup_mount_present",
|
||||
StatusCode::CONFLICT,
|
||||
),
|
||||
(
|
||||
"working_directory_cleanup_mount_check_unavailable",
|
||||
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 {
|
||||
let code = format!("working_directory_cleanup_{cause}");
|
||||
for (code, expected_status) in cases {
|
||||
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(
|
||||
&code,
|
||||
code,
|
||||
"Resolve the current blocker and retry removal.",
|
||||
);
|
||||
let response =
|
||||
RuntimeHttpRestError::runtime(RuntimeError::WorkingDirectory(diagnostic))
|
||||
.into_response();
|
||||
assert_eq!(response.status(), status);
|
||||
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"));
|
||||
assert_eq!(
|
||||
json["error"]["message"],
|
||||
RuntimeError::WorkingDirectory(diagnostic).to_string(),
|
||||
"{json}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4676,7 +5185,6 @@ mod ws_tests {
|
||||
metadata: ConfigBundleMetadata {
|
||||
id: "ws-test-bundle".to_string(),
|
||||
digest: String::new(),
|
||||
revision: "test".to_string(),
|
||||
workspace_id: "test".to_string(),
|
||||
created_at: "test".to_string(),
|
||||
provenance: ConfigBundleProvenance {
|
||||
@@ -4732,7 +5240,6 @@ mod ws_tests {
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
|
||||
@@ -905,12 +905,11 @@ fn print_workspace_trust_mutation(
|
||||
WorkspaceIssuerTrustMutation::Unchanged => "unchanged",
|
||||
};
|
||||
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.key_id,
|
||||
record.public_key_fingerprint,
|
||||
record.identity_revision,
|
||||
record.trust_generation,
|
||||
record.trust_id,
|
||||
record.state,
|
||||
);
|
||||
}
|
||||
@@ -936,10 +935,38 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
|
||||
}
|
||||
let contents = String::from_utf8(contents)
|
||||
.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"))?;
|
||||
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -1398,7 +1425,6 @@ mod tests {
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: identity.public_key,
|
||||
public_key_fingerprint: fingerprint,
|
||||
revision: 1,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -1419,7 +1445,38 @@ mod tests {
|
||||
let path = runtime_auth_path(&config);
|
||||
let first = read_runtime_auth_file(&path).unwrap();
|
||||
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([
|
||||
"add".to_string(),
|
||||
@@ -1430,7 +1487,10 @@ mod tests {
|
||||
]))
|
||||
.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 public_key = decode_public_key(&replacement.public_key).unwrap();
|
||||
@@ -1451,7 +1511,6 @@ mod tests {
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: replacement.public_key,
|
||||
public_key_fingerprint: fingerprint,
|
||||
revision: 2,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -1483,7 +1542,10 @@ mod tests {
|
||||
]))
|
||||
.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!(
|
||||
revoked.workspace_issuers[0].state,
|
||||
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
|
||||
|
||||
@@ -17,7 +17,7 @@ pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RepositorySshAccessSecretCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub public_key_fingerprint: String,
|
||||
pub private_key: String,
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
|
||||
formatter
|
||||
.debug_struct("RepositorySshAccessSecretCandidate")
|
||||
.field("credential_id", &self.credential_id)
|
||||
.field("credential_revision", &self.credential_revision)
|
||||
.field("public_key_fingerprint", &self.public_key_fingerprint)
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
@@ -89,9 +89,6 @@ pub struct BackendResourceHandle {
|
||||
pub operation: BackendResourceOperation,
|
||||
pub expires_at_unix_seconds: i64,
|
||||
pub nonce: String,
|
||||
pub revision: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation: Option<u64>,
|
||||
pub max_bytes: u64,
|
||||
pub content_type: String,
|
||||
pub redaction: ResourceRedactionPolicy,
|
||||
@@ -425,8 +422,6 @@ mod tests {
|
||||
operation: BackendResourceOperation::FetchArchive,
|
||||
expires_at_unix_seconds: 4_102_444_800,
|
||||
nonce: "nonce-test".to_string(),
|
||||
revision: sha256_hex(bytes),
|
||||
generation: Some(1),
|
||||
max_bytes: 1024,
|
||||
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
|
||||
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
@@ -477,7 +472,7 @@ mod tests {
|
||||
let secret = RepositorySshAccessSecret {
|
||||
credential_candidates: vec![RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 2,
|
||||
public_key_fingerprint: "key-2".into(),
|
||||
private_key: "PRIVATE 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 source_runtime_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub expected_worker_revision: String,
|
||||
pub source_created_at: String,
|
||||
pub removed_at: String,
|
||||
pub effective_profile: Option<String>,
|
||||
pub retention_class: Option<String>,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||
}
|
||||
@@ -145,7 +143,6 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub content_bytes: u64,
|
||||
pub content_file_count: u64,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
}
|
||||
@@ -154,7 +151,6 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub struct WorkerRetentionExecutionResult {
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
pub expected_worker_revision: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||
@@ -524,7 +520,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
||||
let mut result = WorkerRetentionExecutionResult {
|
||||
operation_id: request.operation_id.clone(),
|
||||
input_fingerprint: request.input_fingerprint.clone(),
|
||||
expected_worker_revision: request.expected_worker_revision.clone(),
|
||||
worker_id: request.worker_id,
|
||||
session_disposition: request.session_disposition,
|
||||
diagnostics_disposition: request.diagnostics_disposition,
|
||||
@@ -749,7 +744,6 @@ fn commit_session_archive(
|
||||
content_bytes: bytes,
|
||||
content_file_count: count,
|
||||
policy_id: request.policy_id.clone(),
|
||||
policy_revision: request.policy_revision,
|
||||
operation_id: request.operation_id.clone(),
|
||||
input_fingerprint: request.input_fingerprint.clone(),
|
||||
};
|
||||
@@ -1290,7 +1284,6 @@ mod tests {
|
||||
WorkerRetentionExecutionRequest {
|
||||
operation_id: "operation-a".to_string(),
|
||||
input_fingerprint: "fingerprint-a".to_string(),
|
||||
expected_worker_revision: "revision-a".to_string(),
|
||||
archive_id: (disposition == SessionDisposition::Archive)
|
||||
.then(|| "archive-a".to_string()),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
@@ -1301,7 +1294,6 @@ mod tests {
|
||||
effective_profile: Some("builtin:coder".to_string()),
|
||||
retention_class: None,
|
||||
policy_id: "policy-a".to_string(),
|
||||
policy_revision: 3,
|
||||
session_disposition: disposition,
|
||||
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_file_count: manifest.content_file_count,
|
||||
policy_id: manifest.policy_id.clone(),
|
||||
policy_revision: manifest.policy_revision,
|
||||
operation_id: manifest.operation_id.clone(),
|
||||
input_fingerprint: manifest.input_fingerprint.clone(),
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ use worker::feature::builtin::{
|
||||
WorkspaceClientWorkerObservationProvider,
|
||||
};
|
||||
#[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::{
|
||||
PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle,
|
||||
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
||||
@@ -142,6 +142,15 @@ use worker::{
|
||||
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 RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SPAWN_RESTORE_TASK_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
@@ -403,26 +412,12 @@ impl WorkspacePromptProjectionCache {
|
||||
.active
|
||||
.lock()
|
||||
.map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?;
|
||||
if let Some(current) = active.get(&workspace_id) {
|
||||
if current.projection.config_revision > resolution.projection.config_revision {
|
||||
return Ok(current.clone());
|
||||
}
|
||||
if current.projection.config_revision == resolution.projection.config_revision
|
||||
&& (current.projection.source_digest != resolution.projection.source_digest
|
||||
|| current.projection.projection_digest
|
||||
!= resolution.projection.projection_digest
|
||||
|| current.projection.catalog.catalog_digest
|
||||
!= resolution.projection.catalog.catalog_digest
|
||||
|| current.projection.catalog.schema_fingerprint
|
||||
!= resolution.projection.catalog.schema_fingerprint
|
||||
|| current.projection.catalog.toolchain_fingerprint
|
||||
!= resolution.projection.catalog.toolchain_fingerprint)
|
||||
{
|
||||
return Err(format!(
|
||||
"Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}",
|
||||
resolution.projection.config_revision
|
||||
));
|
||||
}
|
||||
// Runtime receives authoritative projections in operation/arrival order.
|
||||
// Equal content reuses the compiled catalog; counters are not authority.
|
||||
if let Some(current) = active.get(&workspace_id)
|
||||
&& current.projection == resolution.projection
|
||||
{
|
||||
return Ok(current.clone());
|
||||
}
|
||||
active.insert(workspace_id, resolution.clone());
|
||||
Ok(resolution)
|
||||
@@ -688,7 +683,7 @@ impl ProfileRuntimeWorkerFactory {
|
||||
None => fallback,
|
||||
};
|
||||
// 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)?;
|
||||
Ok((manifest, loader))
|
||||
}
|
||||
@@ -1025,7 +1020,9 @@ fn runtime_local_workdir_router(
|
||||
command_output_scope.clone(),
|
||||
capabilities,
|
||||
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}"))?;
|
||||
@@ -1193,11 +1190,8 @@ fn validate_worker_memory_settings(
|
||||
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
||||
})?;
|
||||
return Err(format!(
|
||||
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||
expected.workspace_id,
|
||||
expected.settings_revision,
|
||||
actual.workspace_id,
|
||||
actual.settings_revision
|
||||
"Workspace Worker Memory settings snapshot mismatch: expected {} language {}, restored {} language {}",
|
||||
expected.workspace_id, expected.language, actual.workspace_id, actual.language
|
||||
));
|
||||
}
|
||||
let subjektiv_settings = manifest.feature.subjektiv.workspace_settings();
|
||||
@@ -1214,11 +1208,8 @@ fn validate_worker_memory_settings(
|
||||
.to_string()
|
||||
})?;
|
||||
return Err(format!(
|
||||
"Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||
expected.workspace_id,
|
||||
expected.settings_revision,
|
||||
actual.workspace_id,
|
||||
actual.settings_revision
|
||||
"Workspace Worker subjektiv settings snapshot mismatch: expected {} language {}, restored {} language {}",
|
||||
expected.workspace_id, expected.language, actual.workspace_id, actual.language
|
||||
));
|
||||
}
|
||||
} else if subjektiv_settings.is_some() {
|
||||
@@ -3453,7 +3444,7 @@ where
|
||||
scope,
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
binding.command_environment(),
|
||||
binding.session_resources(),
|
||||
binding.session_resources()?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -4787,7 +4778,6 @@ mod tests {
|
||||
request.subjektiv_attached = true;
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-saved-subjektiv".to_string(),
|
||||
settings_revision: 17,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
bind_workspace_memory_settings(&mut saved, &request).unwrap();
|
||||
@@ -5980,7 +5970,6 @@ mod tests {
|
||||
);
|
||||
let mut catalog = worker::EffectivePromptCatalog::new(
|
||||
templates,
|
||||
17,
|
||||
projection.schema_fingerprint.clone(),
|
||||
projection.toolchain_fingerprint.clone(),
|
||||
)
|
||||
@@ -6003,7 +5992,6 @@ mod tests {
|
||||
missing_instruction_bundle.prompt_catalog = Some(
|
||||
worker::EffectivePromptCatalog::new(
|
||||
templates,
|
||||
17,
|
||||
projection.schema_fingerprint.clone(),
|
||||
projection.toolchain_fingerprint.clone(),
|
||||
)
|
||||
@@ -6113,19 +6101,19 @@ mod tests {
|
||||
.with_runtime_store_dir(&runtime_store)
|
||||
.with_runtime_id("runtime-subjektiv-test");
|
||||
let before = persisted_files(&runtime_store);
|
||||
let mut wrong_revision = request.clone();
|
||||
wrong_revision
|
||||
let mut wrong_settings = request.clone();
|
||||
wrong_settings
|
||||
.request
|
||||
.memory_settings
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.settings_revision = 18;
|
||||
.language = "different language".into();
|
||||
let mut detached = request.clone();
|
||||
detached.request.subjektiv_attached = false;
|
||||
let mut missing_settings = request.clone();
|
||||
missing_settings.request.memory_settings = None;
|
||||
for (invalid, expected) in [
|
||||
(wrong_revision, "subjektiv settings snapshot mismatch"),
|
||||
(wrong_settings, "subjektiv settings snapshot mismatch"),
|
||||
(detached, "unauthorized subjektiv attachment"),
|
||||
(missing_settings, "missing its trusted settings snapshot"),
|
||||
] {
|
||||
@@ -6658,7 +6646,6 @@ mod tests {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
let catalog_v1 = worker::EffectivePromptCatalog::new(
|
||||
BTreeMap::from([("default".to_string(), "prompt-v1".to_string())]),
|
||||
8,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -6672,7 +6659,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let catalog_v2 = worker::EffectivePromptCatalog::new(
|
||||
BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]),
|
||||
9,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -6689,7 +6675,7 @@ mod tests {
|
||||
cache.observe(projection_v2).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!(
|
||||
active.projection.catalog.catalog_digest,
|
||||
catalog_v2.catalog_digest
|
||||
@@ -6697,10 +6683,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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(
|
||||
BTreeMap::from([("default".to_string(), "prompt".to_string())]),
|
||||
8,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -6722,22 +6707,20 @@ mod tests {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
|
||||
cache.observe(first).unwrap();
|
||||
let error = cache.observe(drifted).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("without a config revision transition")
|
||||
assert_eq!(
|
||||
cache.observe(drifted).unwrap().projection.source_digest,
|
||||
"source-b"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 first_catalog =
|
||||
worker::EffectivePromptCatalog::new(templates.clone(), 8, "schema-a", "toolchain")
|
||||
worker::EffectivePromptCatalog::new(templates.clone(), "schema-a", "toolchain")
|
||||
.unwrap();
|
||||
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(
|
||||
"workspace-a",
|
||||
"source-a",
|
||||
@@ -6755,8 +6738,14 @@ mod tests {
|
||||
let cache = WorkspacePromptProjectionCache::default();
|
||||
|
||||
cache.observe(first).unwrap();
|
||||
let error = cache.observe(drifted).unwrap_err();
|
||||
assert!(error.contains("without a config revision transition"));
|
||||
assert_eq!(
|
||||
cache
|
||||
.observe(drifted)
|
||||
.unwrap()
|
||||
.projection
|
||||
.schema_fingerprint,
|
||||
"schema-b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7163,7 +7152,6 @@ mod tests {
|
||||
metadata: crate::config_bundle::ConfigBundleMetadata {
|
||||
id: "adapter-test-bundle".to_string(),
|
||||
digest: String::new(),
|
||||
revision: "test".to_string(),
|
||||
workspace_id: "adapter-test".to_string(),
|
||||
created_at: "test".to_string(),
|
||||
provenance: crate::config_bundle::ConfigBundleProvenance {
|
||||
@@ -7271,7 +7259,7 @@ mod tests {
|
||||
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
|
||||
job_id: "job-1".into(),
|
||||
attempt_id: "attempt-1".into(),
|
||||
input_revision: None,
|
||||
input_digest: None,
|
||||
subjektiv_consolidation: granted,
|
||||
});
|
||||
for tools in [false, true] {
|
||||
@@ -7327,7 +7315,6 @@ mod tests {
|
||||
};
|
||||
let settings = manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-subject".to_string(),
|
||||
settings_revision: 3,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
let mut ordinary_request = create_request("ordinary");
|
||||
@@ -7985,7 +7972,6 @@ mod tests {
|
||||
kind: server_api::RepositorySourceKind::LocalPath,
|
||||
uri: repo.display().to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
selector: Some(RepositorySelector::from("HEAD")),
|
||||
},
|
||||
@@ -8195,7 +8181,6 @@ mod tests {
|
||||
);
|
||||
let mut effective = worker::EffectivePromptCatalog::new(
|
||||
templates,
|
||||
7,
|
||||
projection.schema_fingerprint.clone(),
|
||||
projection.toolchain_fingerprint.clone(),
|
||||
)
|
||||
@@ -8211,7 +8196,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolution.projection.config_revision, 7);
|
||||
assert_eq!(resolution.projection.source_digest, "source-7");
|
||||
assert_eq!(
|
||||
resolution.catalog.notify_wrapper("restored").unwrap(),
|
||||
"PENDING-LAUNCH restored"
|
||||
@@ -8248,7 +8233,6 @@ mod tests {
|
||||
});
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-restore".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap();
|
||||
@@ -9501,6 +9485,42 @@ mod tests {
|
||||
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]
|
||||
fn spawn_failure_with_existing_working_directory_preserves_workdir() {
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -736,7 +736,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
|
||||
fn current_prompt_projection(
|
||||
&self,
|
||||
minimum_revision: Option<u64>,
|
||||
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||
let Some(cache) = self.prompt_projection_cache.as_ref() else {
|
||||
return Ok(None);
|
||||
@@ -744,11 +743,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
if let Some(resolution) = cache
|
||||
.active(&self.workspace_id)
|
||||
.map_err(WorkspaceClientError::Request)?
|
||||
.filter(|resolution| {
|
||||
minimum_revision
|
||||
.map(|minimum| resolution.projection.config_revision >= minimum)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
{
|
||||
return Ok(Some((*resolution).clone()));
|
||||
}
|
||||
@@ -763,11 +757,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
if let Some(resolution) = cache
|
||||
.active(&self.workspace_id)
|
||||
.map_err(WorkspaceClientError::Request)?
|
||||
.filter(|resolution| {
|
||||
minimum_revision
|
||||
.map(|minimum| resolution.projection.config_revision >= minimum)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
{
|
||||
return Ok(Some((*resolution).clone()));
|
||||
}
|
||||
@@ -807,14 +796,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
let resolution = cache
|
||||
.observe(projection)
|
||||
.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()))
|
||||
}
|
||||
|
||||
@@ -1082,7 +1063,6 @@ mod tests {
|
||||
"default".to_string(),
|
||||
"workspace prompt".to_string(),
|
||||
)]),
|
||||
3,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -1103,22 +1083,20 @@ mod tests {
|
||||
)
|
||||
.with_prompt_projection_cache(cache);
|
||||
|
||||
let projection = client.current_prompt_projection(None).unwrap().unwrap();
|
||||
let second = client.current_prompt_projection(None).unwrap().unwrap();
|
||||
let projection = client.current_prompt_projection().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!(Arc::ptr_eq(&projection.catalog, &second.catalog));
|
||||
}
|
||||
|
||||
#[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::net::TcpListener;
|
||||
|
||||
let catalog = worker::EffectivePromptCatalog::new(
|
||||
std::collections::BTreeMap::from([("default".to_string(), "stale prompt".to_string())]),
|
||||
3,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -1132,7 +1110,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let body = serde_json::to_string(&projection).unwrap();
|
||||
let cache = Arc::new(WorkspacePromptProjectionCache::default());
|
||||
cache.observe(projection).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = std::thread::spawn(move || {
|
||||
@@ -1151,10 +1128,10 @@ mod tests {
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
|
||||
.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();
|
||||
|
||||
assert!(error.to_string().contains("required revision 4, got 3"));
|
||||
assert_eq!(fetched.projection.source_digest, "source-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1168,7 +1145,6 @@ mod tests {
|
||||
"default".to_string(),
|
||||
"shared prompt".to_string(),
|
||||
)]),
|
||||
5,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -1211,7 +1187,7 @@ mod tests {
|
||||
let barrier = barrier.clone();
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
client.current_prompt_projection(None).unwrap().unwrap()
|
||||
client.current_prompt_projection().unwrap().unwrap()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1239,7 +1215,6 @@ mod tests {
|
||||
"default".to_string(),
|
||||
"foreign prompt".to_string(),
|
||||
)]),
|
||||
4,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -1270,7 +1245,7 @@ mod tests {
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
|
||||
.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();
|
||||
|
||||
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();
|
||||
summary.status = WorkingDirectoryStatusKind::CleanupPending;
|
||||
summary.cleanliness = Some(
|
||||
match check_mounts(
|
||||
&materializer.working_directory_root(id),
|
||||
&fs::read("/proc/self/mountinfo").ok()?,
|
||||
)
|
||||
.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(),
|
||||
);
|
||||
// Observation is not removal admission: do not inventory or hash the
|
||||
// surviving checkout just to list it. The witness can be stale, so neither
|
||||
// its old cleanliness nor a successful identity check proves current content
|
||||
// is safe to remove. Actual retries still perform the full safety checks.
|
||||
summary.cleanliness = Some("unknown".to_string());
|
||||
Some(WorkingDirectoryStatus { summary })
|
||||
}
|
||||
|
||||
@@ -198,7 +190,7 @@ fn remove_with_checks(
|
||||
}
|
||||
} else {
|
||||
let mut binding = materializer
|
||||
.read_binding(id)
|
||||
.read_binding_for_cleanup(id)
|
||||
.map_err(|_| cause("ownership_unknown"))?;
|
||||
validate_binding(materializer, id, &binding)?;
|
||||
// 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
|
||||
// materialization. All repository, creation and cleanup identity stays.
|
||||
directory.evidence.operation_id = None;
|
||||
directory.evidence.credential_revision = None;
|
||||
directory.evidence.host_trust_revision = None;
|
||||
directory.evidence.public_key_fingerprint = None;
|
||||
directory.evidence.host_key_fingerprint = None;
|
||||
directory
|
||||
};
|
||||
immutable(saved) == immutable(current)
|
||||
@@ -306,7 +298,7 @@ fn retry_inventory(
|
||||
return Ok(before);
|
||||
}
|
||||
let binding = materializer
|
||||
.read_binding(id)
|
||||
.read_binding_for_cleanup(id)
|
||||
.map_err(|_| cause("changes_present"))?;
|
||||
validate_binding(materializer, id, &binding)?;
|
||||
if !same_materialization(&saved.working_directory, &binding.working_directory)
|
||||
@@ -974,7 +966,7 @@ pub(super) mod tests {
|
||||
}
|
||||
|
||||
#[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 id = &binding.working_directory.id;
|
||||
fail_once(&materializer, id);
|
||||
@@ -983,12 +975,54 @@ pub(super) mod tests {
|
||||
status.summary.status,
|
||||
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();
|
||||
assert_eq!(removed.summary.status, WorkingDirectoryStatusKind::NotFound);
|
||||
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]
|
||||
fn partial_deletion_missing_checkout_and_restart_finish_with_durable_content_authority() {
|
||||
for removed in [
|
||||
@@ -1021,7 +1055,7 @@ pub(super) mod tests {
|
||||
retained.summary.cleanup_target.as_ref(),
|
||||
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!(
|
||||
restarted.list_working_directories().unwrap()[0].summary,
|
||||
retained.summary
|
||||
@@ -1074,7 +1108,7 @@ pub(super) mod tests {
|
||||
.summary
|
||||
.cleanliness
|
||||
.as_deref(),
|
||||
Some("dirty")
|
||||
Some("unknown")
|
||||
);
|
||||
assert_eq!(
|
||||
materializer.cleanup_working_directory(id).unwrap_err().code,
|
||||
@@ -1117,7 +1151,7 @@ pub(super) mod tests {
|
||||
.summary
|
||||
.cleanliness
|
||||
.as_deref(),
|
||||
Some("clean"),
|
||||
Some("unknown"),
|
||||
"{resolution}"
|
||||
);
|
||||
// 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"] {
|
||||
let (_runtime, materializer, binding) = fixture();
|
||||
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);
|
||||
let mut record = materializer.read_binding(id).unwrap();
|
||||
match update {
|
||||
@@ -1184,8 +1225,8 @@ pub(super) mod tests {
|
||||
// The same serialized fields changed by authorize_repository_access.
|
||||
record.working_directory.evidence.operation_id =
|
||||
Some("reattach-operation".into());
|
||||
record.working_directory.evidence.credential_revision = Some(2);
|
||||
record.working_directory.evidence.host_trust_revision = Some(2);
|
||||
record.working_directory.evidence.public_key_fingerprint = Some("key-2".into());
|
||||
record.working_directory.evidence.host_key_fingerprint = Some("key-2".into());
|
||||
}
|
||||
"repository" => {
|
||||
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.
|
||||
|
||||
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
|
||||
the live grant/link and validates canonical changes/CAS under its execution lock.
|
||||
No operation accepts LLM-supplied validators or `expected_digest` fields. Returned
|
||||
post-call path validators are refreshed from the exact committed revision, not
|
||||
substituted with root validators or later unrelated state. UI/Worker edits and
|
||||
same-bytes replacement revisions reject stale calls. Old descriptors cannot
|
||||
post-call path validators are refreshed from the exact committed content, not
|
||||
substituted with root validators or later unrelated state. Content changes and
|
||||
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.
|
||||
|
||||
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 attempt_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_revision: Option<String>,
|
||||
pub input_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subjektiv_consolidation: bool,
|
||||
}
|
||||
@@ -19,10 +19,10 @@ impl BackendJobExecutionBinding {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if [&self.job_id, &self.attempt_id]
|
||||
.into_iter()
|
||||
.chain(self.input_revision.iter())
|
||||
.chain(self.input_digest.iter())
|
||||
.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(())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user