12 Commits
Author SHA1 Message Date
Hare c78f7f7d80 feat: replace revision counters with domain state contracts
Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts.

Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
2026-10-10 15:55:20 +09:00
Hare 72ce817de4 fix: integrate structured Workdir refusal diagnostics
Merge independently approved T-731 source 8af492ff into current develop after T-732. No conflicts or source edits. Integration root locked/offline check, eight affected crates, Workdir default/http-client, Server 742+11 (one existing ignored), API schema freshness, fmt/diff passed. Historical Workdir403 remains unidentified; HTTP-only feature-gating limitation remains separate.
2026-10-10 14:40:05 +09:00
Hare ac01f64f4d fix: integrate concurrent Workspace request admission
Merge independently approved T-732 source defaf8f3 into develop. Integration root locked/offline cargo check, full Server 737 library and 11 binary tests (one existing ignored), fmt/diff passed. No source changes or conflict resolution.
2026-10-10 14:25:37 +09:00
Hare 8af492ff84 Merge develop into T-731 source and align scoped authorization logging fixture 2026-10-10 14:20:32 +09:00
Hare defaf8f369 fix: allow concurrent Workspace requests with deletion admission fencing 2026-10-10 13:19:52 +09:00
Hare fc57716344 docs: record workdir tooling and runtime feature blockers 2026-10-10 12:04:56 +09:00
Hare dc8e586a0b fix: keep runtime responsive during workdir cleanup 2026-10-10 12:04:56 +09:00
Hare 7321d6c7c3 fix: forward scoped Workdir delegation checks
Forward provider authorization and overlap checks through ScopedWorkdirSession while preserving capability ceilings, path rebasing, write leases, and revocation. Exercise stacked wrappers, External providers, and remote HTTP sessions without weakening resolved symlink policy.
2026-10-10 02:12:29 +09:00
Hare fd759545c1 fix(workdir): preserve mutation and command safety through diagnostics 2026-10-10 00:00:43 +09:00
Hare 6ac87c885e fix(workdir): keep diagnostic context transparent to Write creation 2026-10-09 23:21:50 +09:00
Hare 10c55874e3 fix: preserve tool classification and accurate Workdir failure stages 2026-10-09 22:55:53 +09:00
Hare f9d70e2ae2 fix: retain structured Workdir refusal diagnostics across provider boundaries 2026-10-09 22:23:22 +09:00
399 changed files with 33018 additions and 12513 deletions
Generated
+2
View File
@@ -2754,6 +2754,7 @@ dependencies = [
"serde_json",
"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
View File
@@ -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"
+19 -19
View File
@@ -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(&current),
expected_content_digest: ticket::ticket_content_digest(&current),
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(&current),
expected_content_digest: ticket::ticket_content_digest(&current),
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());
+42 -19
View File
@@ -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
View File
@@ -27,9 +27,16 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_PATH_BYTES: usize = 512;
pub const MAX_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(&current, 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(&current)
.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))
+2 -11
View File
@@ -7,9 +7,6 @@ const CODER_REVIEW_FLOW_SOURCE: &str = include_str!("../../../resources/flows/co
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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
View File
@@ -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,
};
-1
View File
@@ -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,
}
+2 -8
View File
@@ -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.
+27 -22
View File
@@ -37,6 +37,26 @@ pub const MAX_RESULT_PATH_BYTES: usize = 1024 * 1024;
/// Provider-side ceiling for aggregate grep source bytes per operation.
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
View File
@@ -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
))
+26 -10
View File
@@ -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
View File
@@ -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()
-1
View File
@@ -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!({
-2
View File
@@ -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,
}
+30 -2
View File
@@ -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";
+1
View File
@@ -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
View File
@@ -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,
+37 -62
View File
@@ -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,
+254 -24
View File
@@ -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
View File
@@ -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 =
-1
View File
@@ -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(),
+3 -12
View File
@@ -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,
+79 -33
View File
@@ -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"),
(
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()
+15 -8
View File
@@ -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 [
+3 -1
View File
@@ -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,
},
+2 -2
View File
@@ -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(),
},
+1 -1
View File
@@ -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();
+24 -12
View File
@@ -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]
+36 -24
View File
@@ -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()
);
}
}
+12 -6
View File
@@ -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());
}
+9 -10
View File
@@ -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
View File
@@ -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(&current).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!(
+4 -5
View File
@@ -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)),
+1 -1
View File
@@ -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()),
-1
View File
@@ -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(),
+7 -7
View File
@@ -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
+1
View File
@@ -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"] }
+24 -3
View File
@@ -17,7 +17,9 @@ identity under its operator-managed state directory, **not a fake Workspace**.
Subject IDs are independently issued random domain identities. An ID is a lookup
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
View File
@@ -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(_) => {
+9 -10
View File
@@ -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(),
})
}
+117 -6
View File
@@ -23,10 +23,15 @@ fn query(cursor: Option<String>) -> Op {
})
}
fn read(id: &str, revision: Option<u64>, offset: Option<usize>, byte_offset: Option<usize>) -> Op {
fn read(
id: &str,
change_id: Option<String>,
offset: Option<usize>,
byte_offset: Option<usize>,
) -> Op {
Op::Read(server_api::SubjektivMemoryReadRequest {
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(_))
));
}
+3 -3
View File
@@ -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,
+4 -4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
//! Current schema creation. Frozen data conversion lives with the old migrations.
use super::*;
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
legacy_migrations::remove_state_numbers(tx)
}
pub(super) fn create(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch(include_str!("schema.sql"))?;
Ok(())
}
+390
View File
@@ -0,0 +1,390 @@
CREATE TABLE store_scope (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
workspace_id TEXT NOT NULL UNIQUE
);
CREATE TABLE subjects (
subject_id TEXT PRIMARY KEY,
role TEXT NOT NULL,
behavior_md TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL CHECK (state IN ('active', 'retired')),
record_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE staging_records (
subject_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (
kind IN (
'preference', 'working_assumption', 'constraint',
'decision', 'open_question', 'lesson'
)
),
record_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, candidate_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
);
CREATE TABLE memory_records (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
current_change_id TEXT NOT NULL CHECK (length(current_change_id) > 0),
kind TEXT NOT NULL CHECK (
kind IN (
'preference', 'working_assumption', 'constraint',
'decision', 'open_question', 'lesson'
)
),
state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')),
record_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (subject_id, memory_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, memory_id, current_change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id)
DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE memory_changes (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
previous_change_id TEXT,
kind TEXT NOT NULL CHECK (
kind IN (
'preference', 'working_assumption', 'constraint',
'decision', 'open_question', 'lesson'
)
),
state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')),
record_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, memory_id, change_id),
FOREIGN KEY (subject_id, memory_id)
REFERENCES memory_records(subject_id, memory_id)
ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
FOREIGN KEY (subject_id, memory_id, previous_change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id)
ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
CHECK (previous_change_id IS NULL OR previous_change_id <> change_id)
);
CREATE TABLE memory_change_candidates (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
candidate_id TEXT NOT NULL,
PRIMARY KEY (subject_id, memory_id, change_id, candidate_id),
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TABLE memory_change_derivations (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
source_memory_id TEXT NOT NULL,
source_change_id TEXT NOT NULL CHECK (length(source_change_id) > 0),
PRIMARY KEY (
subject_id, memory_id, change_id, source_memory_id, source_change_id
),
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, source_memory_id, source_change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE staging_resolutions (
subject_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
resolution_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (
action IN ('applied', 'discarded', 'invalid', 'duplicate', 'already_covered')
),
reason TEXT NOT NULL,
affected_refs_json TEXT NOT NULL,
staging_raw_json TEXT NOT NULL,
resolution_json TEXT NOT NULL,
resolved_at TEXT NOT NULL,
PRIMARY KEY (subject_id, candidate_id),
UNIQUE (resolution_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TABLE staging_resolution_targets (
subject_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
PRIMARY KEY (subject_id, candidate_id, memory_id, change_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE surface_snapshots (
subject_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
built_from_memory_fingerprint TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, snapshot_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
);
CREATE TABLE surface_snapshot_refs (
subject_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
PRIMARY KEY (subject_id, snapshot_id, memory_id),
FOREIGN KEY (subject_id, snapshot_id)
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE memory_change_seals (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
PRIMARY KEY (subject_id, memory_id, change_id),
FOREIGN KEY (subject_id, memory_id, change_id)
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE staging_resolution_seals (
subject_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
PRIMARY KEY (subject_id, candidate_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TABLE surface_snapshot_seals (
subject_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
PRIMARY KEY (subject_id, snapshot_id),
FOREIGN KEY (subject_id, snapshot_id)
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT
);
CREATE TRIGGER memory_change_candidates_no_late_insert
BEFORE INSERT ON memory_change_candidates
WHEN EXISTS (
SELECT 1 FROM memory_change_seals
WHERE subject_id = NEW.subject_id
AND memory_id = NEW.memory_id
AND change_id = NEW.change_id
) BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id evidence is sealed');
END;
CREATE TRIGGER memory_change_derivations_no_late_insert
BEFORE INSERT ON memory_change_derivations
WHEN EXISTS (
SELECT 1 FROM memory_change_seals
WHERE subject_id = NEW.subject_id
AND memory_id = NEW.memory_id
AND change_id = NEW.change_id
) BEGIN
SELECT RAISE(ABORT, 'subjektiv derivations are sealed');
END;
CREATE TRIGGER staging_resolution_targets_no_late_insert
BEFORE INSERT ON staging_resolution_targets
WHEN EXISTS (
SELECT 1 FROM staging_resolution_seals
WHERE subject_id = NEW.subject_id
AND candidate_id = NEW.candidate_id
) BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution targets are sealed');
END;
CREATE TRIGGER surface_snapshot_refs_no_late_insert
BEFORE INSERT ON surface_snapshot_refs
WHEN EXISTS (
SELECT 1 FROM surface_snapshot_seals
WHERE subject_id = NEW.subject_id
AND snapshot_id = NEW.snapshot_id
) BEGIN
SELECT RAISE(ABORT, 'subjektiv surface references are sealed');
END;
CREATE TRIGGER memory_change_seals_no_update
BEFORE UPDATE ON memory_change_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id seals are immutable');
END;
CREATE TRIGGER memory_change_seals_no_delete
BEFORE DELETE ON memory_change_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id seals are retained');
END;
CREATE TRIGGER staging_resolution_seals_no_update
BEFORE UPDATE ON staging_resolution_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution seals are immutable');
END;
CREATE TRIGGER staging_resolution_seals_no_delete
BEFORE DELETE ON staging_resolution_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution seals are retained');
END;
CREATE TRIGGER surface_snapshot_seals_no_update
BEFORE UPDATE ON surface_snapshot_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv snapshot seals are immutable');
END;
CREATE TRIGGER surface_snapshot_seals_no_delete
BEFORE DELETE ON surface_snapshot_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv snapshot seals are retained');
END;
CREATE TRIGGER memory_changes_no_update
BEFORE UPDATE ON memory_changes BEGIN
SELECT RAISE(ABORT, 'subjektiv memory changes are immutable');
END;
CREATE TRIGGER memory_changes_no_delete
BEFORE DELETE ON memory_changes BEGIN
SELECT RAISE(ABORT, 'subjektiv memory changes are retained');
END;
CREATE TRIGGER memory_change_candidates_no_update
BEFORE UPDATE ON memory_change_candidates BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id evidence is immutable');
END;
CREATE TRIGGER memory_change_candidates_no_delete
BEFORE DELETE ON memory_change_candidates BEGIN
SELECT RAISE(ABORT, 'subjektiv change_id evidence is retained');
END;
CREATE TRIGGER memory_change_derivations_no_update
BEFORE UPDATE ON memory_change_derivations BEGIN
SELECT RAISE(ABORT, 'subjektiv derivations are immutable');
END;
CREATE TRIGGER memory_change_derivations_no_delete
BEFORE DELETE ON memory_change_derivations BEGIN
SELECT RAISE(ABORT, 'subjektiv derivations are retained');
END;
CREATE TRIGGER staging_records_no_update
BEFORE UPDATE ON staging_records BEGIN
SELECT RAISE(ABORT, 'subjektiv staging records are immutable');
END;
CREATE TRIGGER staging_records_no_delete
BEFORE DELETE ON staging_records BEGIN
SELECT RAISE(ABORT, 'subjektiv staging records are retained');
END;
CREATE TRIGGER staging_resolutions_no_update
BEFORE UPDATE ON staging_resolutions BEGIN
SELECT RAISE(ABORT, 'subjektiv staging resolutions are immutable');
END;
CREATE TRIGGER staging_resolutions_no_delete
BEFORE DELETE ON staging_resolutions BEGIN
SELECT RAISE(ABORT, 'subjektiv staging resolutions are retained');
END;
CREATE TRIGGER staging_resolution_targets_no_update
BEFORE UPDATE ON staging_resolution_targets BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution targets are immutable');
END;
CREATE TRIGGER staging_resolution_targets_no_delete
BEFORE DELETE ON staging_resolution_targets BEGIN
SELECT RAISE(ABORT, 'subjektiv resolution targets are retained');
END;
CREATE TRIGGER surface_snapshots_no_update
BEFORE UPDATE ON surface_snapshots BEGIN
SELECT RAISE(ABORT, 'subjektiv surface snapshots are immutable');
END;
CREATE TRIGGER surface_snapshots_no_delete
BEFORE DELETE ON surface_snapshots BEGIN
SELECT RAISE(ABORT, 'subjektiv surface snapshots are retained');
END;
CREATE TRIGGER surface_snapshot_refs_no_update
BEFORE UPDATE ON surface_snapshot_refs BEGIN
SELECT RAISE(ABORT, 'subjektiv surface references are immutable');
END;
CREATE TRIGGER surface_snapshot_refs_no_delete
BEFORE DELETE ON surface_snapshot_refs BEGIN
SELECT RAISE(ABORT, 'subjektiv surface references are retained');
END;
CREATE TABLE subject_session_attributions (
session_id TEXT PRIMARY KEY,
subject_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
record_json TEXT NOT NULL,
attributed_at TEXT NOT NULL,
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
);
CREATE INDEX subject_session_attributions_by_subject
ON subject_session_attributions(subject_id, attributed_at, session_id);
CREATE TRIGGER subject_session_attributions_no_update
BEFORE UPDATE ON subject_session_attributions BEGIN
SELECT RAISE(ABORT, 'subjektiv session attribution is immutable');
END;
CREATE TRIGGER subject_session_attributions_no_delete
BEFORE DELETE ON subject_session_attributions BEGIN
SELECT RAISE(ABORT, 'subjektiv session attribution is retained');
END;
CREATE TABLE candidate_decision_receipts (
subject_id TEXT NOT NULL,
request_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
request_json TEXT NOT NULL,
receipt_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, request_id),
UNIQUE (subject_id, candidate_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TRIGGER candidate_decision_receipts_no_update
BEFORE UPDATE ON candidate_decision_receipts BEGIN
SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable');
END;
CREATE TRIGGER candidate_decision_receipts_no_delete
BEFORE DELETE ON candidate_decision_receipts BEGIN
SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained');
END;
CREATE TABLE surface_generation_state (
subject_id TEXT PRIMARY KEY,
memory_fingerprint TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')),
snapshot_id TEXT,
reason_code TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT,
FOREIGN KEY (subject_id, snapshot_id)
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT
);
CREATE TABLE surface_generation_runs (
subject_id TEXT NOT NULL,
generation_id TEXT NOT NULL,
memory_fingerprint TEXT NOT NULL,
active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0),
materials_json TEXT NOT NULL,
job_id TEXT,
attempt_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, generation_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
);
CREATE INDEX surface_generation_runs_by_change
ON surface_generation_runs(subject_id, memory_fingerprint, created_at, generation_id);
CREATE TRIGGER surface_generation_runs_no_update
BEFORE UPDATE ON surface_generation_runs BEGIN
SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable');
END;
CREATE TRIGGER surface_generation_runs_no_delete
BEFORE DELETE ON surface_generation_runs BEGIN
SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained');
END;
+175 -55
View File
@@ -88,8 +88,8 @@ pub enum TicketError {
InvalidReadWriteTargetCount(usize),
#[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"));
}
+7
View File
@@ -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
View File
@@ -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(
+2 -1
View File
@@ -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
View File
@@ -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();
+6 -5
View File
@@ -40,11 +40,12 @@ arguments; Backspace/Delete removes a chip atomically. Typed chips survive local
history, draft restoration, transport retry, and rewind.
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
View File
@@ -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(&current));
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(&current, "資料/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),
}),
+3 -4
View File
@@ -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),
+2 -10
View File
@@ -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
View File
@@ -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;
-2
View File
@@ -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(),
}),
+3 -2
View File
@@ -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
+14 -6
View File
@@ -212,17 +212,17 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
};
assert_eq!(result.entries[0].path, path("keep/yes.txt"));
// 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"),
+44 -3
View File
@@ -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(
+31 -3
View File
@@ -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")]
+44 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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)]
+6 -1
View File
@@ -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
View File
@@ -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());
}
}
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Refusal reasons are selected by providers, not parsed from their messages.
use std::sync::Arc;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
use workdir::{
LocalWorkdirSession, ReadOnlyWorkdirSession, StatRequest, Workdir,
WorkdirDenialReason as Reason, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirToolBroker, WorkdirToolScope,
WorkdirToolScopePermission as ToolPermission, WorkdirToolScopeRule, WriteRequest,
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
};
fn path(value: &str) -> WorkdirPath {
WorkdirPath::new(value).unwrap()
}
fn rule(target: &str, permission: ToolPermission) -> WorkdirToolScopeRule {
WorkdirToolScopeRule {
target: path(target),
permission,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}
}
fn scoped(target: &str, permission: ToolPermission) -> WorkdirToolScope {
WorkdirToolScope {
rules: vec![rule(target, permission)],
cwd: path(target),
command: false,
}
}
fn assert_reason(error: workdir::WorkdirError, reason: Reason) {
assert_eq!(error.denial_reason(), Some(reason), "{error}");
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code.http_status(), 403);
assert_eq!(transport.denial_reason, Some(reason));
let wire = serde_json::to_string(&transport).unwrap();
assert!(!wire.contains("private-input"));
let decoded: WorkdirTransportError = serde_json::from_str(&wire).unwrap();
assert_eq!(
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
transport
);
}
#[tokio::test]
async fn local_authorize_scope_refusals_distinguish_attachment_and_delegation() {
let root = tempfile::tempdir().unwrap();
let session = LocalWorkdirSession::materialized_bound(
Workdir::new("denials"),
root.path().to_owned(),
root.path().to_owned(),
SharedScope::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().join("allowed"),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}],
deny: vec![],
})
.unwrap(),
),
WorkdirSessionCapabilities::ALL,
);
for (rules, requested, reason) in [
(
vec![rule("", ToolPermission::Read)],
"private-input",
Reason::AttachmentScopeExceeded,
),
(
vec![rule("allowed/child", ToolPermission::Read)],
"allowed/private-input",
Reason::DelegatedScopeExceeded,
),
] {
let error = session
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules,
path: path(requested),
permission: ToolPermission::Read,
})
.await
.unwrap_err();
assert_reason(error, reason);
}
}
#[tokio::test]
async fn broker_scope_refusal_matrix_preserves_distinct_reasons() {
let root = tempfile::tempdir().unwrap();
let local = Arc::new(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let parent = WorkdirToolBroker::new(local);
let mut empty = scoped("", ToolPermission::Read);
empty.rules.clear();
let mut command = scoped("", ToolPermission::Read);
command.command = true;
let mut cwd = scoped("allowed", ToolPermission::Read);
cwd.cwd = path("private-input");
for (request, reason) in [
(empty, Reason::EmptyScope),
(command, Reason::CommandRequiresWritableScope),
(cwd, Reason::CwdOutsideReadableScope),
] {
assert_reason(parent.scope(request).await.unwrap_err(), reason);
}
let mut child_request = scoped("allowed", ToolPermission::Write);
child_request.rules[0].recursive = false;
let child = parent.scope(child_request).await.unwrap();
assert_reason(
child
.scope(scoped("private-input", ToolPermission::Read))
.await
.unwrap_err(),
Reason::ParentScopeExceeded,
);
assert_reason(
parent
.scope(scoped("allowed", ToolPermission::Write))
.await
.unwrap_err(),
Reason::ChildWriteLeaseConflict,
);
assert_reason(
parent
.write(WriteRequest {
path: path("allowed/private-input"),
content: "unused".into(),
expected_hash: None,
})
.await
.unwrap_err(),
Reason::ChildWriteLeaseConflict,
);
assert_reason(
child
.stat(StatRequest {
path: path("private-input/nested"),
})
.await
.unwrap_err(),
Reason::DelegatedScopeExceeded,
);
}
#[tokio::test]
async fn read_only_and_scoped_capability_refusals_do_not_collapse() {
let root = tempfile::tempdir().unwrap();
let local = Arc::new(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let parent = WorkdirToolBroker::new(local.clone());
let child = parent
.scope(scoped("", ToolPermission::Read))
.await
.unwrap();
let request = WriteRequest {
path: path("private-input"),
content: "unused".into(),
expected_hash: None,
};
assert_reason(
child
.start_command(workdir::CommandRequest {
command: "must-not-run".into(),
timeout_secs: 1,
output_limit: 1,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await
.unwrap_err(),
Reason::ScopedCapabilityDenied,
);
let readonly = ReadOnlyWorkdirSession::new(local);
assert_reason(
readonly.write(request).await.unwrap_err(),
Reason::ReadOnlySession,
);
let error = parent
.checkout_search(workdir::CheckoutSearchRequest {
operation: workdir::CheckoutSearchOperation::List(workdir::ListRequest {
path: path("private-input"),
limit: 10,
after: None,
}),
output_root: path("allowed"),
scope_layers: vec![],
})
.await
.unwrap_err();
assert_reason(error, Reason::CheckoutOutputRootExceeded);
}
#[test]
fn denial_wire_is_allowlisted_and_old_missing_reason_remains_unknown() {
let old: WorkdirTransportError =
serde_json::from_str(r#"{"code":"denied","message":"private-input secret"}"#).unwrap();
let error = old.into_workdir_error();
assert_eq!(error.denial_reason(), None);
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(forwarded.code, Code::Denied);
assert_eq!(forwarded.message, "Workdir operation was denied");
assert_eq!(
serde_json::to_value(forwarded).unwrap(),
serde_json::json!({"code":"denied", "message":"Workdir operation was denied"})
);
for reason in ["/host/private-input", "secret", &"x".repeat(8192)] {
let input =
serde_json::json!({"code":"denied", "message":"ignored", "denial_reason":reason});
assert!(serde_json::from_value::<WorkdirTransportError>(input).is_err());
}
}
// A provider with filesystem methods, but no provider-side scope resolver.
#[derive(Debug)]
struct UnresolvedProvider(LocalWorkdirSession);
#[async_trait::async_trait]
impl WorkdirSession for UnresolvedProvider {
fn workdir(&self) -> &Workdir {
self.0.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.0.capabilities()
}
async fn stat(&self, r: StatRequest) -> Result<workdir::StatResult, workdir::WorkdirError> {
self.0.stat(r).await
}
async fn read(
&self,
r: workdir::ReadRequest,
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
self.0.read(r).await
}
async fn write(&self, r: WriteRequest) -> Result<workdir::WriteResult, workdir::WorkdirError> {
self.0.write(r).await
}
async fn edit(
&self,
r: workdir::EditRequest,
) -> Result<workdir::EditResult, workdir::WorkdirError> {
self.0.edit(r).await
}
async fn list(
&self,
r: workdir::ListRequest,
) -> Result<workdir::ListResult, workdir::WorkdirError> {
self.0.list(r).await
}
async fn glob(
&self,
r: workdir::GlobRequest,
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
self.0.glob(r).await
}
async fn grep(
&self,
r: workdir::GrepRequest,
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
self.0.grep(r).await
}
async fn start_command(
&self,
r: workdir::CommandRequest,
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
self.0.start_command(r).await
}
async fn command_status(
&self,
h: workdir::CommandHandle,
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
self.0.command_status(h).await
}
async fn command_output(
&self,
r: workdir::CommandOutputRequest,
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
self.0.command_output(r).await
}
async fn cancel_command(&self, h: workdir::CommandHandle) -> Result<(), workdir::WorkdirError> {
self.0.cancel_command(h).await
}
async fn close(&self) -> Result<(), workdir::WorkdirError> {
self.0.close().await
}
}
#[tokio::test]
async fn providers_without_scope_resolution_fail_closed_with_specific_reasons() {
let root = tempfile::tempdir().unwrap();
let provider = UnresolvedProvider(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let rules = vec![rule("", ToolPermission::Read)];
assert_reason(
provider
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules: rules.clone(),
path: path("private-input"),
permission: ToolPermission::Read,
})
.await
.unwrap_err(),
Reason::ScopeResolutionUnavailable,
);
assert_reason(
provider
.scope_rules_overlap(workdir::WorkdirScopeOverlapRequest {
left: rules[0].clone(),
right: rules[0].clone(),
})
.await
.unwrap_err(),
Reason::ScopeComparisonUnavailable,
);
let mut logical = rules;
logical[0].symlink_policy = SymlinkPolicy::Logical;
provider
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules: logical,
path: path("private-input"),
permission: ToolPermission::Read,
})
.await
.unwrap();
}
+288
View File
@@ -0,0 +1,288 @@
use std::path::Path;
use fs_operation::{AtomicWriteMode, CheckedTarget, FsAccessPolicy, FsDenialReason, FsError};
use workdir::{
WorkdirDenialReason as Reason, WorkdirError,
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
};
fn assert_origin(source: std::io::Error, reason: Reason, message: &str) {
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
assert_eq!(source.raw_os_error(), None);
assert_eq!(source.to_string(), message);
assert_transport_reason(source, reason);
}
fn assert_transport_reason(source: std::io::Error, reason: Reason) {
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
assert!(matches!(&error, WorkdirError::Io { .. }));
assert_eq!(error.denial_reason(), Some(reason));
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, Code::Denied);
assert_eq!(transport.code.http_status(), 403);
assert_eq!(transport.message, "Workdir operation was denied");
let wire = serde_json::to_value(&transport).unwrap();
assert_eq!(wire["denial_reason"], reason.as_str());
assert!(!wire.to_string().contains("private-input"));
assert!(!wire.to_string().contains("secret"));
let decoded: WorkdirTransportError = serde_json::from_value(wire).unwrap();
assert_eq!(
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
transport
);
}
#[test]
fn synthetic_fs_origins_survive_fs_workdir_and_transport_conversion() {
for (origin, reason, message) in [
(
FsDenialReason::ProviderRootExceeded,
Reason::ProviderRootExceeded,
"path is outside provider root",
),
(
FsDenialReason::ProviderSymlinkDenied,
Reason::ProviderSymlinkDenied,
"symbolic links are not permitted by this provider",
),
(
FsDenialReason::CheckoutTargetDenied,
Reason::CheckoutTargetDenied,
"checkout target denied",
),
(
FsDenialReason::CheckoutCreateParentDenied,
Reason::CheckoutCreateParentDenied,
"Create parent denied",
),
] {
assert_origin(origin.into_io_error(), reason, message);
}
}
#[test]
fn custom_permission_denied_is_not_guessed_from_kind_or_message() {
for message in [
"Permission denied (os error 13)",
"checkout target denied",
"/private-input/secret",
] {
assert_transport_reason(
std::io::Error::new(std::io::ErrorKind::PermissionDenied, message),
Reason::UnclassifiedPermissionDenied,
);
}
}
#[cfg(unix)]
#[test]
fn raw_os_permission_denied_retains_errno_and_os_origin() {
for errno in [libc::EACCES, libc::EPERM] {
let source = std::io::Error::from_raw_os_error(errno);
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
let WorkdirError::Io { source, .. } = &error else {
panic!("classification changed")
};
assert_eq!(source.raw_os_error(), Some(errno));
assert_transport_reason(
std::io::Error::from_raw_os_error(errno),
Reason::OsPermissionDenied,
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn actual_confined_open_and_write_refusals_keep_distinct_origins() {
use fs_operation::{
atomic_write_beneath_no_symlinks_at, open_beneath_no_symlinks, open_beneath_no_symlinks_at,
open_root_no_symlinks,
};
let root = tempfile::tempdir().unwrap();
let directory = open_root_no_symlinks(root.path()).unwrap();
let outside = root.path().parent().unwrap().join("private-input");
for source in [
open_beneath_no_symlinks_at(&directory, &outside).unwrap_err(),
open_beneath_no_symlinks(root.path(), &outside).unwrap_err(),
atomic_write_beneath_no_symlinks_at(
&directory,
&outside,
b"unused",
AtomicWriteMode::CreateNew,
|| Ok(()),
)
.unwrap_err(),
atomic_write_beneath_no_symlinks_at(
&directory,
Path::new("../private-input"),
b"unused",
AtomicWriteMode::CreateNew,
|| Ok(()),
)
.unwrap_err(),
] {
assert_origin(
source,
Reason::ProviderRootExceeded,
"path is outside provider root",
);
}
std::fs::write(root.path().join("file"), "secret").unwrap();
std::os::unix::fs::symlink("file", root.path().join("link")).unwrap();
assert_origin(
open_beneath_no_symlinks_at(&directory, Path::new("link")).unwrap_err(),
Reason::ProviderSymlinkDenied,
"symbolic links are not permitted by this provider",
);
}
struct ExactWrite<'a>(&'a Path);
impl FsAccessPolicy for ExactWrite<'_> {
fn is_readable(&self, _: &Path) -> bool {
true
}
fn is_writable(&self, path: &Path) -> bool {
path == self.0
}
}
#[cfg(target_os = "linux")]
#[test]
fn actual_checked_target_and_create_parent_refusals_keep_distinct_origins() {
let root = tempfile::tempdir().unwrap();
let directory = fs_operation::open_root_no_symlinks(root.path()).unwrap();
let destination = root.path().join("parent/file");
let policy = ExactWrite(&destination);
let source = CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, true)
.err()
.expect("target policy must reject");
assert_origin(
source,
Reason::CheckoutTargetDenied,
"checkout target denied",
);
let target =
CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, false).unwrap();
let create = target.create_access("parent/file".into(), destination.clone());
let error = fs_operation::run_write(
root.path(),
fs_operation::WriteRequest {
path: fs_operation::FsPath::new("parent/file").unwrap(),
content: "unused".into(),
expected_hash: None,
},
&create,
)
.unwrap_err();
let FsError::Io { source, .. } = error else {
panic!("expected parent policy denial")
};
assert_origin(
source,
Reason::CheckoutCreateParentDenied,
"Create parent denied",
);
assert!(!root.path().join("parent").exists());
}
#[test]
fn every_transport_code_forwards_supplied_reason_without_reclassification() {
// JSON is the provider boundary. An optional reason is independent of code/status.
for code in [
"not_found",
"conflict",
"outcome_unknown",
"unsupported",
"invalid_request",
"denied",
"out_of_scope",
"symlink_out_of_scope",
"broken_symlink",
"symlink_target_is_directory",
"read_only",
"is_directory",
"symlink_directory_not_traversed",
"unknown_command",
"unavailable",
"io",
"transport",
"internal",
] {
let decode = |reason| {
serde_json::from_value::<WorkdirTransportError>(serde_json::json!({
"code": code, "message": "private-input secret", "denial_reason": reason,
}))
.unwrap()
.into_workdir_error()
};
let baseline_error = decode(None::<Reason>);
let baseline = WorkdirTransportError::from_workdir_error(&baseline_error);
let error = decode(Some(Reason::CheckoutTargetDenied));
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(
error.denial_reason(),
Some(Reason::CheckoutTargetDenied),
"{code}"
);
assert_eq!(forwarded.code, baseline.code, "{code}");
assert_eq!(
forwarded.code.http_status(),
baseline.code.http_status(),
"{code}"
);
assert_eq!(forwarded.message, baseline.message, "{code}");
assert_eq!(error.to_string(), baseline_error.to_string(), "{code}");
// Missing provider reasons retain the deliberate existing fallback: canonical
// path classifications supply their known reason; all other codes stay unknown.
assert_eq!(
baseline_error.denial_reason(),
match code {
"out_of_scope" => Some(Reason::PathOutOfScope),
"symlink_out_of_scope" => Some(Reason::SymlinkTargetOutOfScope),
"read_only" => Some(Reason::PathReadOnly),
_ => None,
},
"{code}"
);
}
}
#[test]
fn diagnostic_labels_match_allowlisted_wire_values() {
for label in [
"scope_resolution_unavailable",
"scope_comparison_unavailable",
"scoped_capability_denied",
"logical_scope_exceeded",
"invalid_scoped_path",
"child_write_lease_conflict",
"empty_scope",
"parent_capability_denied",
"command_requires_writable_scope",
"parent_scope_exceeded",
"cwd_outside_readable_scope",
"read_only_session",
"external_root_symlink",
"external_root_changed",
"external_root_moved",
"external_scope_symlink",
"attachment_scope_exceeded",
"delegated_scope_exceeded",
"checkout_output_root_exceeded",
"provider_root_exceeded",
"provider_symlink_denied",
"checkout_target_denied",
"checkout_create_parent_denied",
"checkout_search_path_denied",
"checkout_enumeration_denied",
"path_out_of_scope",
"symlink_target_out_of_scope",
"path_read_only",
"os_permission_denied",
"unclassified_permission_denied",
] {
let reason: Reason = serde_json::from_value(serde_json::json!(label)).unwrap();
assert_eq!(reason.as_str(), label);
assert_eq!(serde_json::to_value(reason).unwrap(), label);
}
}
@@ -0,0 +1,218 @@
#![cfg(feature = "http-client")]
//! Loopback fake at the HTTP provider boundary; no product process or live service.
use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
thread,
time::{Duration, Instant},
};
use workdir::{
StatRequest, Workdir, WorkdirDenialReason as Reason, WorkdirPath, WorkdirSession,
http::{
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirTransportError,
WorkdirTransportErrorCode as Code,
},
};
fn reply(listener: &TcpListener, status: &str, body: &str) {
listener.set_nonblocking(true).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let (mut stream, _) = loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(Instant::now() < deadline, "fake provider accept timed out");
thread::yield_now();
}
Err(error) => panic!("fake provider accept failed: {error}"),
}
};
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
read_request(&mut stream);
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
}
fn read_request(stream: &mut TcpStream) {
let mut bytes = Vec::new();
let mut byte = [0];
while !bytes.ends_with(b"\r\n\r\n") {
stream.read_exact(&mut byte).unwrap();
bytes.push(byte[0]);
assert!(bytes.len() < 16384);
}
let headers = String::from_utf8(bytes).unwrap();
let length: usize = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().unwrap())
})
.unwrap_or(0);
assert!(length < 16384);
stream.read_exact(&mut vec![0; length]).unwrap();
}
#[tokio::test]
async fn remote_http_denials_forward_only_typed_reasons_and_old_missing_reason() {
for (code, reason) in [
(Code::Denied, Some(Reason::ReadOnlySession)),
(Code::Denied, Some(Reason::OsPermissionDenied)),
(Code::OutOfScope, Some(Reason::OsPermissionDenied)),
(Code::Denied, None),
] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
// Host paths and secrets in provider-authored text must not become diagnostics.
let mut error = serde_json::json!({"code":code, "message":"/secret/host/root bearer-secret command-secret"});
if let Some(reason) = reason {
error["denial_reason"] = serde_json::to_value(reason).unwrap();
}
let server = thread::spawn(move || {
reply(
&listener,
"200 OK",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
reply(&listener, "403 Forbidden", &error.to_string());
});
let remote = RemoteWorkdirSession::open(
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
format!("http://{address}").parse().unwrap(),
"request-secret",
Workdir::new("w").id().clone(),
OpenWorkdirSessionRequest::default(),
)
.await
.unwrap();
let denied = remote
.stat(StatRequest {
path: WorkdirPath::new("file").unwrap(),
})
.await
.unwrap_err();
assert_eq!(denied.denial_reason(), reason);
assert!(!denied.to_string().contains("secret"));
let forwarded = WorkdirTransportError::from_workdir_error(&denied);
assert_eq!(forwarded.denial_reason, reason);
assert_eq!(forwarded.code, code);
assert_eq!(forwarded.code.http_status(), 403);
assert_eq!(
forwarded.message,
if code == Code::Denied {
"Workdir operation was denied"
} else {
"Workdir path is out of scope"
}
);
let wire = serde_json::to_string(&forwarded).unwrap();
assert!(!wire.contains("secret"));
assert!(wire.len() < 256);
server.join().unwrap();
}
}
#[tokio::test]
async fn remote_checkout_diagnostics_preserve_mutation_outcome_and_read_failures() {
use workdir::{CheckoutOperation as Op, CheckoutRequest, WorkdirSessionCapability};
for code in [
Code::Unavailable,
Code::Transport,
Code::Denied,
Code::Conflict,
Code::OutcomeUnknown,
] {
for reason in [None, Some(Reason::ReadOnlySession)] {
for operation in [
Op::Read {
offset: 0,
limit: 1,
max_bytes: 10,
},
Op::Create {
path: WorkdirPath::new("new.txt").unwrap(),
content: b"fixture".to_vec(),
},
Op::Write {
content: b"fixture".to_vec(),
expected_hash: [0; 32],
},
Op::Edit {
old_string: "old".into(),
new_string: "new".into(),
replace_all: false,
expected_hash: [0; 32],
},
] {
let mutation = operation.capability() != WorkdirSessionCapability::Read;
let expected_code =
if mutation && matches!(code, Code::Unavailable | Code::Transport) {
Code::OutcomeUnknown
} else {
code
};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let error = serde_json::json!({"code":code, "message":"/secret/host provider-secret", "denial_reason":reason});
let server = thread::spawn(move || {
reply(
&listener,
"200 OK",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
reply(
&listener,
&format!("{} Fixture failure", code.http_status()),
&error.to_string(),
);
});
let remote = RemoteWorkdirSession::open(
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
format!("http://{address}").parse().unwrap(),
"request-secret",
Workdir::new("w").id().clone(),
OpenWorkdirSessionRequest::default(),
)
.await
.unwrap();
let error = remote
.checkout_execute(CheckoutRequest {
target: WorkdirPath::root(),
validator: vec![1],
operation,
})
.await
.unwrap_err();
server.join().unwrap();
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(
forwarded.code, expected_code,
"{code:?}, {reason:?}, mutation={mutation}: {error:?}"
);
assert_eq!(error.denial_reason(), reason);
assert_eq!(forwarded.denial_reason, reason);
assert_eq!(forwarded.code.http_status(), expected_code.http_status());
if expected_code == Code::OutcomeUnknown {
assert!(forwarded.message.contains("inspect before retrying"));
}
assert!(
!serde_json::to_string(&forwarded)
.unwrap()
.contains("secret")
);
}
}
}
}
+3 -6
View File
@@ -75,7 +75,6 @@ pub struct WorkingDirectoryRepository {
pub id: String,
pub 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,
}
+3 -22
View File
@@ -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,
+1 -1
View File
@@ -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,
+284 -19
View File
@@ -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);
}
+594 -87
View File
@@ -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,
+71 -9
View File
@@ -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
+3 -8
View File
@@ -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(),
-8
View File
@@ -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(),
}
+83 -63
View File
@@ -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();
+7 -32
View File
@@ -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
+5 -4
View File
@@ -68,13 +68,14 @@ commands and generic POSIX operations are not exposed. The adapter uses the type
an owner config API as a bypass.
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
+3 -3
View File
@@ -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