feat: replace revision counters with domain state contracts
Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts. Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
This commit is contained in:
Generated
+2
@@ -2754,6 +2754,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -4788,6 +4789,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"server-api",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
|
||||
+1
-1
@@ -172,5 +172,5 @@ webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation
|
||||
[workspace.metadata.wip-upstream]
|
||||
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
||||
version = "0.2.0"
|
||||
reference-revision = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
|
||||
reference-commit = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
|
||||
source = "crates.io"
|
||||
|
||||
@@ -3,11 +3,11 @@ use server_api::{
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery,
|
||||
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse,
|
||||
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse,
|
||||
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
RuntimeTrustKeyRevealResponse, SubjektivMemoryChangesQuery, SubjektivMemoryDetailQuery,
|
||||
SubjektivMemoryListChangesResponse, SubjektivMemoryListQuery, SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse, SubjektivResidentSurfaceResponse, SubjektivSubjectListQuery,
|
||||
SubjektivSubjectListResponse, SubjektivSubjectResponse, WorkerLaunchOptionsResponse,
|
||||
WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
};
|
||||
use ticket::{
|
||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
@@ -168,7 +168,7 @@ impl BackendWorkspaceProductClient {
|
||||
};
|
||||
Ok(ticket::TicketStateUpdate {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_item_revision: ticket::ticket_item_revision(¤t),
|
||||
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: parse(&change.from)?,
|
||||
state: parse(&change.to)?,
|
||||
reason: if change.body.as_str().is_empty() {
|
||||
@@ -208,7 +208,7 @@ impl BackendWorkspaceProductClient {
|
||||
let current = self.show_ticket(id)?;
|
||||
let request = ticket::TicketCompletion {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_item_revision: ticket::ticket_item_revision(¤t),
|
||||
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: current.meta.workflow_state,
|
||||
reason: resolution.as_str().to_string(),
|
||||
references: Vec::new(),
|
||||
@@ -521,19 +521,19 @@ impl BackendWorkspaceProductClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_memory_revisions(
|
||||
pub fn list_subjektiv_memory_changes(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
memory_id: &str,
|
||||
query: &SubjektivMemoryRevisionsQuery,
|
||||
) -> Result<SubjektivMemoryListRevisionsResponse, BackendWorkspaceClientError> {
|
||||
query: &SubjektivMemoryChangesQuery,
|
||||
) -> Result<SubjektivMemoryListChangesResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let memory_id = memory_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query)
|
||||
.subjektiv_memory_changes(workspace_id, subject_id, memory_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
@@ -997,7 +997,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_adapter_uses_snapshot_revision_without_overriding_stale_expected_state() {
|
||||
fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
@@ -1021,8 +1021,8 @@ mod tests {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_item_revision"],
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "ready");
|
||||
assert_eq!(body["state"], "done");
|
||||
@@ -1057,8 +1057,8 @@ mod tests {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_item_revision"],
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "The request was withdrawn");
|
||||
@@ -1070,7 +1070,7 @@ mod tests {
|
||||
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
|
||||
let (base_url, request, handle) = one_response_server(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale revision","diagnostics":[]}"#,
|
||||
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
|
||||
);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
@@ -1083,7 +1083,7 @@ mod tests {
|
||||
"T-718",
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "research-done".into(),
|
||||
expected_item_revision: "ticket:1".into(),
|
||||
expected_content_digest: "ticket:1".into(),
|
||||
expected_state: ticket::TicketWorkflowState::Planning,
|
||||
reason: "Research findings recorded".into(),
|
||||
references: Vec::new(),
|
||||
@@ -1100,7 +1100,7 @@ mod tests {
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["state"], "done");
|
||||
assert_eq!(body["operation_key"], "research-done");
|
||||
assert_eq!(body["expected_item_revision"], "ticket:1");
|
||||
assert_eq!(body["expected_content_digest"], "ticket:1");
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "Research findings recorded");
|
||||
assert!(body.get("merge_request_ids").is_none());
|
||||
|
||||
@@ -230,9 +230,7 @@ mod tests {
|
||||
use config_source::{ConfigContentType, ConfigEntry};
|
||||
|
||||
fn snapshot() -> ConfigTreeSnapshot {
|
||||
ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[
|
||||
ConfigTreeSnapshot::from_entries([
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
@@ -246,11 +244,37 @@ mod tests {
|
||||
"{}",
|
||||
)
|
||||
.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, };
|
||||
+313
-138
@@ -27,9 +27,16 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
|
||||
pub const MAX_PATH_BYTES: usize = 512;
|
||||
pub const MAX_IMPORT_DEPTH: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, ts_rs::TS)]
|
||||
pub struct VirtualPath(String);
|
||||
|
||||
impl<'de> Deserialize<'de> for VirtualPath {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::parse(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualPath {
|
||||
pub fn parse(value: impl AsRef<str>) -> Result<Self, ConfigTreeError> {
|
||||
let value = value.as_ref();
|
||||
@@ -267,27 +274,46 @@ impl ConfigEntry {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ts_rs::TS)]
|
||||
pub struct ConfigTreeSnapshot {
|
||||
#[ts(type = "number")]
|
||||
pub revision: u64,
|
||||
/// SHA-256 of ordered virtual paths, content types and complete source bytes.
|
||||
/// Identifies tree content, not an edit sequence or freshness counter.
|
||||
pub digest: String,
|
||||
pub entries: BTreeMap<VirtualPath, ConfigEntry>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ConfigTreeSnapshot {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
struct SnapshotContent {
|
||||
digest: String,
|
||||
entries: BTreeMap<VirtualPath, ConfigEntry>,
|
||||
}
|
||||
let content = SnapshotContent::deserialize(deserializer)?;
|
||||
let snapshot = Self {
|
||||
digest: content.digest,
|
||||
entries: content.entries,
|
||||
};
|
||||
snapshot.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigTreeSnapshot {
|
||||
pub fn empty() -> Self {
|
||||
Self::from_entries(0, Vec::new()).expect("empty config snapshot is valid")
|
||||
Self::from_entries(Vec::new()).expect("empty config snapshot is valid")
|
||||
}
|
||||
|
||||
pub fn from_entries(
|
||||
revision: u64,
|
||||
entries: impl IntoIterator<Item = ConfigEntry>,
|
||||
) -> Result<Self, ConfigTreeError> {
|
||||
let mut ordered = BTreeMap::new();
|
||||
let mut total = 0usize;
|
||||
for entry in entries {
|
||||
validate_workspace_path(&entry.path)?;
|
||||
if digest_bytes(entry.content.as_bytes()) != entry.content_digest {
|
||||
return Err(ConfigTreeError::EntryDigestMismatch(entry.path));
|
||||
}
|
||||
if entry.content.len() > MAX_ENTRY_BYTES {
|
||||
return Err(ConfigTreeError::LimitExceeded("entry bytes"));
|
||||
}
|
||||
@@ -306,12 +332,25 @@ impl ConfigTreeSnapshot {
|
||||
}
|
||||
let digest = snapshot_digest(&ordered);
|
||||
Ok(Self {
|
||||
revision,
|
||||
digest,
|
||||
entries: ordered,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify external snapshot metadata against the complete source content.
|
||||
pub fn validate(&self) -> Result<(), ConfigTreeError> {
|
||||
for (path, entry) in &self.entries {
|
||||
if path != &entry.path {
|
||||
return Err(ConfigTreeError::EntryPathMismatch(path.clone()));
|
||||
}
|
||||
}
|
||||
let rebuilt = Self::from_entries(self.entries.values().cloned())?;
|
||||
if self.digest != rebuilt.digest {
|
||||
return Err(ConfigTreeError::TreeDigestMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_prefix(&self, prefix: Option<&VirtualPath>) -> Vec<&ConfigEntry> {
|
||||
self.entries
|
||||
.values()
|
||||
@@ -364,7 +403,11 @@ impl ConfigTreeSnapshot {
|
||||
changes
|
||||
}
|
||||
|
||||
/// Apply an atomic batch. Each expected_digest compares the target's complete
|
||||
/// source bytes only; it is not a tree precondition. Callers needing whole-tree
|
||||
/// CAS must also compare the authoritative tree digest under their write lock.
|
||||
pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result<Self, ConfigTreeError> {
|
||||
self.validate()?;
|
||||
if changes.len() > MAX_CHANGE_COUNT {
|
||||
return Err(ConfigTreeError::LimitExceeded("change count"));
|
||||
}
|
||||
@@ -440,7 +483,7 @@ impl ConfigTreeSnapshot {
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::from_entries(self.revision, entries.into_values())
|
||||
Self::from_entries(entries.into_values())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,8 +831,6 @@ pub struct ConfigDiagnosticLabel {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
|
||||
pub struct ConfigDiagnostic {
|
||||
pub path: VirtualPath,
|
||||
#[ts(type = "number")]
|
||||
pub revision: u64,
|
||||
pub tree_digest: String,
|
||||
pub kind: String,
|
||||
pub span: ConfigSpan,
|
||||
@@ -1436,7 +1477,6 @@ impl SnapshotEnvironment {
|
||||
) -> ConfigDiagnostic {
|
||||
ConfigDiagnostic {
|
||||
path,
|
||||
revision: self.snapshot.revision,
|
||||
tree_digest: self.snapshot.digest.clone(),
|
||||
kind: kind.into(),
|
||||
span: ConfigSpan {
|
||||
@@ -1572,13 +1612,39 @@ impl SnapshotImportLoader {
|
||||
import_edges: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
/// Translate an Engine cache identity back to its actual virtual source path.
|
||||
/// Only exact keys produced by this loader are recognized; ordinary source
|
||||
/// paths (including paths containing '@') are not split heuristically.
|
||||
fn source_path(&self, key: &str) -> Result<VirtualPath, ConfigTreeError> {
|
||||
if let Some(entry) = self
|
||||
.snapshot
|
||||
.entries
|
||||
.values()
|
||||
.find(|entry| snapshot_import_cache_key(entry) == key)
|
||||
{
|
||||
return Ok(entry.path.clone());
|
||||
}
|
||||
for resource in BUILTIN_PROFILE_RESOURCES {
|
||||
let path = format!("$builtin/{}", resource.path);
|
||||
if key
|
||||
== format!(
|
||||
"config-source://{path}@{}",
|
||||
digest_bytes(resource.source.as_bytes())
|
||||
)
|
||||
{
|
||||
return VirtualPath::parse(path);
|
||||
}
|
||||
}
|
||||
VirtualPath::parse(key)
|
||||
}
|
||||
|
||||
pub fn resolve(
|
||||
&self,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> Result<VirtualPath, ConfigTreeError> {
|
||||
let current = current_key
|
||||
.map(VirtualPath::parse)
|
||||
.map(|key| self.source_path(key))
|
||||
.transpose()?
|
||||
.ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?;
|
||||
resolve_import(¤t, specifier)
|
||||
@@ -1594,11 +1660,10 @@ impl ImportLoader for SnapshotImportLoader {
|
||||
let path = self.resolve(current_key, specifier).map_err(import_error)?;
|
||||
// Guard the import graph before returning a module to Decodal. In particular,
|
||||
// cycles hidden inside lazy object fields must not recurse during materialization.
|
||||
let current = current_key
|
||||
.expect("resolve requires a source context")
|
||||
.split("@sha256:")
|
||||
.next()
|
||||
.unwrap();
|
||||
let current = self
|
||||
.source_path(current_key.expect("resolve requires a source context"))
|
||||
.map_err(import_error)?
|
||||
.to_string();
|
||||
if self
|
||||
.import_edges
|
||||
.entry(current.to_string())
|
||||
@@ -1614,7 +1679,7 @@ impl ImportLoader for SnapshotImportLoader {
|
||||
// A language service can keep using its loader after a diagnostic.
|
||||
// Only successful edges may be cached as already validated.
|
||||
self.import_edges
|
||||
.get_mut(current)
|
||||
.get_mut(¤t)
|
||||
.unwrap()
|
||||
.remove(path.as_str());
|
||||
return Err(diagnostic);
|
||||
@@ -1651,7 +1716,10 @@ impl ImportLoader for SnapshotImportLoader {
|
||||
return Err(import_error(ConfigTreeError::LimitExceeded("total bytes")));
|
||||
}
|
||||
return Ok(LoadedImport::source(
|
||||
format!("{path}@{}", digest_bytes(resource.source.as_bytes())),
|
||||
format!(
|
||||
"config-source://{path}@{}",
|
||||
digest_bytes(resource.source.as_bytes())
|
||||
),
|
||||
path.as_str(),
|
||||
resource.source,
|
||||
));
|
||||
@@ -1747,9 +1815,16 @@ fn validate_import_graph(
|
||||
|
||||
fn snapshot_import_cache_key(entry: &ConfigEntry) -> String {
|
||||
// The source id remains the virtual path for diagnostics and relative-import
|
||||
// resolution. The cache identity also includes immutable source content so
|
||||
// equal paths from different revisions cannot alias in an Engine cache.
|
||||
format!("{}@{}", entry.path, entry.content_digest)
|
||||
// resolution. Cache identity binds path, import interpretation and source bytes,
|
||||
// so changed content or content type cannot alias in an Engine cache.
|
||||
// The URI-like prefix is rejected by VirtualPath, keeping engine identities
|
||||
// disjoint from literal paths even when a path spells another entry's digest.
|
||||
format!(
|
||||
"config-source://{}@{}@{}",
|
||||
entry.path,
|
||||
entry.content_type.media_type(),
|
||||
entry.content_digest
|
||||
)
|
||||
}
|
||||
|
||||
fn is_builtin_path(path: &VirtualPath) -> bool {
|
||||
@@ -1881,7 +1956,6 @@ fn project_engine_diagnostic(
|
||||
.unwrap_or(fallback_path);
|
||||
ConfigDiagnostic {
|
||||
path,
|
||||
revision: snapshot.revision,
|
||||
tree_digest: snapshot.digest.clone(),
|
||||
span: ConfigSpan {
|
||||
start_byte: diagnostic.span.start,
|
||||
@@ -1911,7 +1985,6 @@ fn project_diagnostic(
|
||||
) -> ConfigDiagnostic {
|
||||
ConfigDiagnostic {
|
||||
path: fallback_path,
|
||||
revision: snapshot.revision,
|
||||
tree_digest: snapshot.digest.clone(),
|
||||
kind: diagnostic_kind(diagnostic.kind).to_string(),
|
||||
span: ConfigSpan {
|
||||
@@ -2174,6 +2247,12 @@ pub enum ConfigTreeError {
|
||||
NotFound(VirtualPath),
|
||||
#[error("virtual config entry changed: {0}")]
|
||||
EntryConflict(VirtualPath),
|
||||
#[error("virtual config entry content digest mismatch: {0}")]
|
||||
EntryDigestMismatch(VirtualPath),
|
||||
#[error("virtual config entry path does not match its tree key: {0}")]
|
||||
EntryPathMismatch(VirtualPath),
|
||||
#[error("virtual config tree content digest mismatch")]
|
||||
TreeDigestMismatch,
|
||||
#[error("virtual config path changed more than once in one candidate: {0}")]
|
||||
PathChangedMoreThanOnce(VirtualPath),
|
||||
#[error("duplicate virtual config path")]
|
||||
@@ -2254,13 +2333,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
|
||||
let base = ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[
|
||||
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(
|
||||
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,
|
||||
[
|
||||
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,
|
||||
[
|
||||
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(
|
||||
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(
|
||||
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,10 +3540,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[entry("main.dcdl", "{ web = { enabled = 1; }; }")],
|
||||
)
|
||||
let snapshot =
|
||||
ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = { enabled = 1; }; }")])
|
||||
.unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin: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,
|
||||
[
|
||||
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,
|
||||
[
|
||||
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(
|
||||
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,
|
||||
[
|
||||
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,
|
||||
[
|
||||
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,
|
||||
[
|
||||
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,
|
||||
[
|
||||
let cycle = ConfigTreeSnapshot::from_entries([
|
||||
entry("a.dcdl", r#"import "./b.dcdl""#),
|
||||
entry("b.dcdl", r#"import "./a.dcdl""#),
|
||||
],
|
||||
)
|
||||
])
|
||||
.unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(cycle)
|
||||
.evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1))
|
||||
|
||||
@@ -7,9 +7,6 @@ const CODER_REVIEW_FLOW_SOURCE: &str = include_str!("../../../resources/flows/co
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuiltinFlowSource {
|
||||
pub slug: &'static str,
|
||||
/// Monotonic resource revision. Increment when built-in semantics change;
|
||||
/// Runtime also pins the compiled content digest.
|
||||
pub revision: u64,
|
||||
pub path: &'static str,
|
||||
pub content: &'static str,
|
||||
}
|
||||
@@ -24,7 +21,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
match slug {
|
||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 5,
|
||||
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}),
|
||||
@@ -35,7 +32,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 5,
|
||||
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}];
|
||||
@@ -64,7 +61,6 @@ mod tests {
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReview",
|
||||
"MergeRequestComplete",
|
||||
"new immutable revision",
|
||||
] {
|
||||
assert!(!source.content.contains(stale), "stale contract {stale}");
|
||||
}
|
||||
@@ -74,10 +70,6 @@ mod tests {
|
||||
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
||||
assert!(!builtin_flow_sources().is_empty());
|
||||
for source in builtin_flow_sources() {
|
||||
assert!(
|
||||
source.revision > 0,
|
||||
"built-in Flow revision must be positive"
|
||||
);
|
||||
let definition = source.compile().unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"builtin Flow {} failed to compile: {:?}",
|
||||
@@ -88,7 +80,6 @@ mod tests {
|
||||
let selected =
|
||||
builtin_flow_source(source.slug).expect("builtin Flow must be selectable");
|
||||
assert_eq!(selected.content, source.content);
|
||||
assert_eq!(selected.revision, source.revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+126
-56
@@ -21,10 +21,8 @@ pub enum FlowInstanceStatus {
|
||||
pub struct FlowInstance {
|
||||
pub instance_id: String,
|
||||
pub definition_id: String,
|
||||
pub definition_revision: u64,
|
||||
pub definition_digest: String,
|
||||
pub current_state: StateId,
|
||||
pub state_revision: u64,
|
||||
pub status: FlowInstanceStatus,
|
||||
pub active_attempt_id: Option<String>,
|
||||
}
|
||||
@@ -33,7 +31,6 @@ impl FlowInstance {
|
||||
pub fn start(
|
||||
instance_id: impl Into<String>,
|
||||
definition_id: impl Into<String>,
|
||||
definition_revision: u64,
|
||||
definition: &CompiledFlowDefinition,
|
||||
) -> Result<Self, FlowTransitionError> {
|
||||
let instance_id = instance_id.into();
|
||||
@@ -41,11 +38,6 @@ impl FlowInstance {
|
||||
ensure_non_empty("instance_id", &instance_id)
|
||||
.and_then(|_| ensure_non_empty("definition_id", &definition_id))
|
||||
.map_err(FlowTransitionError::InvalidRequest)?;
|
||||
if definition_revision == 0 {
|
||||
return Err(FlowTransitionError::InvalidRequest(
|
||||
"definition_revision must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
let initial_state = definition.state(&definition.initial).ok_or_else(|| {
|
||||
FlowTransitionError::Invariant("compiled initial state is missing".to_string())
|
||||
})?;
|
||||
@@ -57,10 +49,8 @@ impl FlowInstance {
|
||||
Ok(Self {
|
||||
instance_id,
|
||||
definition_id,
|
||||
definition_revision,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status,
|
||||
active_attempt_id: None,
|
||||
})
|
||||
@@ -77,9 +67,7 @@ pub struct FlowTransitionRequest {
|
||||
pub struct FlowTransitionAttempt {
|
||||
pub attempt_id: String,
|
||||
pub instance_id: String,
|
||||
pub definition_revision: u64,
|
||||
pub definition_digest: String,
|
||||
pub checked_state_revision: u64,
|
||||
pub from_state: StateId,
|
||||
pub reason: String,
|
||||
pub transitions: Vec<TransitionCheckSnapshot>,
|
||||
@@ -174,7 +162,6 @@ pub enum FlowEventKind {
|
||||
TransitionRequested {
|
||||
attempt_id: String,
|
||||
state_id: StateId,
|
||||
state_revision: u64,
|
||||
reason: String,
|
||||
transitions: Vec<TransitionCheckSnapshot>,
|
||||
},
|
||||
@@ -201,7 +188,6 @@ pub enum FlowEventKind {
|
||||
StateEntered {
|
||||
attempt_id: String,
|
||||
state_id: StateId,
|
||||
state_revision: u64,
|
||||
status: FlowInstanceStatus,
|
||||
},
|
||||
}
|
||||
@@ -214,7 +200,7 @@ pub struct FlowRuntimeEvent {
|
||||
|
||||
/// Durable Flow authority owned by one Runtime Worker.
|
||||
///
|
||||
/// Workspace authority resolves and revisions the source, but never mutates
|
||||
/// Workspace authority resolves the exact source content, but never mutates
|
||||
/// this value. Runtime persists the complete snapshot with the Worker session
|
||||
/// and replaces it only after the corresponding session-log write succeeds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -240,12 +226,8 @@ impl FlowRuntimeState {
|
||||
"resolved Flow selector does not match compiled definition name".to_string(),
|
||||
));
|
||||
}
|
||||
let instance = FlowInstance::start(
|
||||
instance_id,
|
||||
source.flow_id.clone(),
|
||||
source.revision,
|
||||
&source.definition,
|
||||
)?;
|
||||
let instance =
|
||||
FlowInstance::start(instance_id, source.flow_id.clone(), &source.definition)?;
|
||||
let initial = source
|
||||
.definition
|
||||
.state(&source.definition.initial)
|
||||
@@ -257,7 +239,6 @@ impl FlowRuntimeState {
|
||||
event: FlowEventKind::StateEntered {
|
||||
attempt_id: String::new(),
|
||||
state_id: instance.current_state.clone(),
|
||||
state_revision: instance.state_revision,
|
||||
status: instance.status,
|
||||
},
|
||||
};
|
||||
@@ -282,8 +263,22 @@ impl FlowRuntimeState {
|
||||
if let Some(attempt) = &self.active_attempt {
|
||||
return Ok(attempt.clone());
|
||||
}
|
||||
let attempt_id = attempt_id.into();
|
||||
// The event log is the identity authority: an attempt ID is used once,
|
||||
// including failed/cancelled attempts. This prevents a delayed result
|
||||
// from an earlier visit to the same state from matching a later attempt.
|
||||
if self.events.iter().any(|event| {
|
||||
matches!(
|
||||
&event.event,
|
||||
FlowEventKind::TransitionRequested { attempt_id: used, .. } if used == &attempt_id
|
||||
)
|
||||
}) {
|
||||
return Err(FlowTransitionError::InvalidRequest(
|
||||
"transition attempt ID has already been used".to_string(),
|
||||
));
|
||||
}
|
||||
let request = FlowTransitionRequest {
|
||||
attempt_id: attempt_id.into(),
|
||||
attempt_id,
|
||||
reason: reason.into(),
|
||||
};
|
||||
let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?;
|
||||
@@ -329,7 +324,7 @@ pub enum FlowTransitionError {
|
||||
NotActive,
|
||||
#[error("another transition attempt is already active")]
|
||||
AttemptInProgress,
|
||||
#[error("Flow definition does not match the instance's pinned revision")]
|
||||
#[error("Flow definition content does not match the instance's pinned digest")]
|
||||
DefinitionMismatch,
|
||||
#[error("invalid transition request: {0}")]
|
||||
InvalidRequest(String),
|
||||
@@ -337,7 +332,7 @@ pub enum FlowTransitionError {
|
||||
Invariant(String),
|
||||
}
|
||||
|
||||
pub fn begin_transition(
|
||||
fn begin_transition(
|
||||
instance: &mut FlowInstance,
|
||||
definition: &CompiledFlowDefinition,
|
||||
request: FlowTransitionRequest,
|
||||
@@ -374,9 +369,7 @@ pub fn begin_transition(
|
||||
let attempt = FlowTransitionAttempt {
|
||||
attempt_id: request.attempt_id,
|
||||
instance_id: instance.instance_id.clone(),
|
||||
definition_revision: instance.definition_revision,
|
||||
definition_digest: instance.definition_digest.clone(),
|
||||
checked_state_revision: instance.state_revision,
|
||||
from_state: instance.current_state.clone(),
|
||||
reason: request.reason,
|
||||
transitions,
|
||||
@@ -387,7 +380,6 @@ pub fn begin_transition(
|
||||
FlowEventKind::TransitionRequested {
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
state_id: attempt.from_state.clone(),
|
||||
state_revision: attempt.checked_state_revision,
|
||||
reason: attempt.reason.clone(),
|
||||
transitions: attempt.transitions.clone(),
|
||||
},
|
||||
@@ -398,20 +390,23 @@ pub fn begin_transition(
|
||||
Ok((attempt, events))
|
||||
}
|
||||
|
||||
pub fn resolve_transition(
|
||||
fn resolve_transition(
|
||||
instance: &mut FlowInstance,
|
||||
definition: &CompiledFlowDefinition,
|
||||
mut attempt: FlowTransitionAttempt,
|
||||
outcome: FlowVerifierOutcome,
|
||||
) -> Result<FlowTransitionResolution, FlowTransitionError> {
|
||||
if instance.active_attempt_id.as_deref() != Some(attempt.attempt_id.as_str()) {
|
||||
// Accept a result only for the currently reserved operation on this instance.
|
||||
// A completed/cancelled attempt has already released that reservation.
|
||||
if instance.instance_id != attempt.instance_id
|
||||
|| instance.active_attempt_id.as_deref() != Some(attempt.attempt_id.as_str())
|
||||
{
|
||||
return Err(FlowTransitionError::InvalidRequest(
|
||||
"attempt is not the active/latest attempt for this Flow instance".to_string(),
|
||||
));
|
||||
}
|
||||
if definition.content_digest != instance.definition_digest
|
||||
|| attempt.definition_digest != instance.definition_digest
|
||||
|| attempt.definition_revision != instance.definition_revision
|
||||
{
|
||||
return Err(FlowTransitionError::DefinitionMismatch);
|
||||
}
|
||||
@@ -447,7 +442,7 @@ pub fn resolve_transition(
|
||||
)
|
||||
}
|
||||
FlowVerifierOutcome::Completed { results } => {
|
||||
if instance.state_revision != attempt.checked_state_revision
|
||||
if instance.status != FlowInstanceStatus::Active
|
||||
|| instance.current_state != attempt.from_state
|
||||
{
|
||||
attempt.status = FlowAttemptStatus::Rejected;
|
||||
@@ -604,9 +599,6 @@ fn resolve_completed_results(
|
||||
results,
|
||||
});
|
||||
instance.current_state = transition.target.clone();
|
||||
instance.state_revision = instance.state_revision.checked_add(1).ok_or_else(|| {
|
||||
FlowTransitionError::Invariant("Flow state revision overflowed".to_string())
|
||||
})?;
|
||||
instance.status = if transition.target.as_str() == CANCELLED_STATE_ID {
|
||||
FlowInstanceStatus::Cancelled
|
||||
} else if target_state.terminal {
|
||||
@@ -617,7 +609,6 @@ fn resolve_completed_results(
|
||||
events.push(FlowEventKind::StateEntered {
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
state_id: instance.current_state.clone(),
|
||||
state_revision: instance.state_revision,
|
||||
status: instance.status,
|
||||
});
|
||||
Ok(FlowTransitionResolution {
|
||||
@@ -735,16 +726,109 @@ mod tests {
|
||||
attempt
|
||||
}
|
||||
|
||||
fn runtime_state() -> FlowRuntimeState {
|
||||
let definition = definition();
|
||||
FlowRuntimeState::start(
|
||||
&crate::ResolvedFlowSource {
|
||||
selector: "workspace:simple".parse().unwrap(),
|
||||
workspace_id: "workspace-1".into(),
|
||||
flow_id: "flow-1".into(),
|
||||
content_digest: definition.content_digest.clone(),
|
||||
definition,
|
||||
},
|
||||
"instance-1",
|
||||
)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_attempt_id_cannot_be_reused_even_after_restore() {
|
||||
let mut state = runtime_state();
|
||||
state.begin_or_recover_transition("first", "ready").unwrap();
|
||||
state
|
||||
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
|
||||
.unwrap();
|
||||
let mut restored: FlowRuntimeState =
|
||||
serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap();
|
||||
let before = restored.clone();
|
||||
assert!(matches!(
|
||||
restored.begin_or_recover_transition("first", "try again"),
|
||||
Err(FlowTransitionError::InvalidRequest(_))
|
||||
));
|
||||
assert_eq!(restored, before);
|
||||
restored
|
||||
.begin_or_recover_transition("second", "try again")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.instance.active_attempt_id.as_deref(),
|
||||
Some("second")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_result_cannot_consume_a_new_attempt_in_the_same_state() {
|
||||
let mut state = runtime_state();
|
||||
state.begin_or_recover_transition("first", "ready").unwrap();
|
||||
state
|
||||
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
|
||||
.unwrap();
|
||||
state
|
||||
.begin_or_recover_transition("second", "ready")
|
||||
.unwrap();
|
||||
let before = state.clone();
|
||||
assert!(matches!(
|
||||
state.resolve_active_transition(
|
||||
"first",
|
||||
FlowVerifierOutcome::Completed { results: vec![] }
|
||||
),
|
||||
Err(FlowTransitionError::InvalidRequest(_))
|
||||
));
|
||||
assert_eq!(state, before);
|
||||
state
|
||||
.resolve_active_transition("second", FlowVerifierOutcome::Cancelled)
|
||||
.unwrap();
|
||||
assert!(state.active_attempt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_content_is_checked_before_start_and_before_accepting_a_result() {
|
||||
let mut state = runtime_state();
|
||||
let mut changed = state.definition.clone();
|
||||
changed.content_digest = "different-content".into();
|
||||
assert_eq!(
|
||||
begin_transition(
|
||||
&mut state.instance,
|
||||
&changed,
|
||||
FlowTransitionRequest {
|
||||
attempt_id: "first".into(),
|
||||
reason: "ready".into(),
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
FlowTransitionError::DefinitionMismatch
|
||||
);
|
||||
assert!(state.instance.active_attempt_id.is_none());
|
||||
state.begin_or_recover_transition("first", "ready").unwrap();
|
||||
state.definition = changed;
|
||||
let before = state.clone();
|
||||
assert_eq!(
|
||||
state
|
||||
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
|
||||
.unwrap_err(),
|
||||
FlowTransitionError::DefinitionMismatch
|
||||
);
|
||||
assert_eq!(state, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_one_met_transition_enters_terminal_state() {
|
||||
let definition = definition();
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -770,7 +854,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(instance.current_state.as_str(), "done");
|
||||
assert_eq!(instance.state_revision, 1);
|
||||
|
||||
assert_eq!(instance.status, FlowInstanceStatus::Completed);
|
||||
assert!(resolution.rejection.is_none());
|
||||
assert!(matches!(
|
||||
@@ -788,10 +872,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -827,10 +909,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -852,7 +932,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(instance.current_state.as_str(), "work");
|
||||
assert_eq!(instance.state_revision, 0);
|
||||
|
||||
assert_eq!(instance.active_attempt_id, None);
|
||||
assert_eq!(
|
||||
resolution.rejection.unwrap().code,
|
||||
@@ -866,10 +946,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -901,15 +979,13 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
let attempt = begin(&mut instance);
|
||||
instance.state_revision = 1;
|
||||
instance.current_state = StateId::new("done").unwrap();
|
||||
let results = attempt
|
||||
.transitions
|
||||
.iter()
|
||||
@@ -938,10 +1014,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -967,7 +1041,7 @@ mod tests {
|
||||
FlowRejectionCode::MultipleConditionsMet
|
||||
);
|
||||
assert_eq!(instance.current_state.as_str(), "work");
|
||||
assert_eq!(instance.state_revision, 0);
|
||||
|
||||
assert!(
|
||||
resolution
|
||||
.events
|
||||
@@ -982,10 +1056,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
@@ -1015,10 +1087,8 @@ mod tests {
|
||||
let mut instance = FlowInstance {
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_id: "definition-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: definition.content_digest.clone(),
|
||||
current_state: definition.initial.clone(),
|
||||
state_revision: 0,
|
||||
status: FlowInstanceStatus::Active,
|
||||
active_attempt_id: None,
|
||||
};
|
||||
|
||||
@@ -124,7 +124,6 @@ pub struct ResolvedFlowSource {
|
||||
pub selector: FlowSelector,
|
||||
pub workspace_id: String,
|
||||
pub flow_id: String,
|
||||
pub revision: u64,
|
||||
pub content_digest: String,
|
||||
pub definition: crate::CompiledFlowDefinition,
|
||||
}
|
||||
|
||||
+13
-7
@@ -100,8 +100,6 @@ pub struct JobRequest {
|
||||
/// Stable caller-selected idempotency key within the host's namespace.
|
||||
pub job_id: String,
|
||||
pub purpose: String,
|
||||
/// Revision fenced by the structured result.
|
||||
pub input_revision: String,
|
||||
pub input_ref: String,
|
||||
/// Bounded immutable snapshot; must not contain credentials.
|
||||
pub input: Value,
|
||||
@@ -126,7 +124,6 @@ impl JobRequest {
|
||||
pub fn validate_fields(&self) -> Result<()> {
|
||||
validate_identifier("job_id", &self.job_id, 256)?;
|
||||
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
|
||||
validate_identifier("input_revision", &self.input_revision, 256)?;
|
||||
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
|
||||
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
|
||||
self.profile_selector()?;
|
||||
@@ -157,6 +154,13 @@ impl JobRequest {
|
||||
fingerprint(self)
|
||||
}
|
||||
|
||||
/// Content identity for result fencing; hosts compare it with the reserved
|
||||
/// attempt's input, never with a caller-selected counter.
|
||||
pub fn input_digest(&self) -> Result<String> {
|
||||
self.validate_input()?;
|
||||
fingerprint(&self.input)
|
||||
}
|
||||
|
||||
pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
|
||||
worker_input(self, attempt_id)
|
||||
}
|
||||
@@ -167,7 +171,8 @@ impl JobRequest {
|
||||
pub struct JobResultSubmission {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
pub input_revision: String,
|
||||
/// SHA-256 of the exact JSON encoding of the immutable input snapshot.
|
||||
pub input_digest: String,
|
||||
pub result: Value,
|
||||
}
|
||||
|
||||
@@ -202,7 +207,8 @@ pub enum JobAttemptState {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct JobOutcome {
|
||||
pub job_id: String,
|
||||
pub input_revision: String,
|
||||
/// SHA-256 of the exact JSON encoding of the immutable input snapshot.
|
||||
pub input_digest: String,
|
||||
pub attempt_id: String,
|
||||
pub attempt: u8,
|
||||
pub state: JobState,
|
||||
@@ -263,11 +269,11 @@ pub fn worker_input_with_label(
|
||||
let input = serde_json::to_string(&request.input)
|
||||
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
|
||||
Ok(format!(
|
||||
"{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.",
|
||||
"{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_digest: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.",
|
||||
request.instruction,
|
||||
request.job_id,
|
||||
attempt_id,
|
||||
request.input_revision,
|
||||
request.input_digest()?,
|
||||
request.input_ref,
|
||||
input
|
||||
))
|
||||
|
||||
@@ -5,7 +5,6 @@ fn request() -> JobRequest {
|
||||
JobRequest {
|
||||
job_id: "check:r7".into(),
|
||||
purpose: "snapshot_check".into(),
|
||||
input_revision: "7".into(),
|
||||
input_ref: "snapshot://7".into(),
|
||||
input: json!({"title": "Check me"}),
|
||||
instruction: "Check this immutable snapshot.".into(),
|
||||
@@ -15,6 +14,25 @@ fn request() -> JobRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_content_identity_is_computed_and_old_counter_is_not_accepted() {
|
||||
let original = request();
|
||||
let mut changed = original.clone();
|
||||
changed.input = json!({"title": "changed"});
|
||||
assert_ne!(
|
||||
original.input_digest().unwrap(),
|
||||
changed.input_digest().unwrap()
|
||||
);
|
||||
changed.instruction.push_str(" Extra instruction.");
|
||||
assert_eq!(
|
||||
changed.input_digest().unwrap(),
|
||||
fingerprint(&changed.input).unwrap()
|
||||
);
|
||||
let mut old = serde_json::to_value(&original).unwrap();
|
||||
old["input_revision"] = json!(7);
|
||||
assert!(serde_json::from_value::<JobRequest>(old).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_lifecycle_enums_round_trip_all_host_states() {
|
||||
for (state, name) in [
|
||||
@@ -53,7 +71,7 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
|
||||
fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
|
||||
let mut outcome = JobOutcome {
|
||||
job_id: "job-1".into(),
|
||||
input_revision: "7".into(),
|
||||
input_digest: request().input_digest().unwrap(),
|
||||
attempt_id: "job-1:attempt:2".into(),
|
||||
attempt: 2,
|
||||
state: JobState::Pending,
|
||||
@@ -66,7 +84,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
|
||||
assert_eq!(
|
||||
encoded,
|
||||
json!({
|
||||
"job_id": "job-1", "input_revision": "7", "attempt_id": "job-1:attempt:2",
|
||||
"job_id": "job-1", "input_digest": request().input_digest().unwrap(), "attempt_id": "job-1:attempt:2",
|
||||
"attempt": 2, "state": "pending", "attempt_state": "dispatching",
|
||||
"result": null, "failure_category": null, "failure_detail": null
|
||||
})
|
||||
@@ -124,7 +142,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
|
||||
fn neutral_request_and_result_have_strict_round_trip_contracts() {
|
||||
let request = request();
|
||||
let encoded = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(encoded.as_object().unwrap().len(), 8);
|
||||
assert_eq!(encoded.as_object().unwrap().len(), 7);
|
||||
assert!(encoded.get("serialization_key").is_none());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<JobRequest>(encoded.clone()).unwrap(),
|
||||
@@ -137,10 +155,11 @@ fn neutral_request_and_result_have_strict_round_trip_contracts() {
|
||||
with_provenance["source_worker"] = json!({});
|
||||
assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err());
|
||||
|
||||
let input_digest = request.input_digest().unwrap();
|
||||
let submission = JobResultSubmission {
|
||||
job_id: request.job_id,
|
||||
attempt_id: "check:r7:attempt:1".into(),
|
||||
input_revision: request.input_revision,
|
||||
input_digest,
|
||||
result: json!({"ok": true}),
|
||||
};
|
||||
let mut encoded = serde_json::to_value(&submission).unwrap();
|
||||
@@ -168,7 +187,6 @@ fn fingerprint_covers_every_immutable_field_and_preserves_replay() {
|
||||
for mutate in [
|
||||
|r: &mut JobRequest| r.job_id.push('x'),
|
||||
|r: &mut JobRequest| r.purpose.push('x'),
|
||||
|r: &mut JobRequest| r.input_revision.push('x'),
|
||||
|r: &mut JobRequest| r.input_ref.push('x'),
|
||||
|r: &mut JobRequest| r.input = json!({"title": "changed"}),
|
||||
|r: &mut JobRequest| r.instruction.push('x'),
|
||||
@@ -216,7 +234,6 @@ fn registry_profiles_are_not_authority_or_a_fixed_allowlist() {
|
||||
fn validation_enforces_utf8_bytes_and_metadata_bounds() {
|
||||
let mut r = request();
|
||||
r.job_id = "x".repeat(256);
|
||||
r.input_revision = "x".repeat(256);
|
||||
r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES);
|
||||
r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES);
|
||||
r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES);
|
||||
@@ -225,7 +242,6 @@ fn validation_enforces_utf8_bytes_and_metadata_bounds() {
|
||||
r.validate().unwrap();
|
||||
for mutate in [
|
||||
|r: &mut JobRequest| r.job_id.push('x'),
|
||||
|r: &mut JobRequest| r.input_revision.push('x'),
|
||||
|r: &mut JobRequest| r.purpose.push('x'),
|
||||
|r: &mut JobRequest| r.input_ref.push('x'),
|
||||
|r: &mut JobRequest| r.instruction.push('x'),
|
||||
@@ -287,7 +303,7 @@ fn result_digest_and_failure_detail_bound_exact_utf8_encoding() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() {
|
||||
fn worker_input_is_content_and_attempt_bound_not_final_prose_authority() {
|
||||
let request = request();
|
||||
let attempt = attempt_id(&request.job_id, 2);
|
||||
assert_eq!(attempt, "check:r7:attempt:2");
|
||||
@@ -297,7 +313,7 @@ fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() {
|
||||
"Job envelope (immutable):",
|
||||
"job_id: check:r7",
|
||||
"attempt_id: check:r7:attempt:2",
|
||||
"input_revision: 7",
|
||||
"input_digest: sha256:",
|
||||
"input_ref: snapshot://7",
|
||||
r#"input_json: {"title":"Check me"}"#,
|
||||
"structured Job result capability",
|
||||
|
||||
+113
-14
@@ -391,8 +391,7 @@ impl ResolvedMemoryFeatureConfig {
|
||||
return Err("disabled Memory feature must not carry Workspace settings");
|
||||
}
|
||||
if let Some(settings) = &self.workspace_settings
|
||||
&& (settings.settings_revision == 0
|
||||
|| !is_normalized_workspace_memory_language(&settings.language))
|
||||
&& !is_normalized_workspace_memory_language(&settings.language)
|
||||
{
|
||||
return Err("Memory Workspace settings snapshot metadata is invalid");
|
||||
}
|
||||
@@ -473,8 +472,7 @@ impl ResolvedSubjektivFeatureConfig {
|
||||
return Err("disabled subjektiv feature must not carry Workspace settings");
|
||||
}
|
||||
if let Some(settings) = &self.workspace_settings
|
||||
&& (settings.settings_revision == 0
|
||||
|| !is_normalized_workspace_memory_language(&settings.language))
|
||||
&& !is_normalized_workspace_memory_language(&settings.language)
|
||||
{
|
||||
return Err("subjektiv Workspace settings snapshot metadata is invalid");
|
||||
}
|
||||
@@ -705,10 +703,8 @@ pub fn is_normalized_workspace_memory_language(language: &str) -> bool {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceMemorySettingsSnapshot {
|
||||
/// Workspace that owns the settings revision.
|
||||
/// Workspace that owns these settings.
|
||||
pub workspace_id: String,
|
||||
/// Monotonic Workspace Memory settings revision.
|
||||
pub settings_revision: u64,
|
||||
/// Normalized language used for Memory extraction and consolidation output.
|
||||
pub language: String,
|
||||
}
|
||||
@@ -1155,7 +1151,8 @@ struct LegacyMemoryConfig {
|
||||
consolidation_threshold_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3;
|
||||
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 4;
|
||||
const MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3;
|
||||
const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
|
||||
|
||||
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
|
||||
@@ -1187,6 +1184,7 @@ pub fn read_persisted_worker_manifest_snapshot(
|
||||
))
|
||||
})?;
|
||||
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
||||
&& version != MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
||||
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
||||
{
|
||||
return Err(serde_json::Error::io(std::io::Error::new(
|
||||
@@ -1218,6 +1216,9 @@ pub fn read_persisted_worker_manifest_snapshot(
|
||||
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
|
||||
migrate_legacy_manifest_authority(&mut manifest)?;
|
||||
}
|
||||
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
|
||||
migrate_legacy_memory_settings_counter(&mut manifest)?;
|
||||
}
|
||||
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
|
||||
}
|
||||
|
||||
@@ -1240,6 +1241,32 @@ fn validate_persisted_worker_manifest(
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Frozen migration for schema 2/3 resolved manifests. Keep actual Workspace
|
||||
/// identity and language unchanged; the obsolete sequence has no execution role.
|
||||
fn migrate_legacy_memory_settings_counter(
|
||||
manifest: &mut serde_json::Value,
|
||||
) -> Result<(), serde_json::Error> {
|
||||
for namespace in ["memory", "subjektiv"] {
|
||||
let Some(settings) = manifest
|
||||
.get_mut("feature")
|
||||
.and_then(|feature| feature.get_mut(namespace))
|
||||
.and_then(|feature| feature.get_mut("workspace_settings"))
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(counter) = settings.remove("settings_revision")
|
||||
&& !counter.as_u64().is_some_and(|counter| counter > 0)
|
||||
{
|
||||
return Err(serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"legacy Workspace Memory settings snapshot has an invalid counter",
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_legacy_manifest_authority(
|
||||
manifest: &mut serde_json::Value,
|
||||
) -> Result<(), serde_json::Error> {
|
||||
@@ -1385,6 +1412,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
|
||||
}
|
||||
feature.insert("memory".to_string(), resolved);
|
||||
migrate_legacy_manifest_authority(&mut snapshot)?;
|
||||
migrate_legacy_memory_settings_counter(&mut snapshot)?;
|
||||
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
|
||||
}
|
||||
|
||||
@@ -1749,7 +1777,6 @@ model_id = "claude-sonnet-4-20250514"
|
||||
worker_max_turns = 2\n\n\
|
||||
[feature.memory.workspace_settings]\n\
|
||||
workspace_id = \"workspace-1\"\n\
|
||||
settings_revision = 7\n\
|
||||
language = \"日本語\"\n"
|
||||
);
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
@@ -1774,7 +1801,6 @@ model_id = "claude-sonnet-4-20250514"
|
||||
fn resolved_memory_execution_validation_fails_closed() {
|
||||
let snapshot = WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
let mut enabled = ResolvedMemoryFeatureConfig::default();
|
||||
@@ -1793,7 +1819,6 @@ model_id = "claude-sonnet-4-20250514"
|
||||
fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() {
|
||||
let snapshot = WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
let mut enabled = ResolvedSubjektivFeatureConfig::default();
|
||||
@@ -1816,6 +1841,80 @@ model_id = "claude-sonnet-4-20250514"
|
||||
assert!(WorkerManifest::from_toml(&toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_counter_migration_preserves_memory_and_subject_settings() {
|
||||
for version in [2, 3] {
|
||||
let mut manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
manifest.feature.memory.profile.enabled = true;
|
||||
manifest.feature.subjektiv.profile.enabled = true;
|
||||
let settings = WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".into(),
|
||||
language: "日本語".into(),
|
||||
};
|
||||
manifest
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(settings.clone())
|
||||
.unwrap();
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(settings.clone())
|
||||
.unwrap();
|
||||
let mut snapshot = write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
snapshot["schema_version"] = version.into();
|
||||
for namespace in ["memory", "subjektiv"] {
|
||||
snapshot["manifest"]["feature"][namespace]["workspace_settings"]["settings_revision"] =
|
||||
99.into();
|
||||
}
|
||||
let restored = read_persisted_worker_manifest_snapshot(snapshot).unwrap();
|
||||
assert_eq!(
|
||||
restored.feature.memory.workspace_settings(),
|
||||
Some(settings.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
restored.feature.subjektiv.workspace_settings(),
|
||||
Some(settings)
|
||||
);
|
||||
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
|
||||
assert_eq!(current["schema_version"], 4);
|
||||
for namespace in ["memory", "subjektiv"] {
|
||||
assert_eq!(
|
||||
current["manifest"]["feature"][namespace]["workspace_settings"],
|
||||
serde_json::json!({
|
||||
"workspace_id": "workspace-1", "language": "日本語"
|
||||
})
|
||||
);
|
||||
}
|
||||
read_persisted_worker_manifest_snapshot(current).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_settings_snapshot_rejects_removed_counter_outside_migration() {
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceMemorySettingsSnapshot>(serde_json::json!({
|
||||
"workspace_id": "workspace-1", "language": "English", "settings_revision": 9
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let settings: WorkspaceMemorySettingsSnapshot = serde_json::from_value(serde_json::json!({
|
||||
"workspace_id": "workspace-1", "language": "English"
|
||||
}))
|
||||
.unwrap();
|
||||
let mut memory = ResolvedMemoryFeatureConfig::default();
|
||||
memory.profile.enabled = true;
|
||||
memory.bind_workspace_settings(settings.clone()).unwrap();
|
||||
assert!(memory.validate_execution().is_ok());
|
||||
for language in ["", " English", "English\n"] {
|
||||
memory.workspace_settings = Some(WorkspaceMemorySettingsSnapshot {
|
||||
language: language.into(),
|
||||
..settings.clone()
|
||||
});
|
||||
assert!(memory.validate_execution().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
|
||||
let mut manifest =
|
||||
@@ -1863,7 +1962,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
"Français"
|
||||
);
|
||||
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
|
||||
assert_eq!(current["schema_version"], 3);
|
||||
assert_eq!(current["schema_version"], 4);
|
||||
assert!(current["manifest"].get("memory").is_none());
|
||||
|
||||
let mut disabled =
|
||||
@@ -1905,7 +2004,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
|
||||
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
|
||||
assert_eq!(current["schema_version"], 3);
|
||||
assert_eq!(current["schema_version"], 4);
|
||||
assert!(current["manifest"].get("plugins").is_none());
|
||||
assert!(current["manifest"]["feature"].get("plugins").is_none());
|
||||
assert!(current["manifest"]["feature"].get("workers").is_none());
|
||||
@@ -1976,7 +2075,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
assert!(
|
||||
read_persisted_worker_manifest_snapshot(serde_json::json!({
|
||||
"schema_version": 4,
|
||||
"schema_version": 5,
|
||||
"manifest": manifest,
|
||||
}))
|
||||
.is_err()
|
||||
|
||||
@@ -1489,7 +1489,6 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for (field, value) in [
|
||||
("workspace_id", serde_json::json!("workspace-a")),
|
||||
("settings_revision", serde_json::json!(2)),
|
||||
("language", serde_json::json!("Japanese")),
|
||||
] {
|
||||
let artifact = serde_json::json!({
|
||||
|
||||
@@ -184,8 +184,6 @@ impl OperationCounts {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct MemorySettingsAudit {
|
||||
pub workspace_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub settings_revision: u64,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -108,8 +108,9 @@ pub struct EvidenceOrigin {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_definition_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub flow_definition_revision: Option<u64>,
|
||||
/// SHA-256 of the exact pinned Flow definition content, when available.
|
||||
/// Historical origins without captured definition content leave this absent.
|
||||
pub flow_definition_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
/// Host-resolved source/evidence metadata for an individual staging claim.
|
||||
@@ -156,6 +157,33 @@ pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flow_origin_captures_content_identity_without_a_state_counter() {
|
||||
let origin: EvidenceOrigin = serde_json::from_value(serde_json::json!({
|
||||
"kind": "flow_instruction",
|
||||
"flow_definition_id": "flow-definition",
|
||||
"flow_definition_fingerprint": "a".repeat(64)
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
origin.flow_definition_fingerprint.as_deref(),
|
||||
Some("a".repeat(64).as_str())
|
||||
);
|
||||
let value = serde_json::to_value(&origin).unwrap();
|
||||
assert_eq!(value["flow_definition_fingerprint"], "a".repeat(64));
|
||||
let historical: EvidenceOrigin = serde_json::from_value(serde_json::json!({
|
||||
"kind": "flow_instruction", "flow_definition_id": "flow-definition"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(historical.flow_definition_fingerprint.is_none());
|
||||
assert!(
|
||||
serde_json::to_value(historical)
|
||||
.unwrap()
|
||||
.get("flow_definition_fingerprint")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
let doc = "---\nfoo: 1\n---\nbody here\n";
|
||||
|
||||
@@ -11,6 +11,7 @@ schemars = { workspace = true, features = ["chrono04"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
ticket = { path = "../ticket" }
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+109
-58
@@ -8,7 +8,7 @@ use std::{
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SCHEMA_VERSION: i64 = 12;
|
||||
const SCHEMA_VERSION: i64 = 13;
|
||||
const MAX_BODY_BYTES: usize = 16 * 1024;
|
||||
const DOMAIN_TABLES: [&str; 5] = [
|
||||
"merge_requests",
|
||||
@@ -103,7 +103,7 @@ pub struct ReviewRequestedEvent {
|
||||
pub sequence: u64,
|
||||
pub subject_ref: String,
|
||||
#[serde(default)]
|
||||
pub ticket_item_revision: String,
|
||||
pub ticket_content_digest: String,
|
||||
#[serde(default)]
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub requested_by: WorkerIdentity,
|
||||
@@ -118,7 +118,7 @@ pub struct ReviewEvent {
|
||||
pub request_event_id: String,
|
||||
pub subject_ref: String,
|
||||
#[serde(default)]
|
||||
pub ticket_item_revision: String,
|
||||
pub ticket_content_digest: String,
|
||||
#[serde(default)]
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub decision: ReviewDecision,
|
||||
@@ -249,7 +249,7 @@ pub enum MergeRequestEvidenceError {
|
||||
ReviewRequestMissing,
|
||||
ReviewRequestMismatch,
|
||||
ApprovalNotEffective,
|
||||
ItemRevisionMismatch,
|
||||
TicketContentMismatch,
|
||||
SourceSnapshotMismatch,
|
||||
RequirementApprovalMissing,
|
||||
}
|
||||
@@ -268,7 +268,7 @@ impl MergeRequestEvidenceError {
|
||||
Self::ReviewRequestMissing => "review_request_missing",
|
||||
Self::ReviewRequestMismatch => "review_request_mismatch",
|
||||
Self::ApprovalNotEffective => "approval_not_effective",
|
||||
Self::ItemRevisionMismatch => "item_revision_mismatch",
|
||||
Self::TicketContentMismatch => "ticket_content_mismatch",
|
||||
Self::SourceSnapshotMismatch => "source_snapshot_mismatch",
|
||||
Self::RequirementApprovalMissing => "requirement_approval_missing",
|
||||
}
|
||||
@@ -290,14 +290,14 @@ impl MergeRequestEvidenceError {
|
||||
"Reviewer approval does not match linked ReviewRequested evidence"
|
||||
}
|
||||
Self::ApprovalNotEffective => "Reviewer approval is not the effective review",
|
||||
Self::ItemRevisionMismatch => {
|
||||
"Reviewer approval does not attest the current Ticket revision"
|
||||
Self::TicketContentMismatch => {
|
||||
"Reviewer approval does not attest the current Ticket digest"
|
||||
}
|
||||
Self::SourceSnapshotMismatch => {
|
||||
"Reviewer approval does not attest the exact linked source set"
|
||||
}
|
||||
Self::RequirementApprovalMissing => {
|
||||
"no effective Reviewer approval attests the current Ticket revision and exact linked source set"
|
||||
"no effective Reviewer approval attests the current Ticket digest and exact linked source set"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ impl MergeRequest {
|
||||
})
|
||||
.ok_or(MergeRequestEvidenceError::ReviewRequestMissing)?;
|
||||
if requested.subject_ref != review.subject_ref
|
||||
|| requested.ticket_item_revision != review.ticket_item_revision
|
||||
|| requested.ticket_content_digest != review.ticket_content_digest
|
||||
|| requested.ticket_merge_request_subjects != review.ticket_merge_request_subjects
|
||||
|| requested.reviewer != review.reviewer
|
||||
|| requested.sequence >= review.sequence
|
||||
@@ -393,7 +393,7 @@ impl MergeRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds an effective approval of the authoritative current Ticket revision and
|
||||
/// Finds an effective approval of the authoritative current Ticket digest and
|
||||
/// exact linked source snapshot. Callers supply all linked requests and resolved
|
||||
/// source refs (recorded merged refs for completion); this function does not read
|
||||
/// authority, resolve selectors, require merged state, or mutate lifecycle state.
|
||||
@@ -401,7 +401,7 @@ impl MergeRequest {
|
||||
/// attestation. Snapshot ordering is immaterial, but duplicate ids are rejected.
|
||||
pub fn requirement_approval<'a>(
|
||||
requests: &'a [MergeRequest],
|
||||
item_revision: &str,
|
||||
content_digest: &str,
|
||||
subjects: &[MergeRequestReviewSubject],
|
||||
event_id: Option<&str>,
|
||||
) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> {
|
||||
@@ -436,8 +436,10 @@ pub fn requirement_approval<'a>(
|
||||
{
|
||||
return Err(MergeRequestEvidenceError::ApprovalNotEffective);
|
||||
}
|
||||
if review.ticket_item_revision != item_revision || item_revision.trim().is_empty() {
|
||||
return Err(MergeRequestEvidenceError::ItemRevisionMismatch);
|
||||
if review.ticket_content_digest != content_digest
|
||||
|| content_digest.trim().is_empty()
|
||||
{
|
||||
return Err(MergeRequestEvidenceError::TicketContentMismatch);
|
||||
}
|
||||
if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) {
|
||||
return Err(MergeRequestEvidenceError::SourceSnapshotMismatch);
|
||||
@@ -508,7 +510,7 @@ pub struct OpenMergeRequest {
|
||||
pub struct RequestMergeRequestReview {
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub ticket_item_revision: String,
|
||||
pub ticket_content_digest: String,
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub subject_ref: String,
|
||||
pub child_session_id: String,
|
||||
@@ -604,6 +606,7 @@ pub struct CompleteMergeRequest {
|
||||
pub struct TicketCompletionEvent {
|
||||
pub operation_id: String,
|
||||
pub ticket_id: String,
|
||||
/// Frozen historical payload only; not a content attestation or live API.
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
@@ -805,11 +808,11 @@ impl MergeRequestStore {
|
||||
}
|
||||
let mut c = self.lock()?;
|
||||
let t = c.transaction()?;
|
||||
let current_revision = current_ticket_revision(&t, &i.auth.workspace_id, &i.ticket_id)?
|
||||
let current_digest = current_ticket_content_digest(&t, &i.auth.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if current_revision != i.ticket_item_revision {
|
||||
if current_digest != i.ticket_content_digest {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket item revision changed before review request".into(),
|
||||
"Ticket item digest changed before review request".into(),
|
||||
));
|
||||
}
|
||||
let current_mr = load_mr(&t, &i.auth.workspace_id, &i.merge_request_id)?
|
||||
@@ -848,7 +851,7 @@ impl MergeRequestStore {
|
||||
event_id: Uuid::now_v7().to_string(),
|
||||
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
|
||||
subject_ref: i.subject_ref,
|
||||
ticket_item_revision: i.ticket_item_revision,
|
||||
ticket_content_digest: i.ticket_content_digest,
|
||||
ticket_merge_request_subjects: i.ticket_merge_request_subjects,
|
||||
requested_by: WorkerIdentity {
|
||||
runtime_id: i.auth.runtime_id,
|
||||
@@ -944,12 +947,12 @@ impl MergeRequestStore {
|
||||
"review grant invalid".into(),
|
||||
));
|
||||
};
|
||||
let (ticket_item_revision, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)?
|
||||
let (ticket_content_digest, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)?
|
||||
.and_then(|request| {
|
||||
request.thread.into_iter().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => {
|
||||
Some((
|
||||
event.ticket_item_revision,
|
||||
event.ticket_content_digest,
|
||||
event.ticket_merge_request_subjects,
|
||||
))
|
||||
}
|
||||
@@ -982,7 +985,7 @@ impl MergeRequestStore {
|
||||
sequence: next_seq(&t, &ws, &mr)?,
|
||||
request_event_id: req,
|
||||
subject_ref: subject,
|
||||
ticket_item_revision,
|
||||
ticket_content_digest,
|
||||
ticket_merge_request_subjects,
|
||||
decision: i.decision,
|
||||
body: i.body,
|
||||
@@ -1056,13 +1059,12 @@ impl MergeRequestStore {
|
||||
}
|
||||
if let Some(review) = &review {
|
||||
let connection = self.lock()?;
|
||||
let current_revision =
|
||||
current_ticket_revision(&connection, &i.auth.workspace_id, &i.ticket_id)?
|
||||
let current_digest =
|
||||
current_ticket_content_digest(&connection, &i.auth.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
if review.ticket_content_digest != current_digest {
|
||||
b.push(
|
||||
"approval predates the current Ticket revision; fresh review is required"
|
||||
.into(),
|
||||
"approval predates the current Ticket digest; fresh review is required".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1147,12 +1149,12 @@ impl MergeRequestStore {
|
||||
));
|
||||
}
|
||||
let connection = self.lock()?;
|
||||
let current_revision =
|
||||
current_ticket_revision(&connection, &mr.workspace_id, &i.ticket_id)?
|
||||
let current_digest =
|
||||
current_ticket_content_digest(&connection, &mr.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
if review.ticket_content_digest != current_digest {
|
||||
return Err(MergeRequestError::NotReady(
|
||||
"approval predates the current Ticket revision; fresh review is required".into(),
|
||||
"approval predates the current Ticket digest; fresh review is required".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = connection
|
||||
@@ -1227,12 +1229,12 @@ impl MergeRequestStore {
|
||||
"current effective review does not approve the source ref".into(),
|
||||
));
|
||||
}
|
||||
let current_revision =
|
||||
current_ticket_revision(&transaction, &mr.workspace_id, &i.ticket_id)?
|
||||
let current_digest =
|
||||
current_ticket_content_digest(&transaction, &mr.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
if review.ticket_content_digest != current_digest {
|
||||
return Err(MergeRequestError::NotReady(
|
||||
"approval predates the current Ticket revision; fresh review is required".into(),
|
||||
"approval predates the current Ticket digest; fresh review is required".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = transaction
|
||||
@@ -1655,34 +1657,13 @@ fn linked_merge_request_ids(
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn current_ticket_revision(
|
||||
fn current_ticket_content_digest(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<String>, MergeRequestError> {
|
||||
let event_index = connection
|
||||
.query_row(
|
||||
"SELECT event_index
|
||||
FROM typed_ticket_events
|
||||
WHERE workspace_id=?1 AND ticket_id=?2
|
||||
AND kind IN ('create','item_edit')
|
||||
ORDER BY event_index DESC
|
||||
LIMIT 1",
|
||||
params![workspace_id, ticket_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(event_index) = event_index {
|
||||
return Ok(Some(format!("{ticket_id}:{event_index}")));
|
||||
}
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT updated_at FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, ticket_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
ticket::sqlite_ticket_content_digest(connection, workspace_id, ticket_id)
|
||||
.map_err(|error| MergeRequestError::Operation(error.to_string()))
|
||||
}
|
||||
|
||||
fn load_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> {
|
||||
@@ -1773,12 +1754,82 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
|
||||
match schema_state(c)? {
|
||||
SchemaState::Fresh => fresh(c),
|
||||
SchemaState::Current(SCHEMA_VERSION) => verify(c),
|
||||
SchemaState::Current(12) => migrate_v12_content_attestations(c),
|
||||
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
|
||||
"unsupported schema {v}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Frozen v12 migration: historical counters cannot prove the reviewed content.
|
||||
/// Preserve request/review identities and integration evidence, but do not
|
||||
/// manufacture content attestations from today's Ticket. Fresh review is needed.
|
||||
fn migrate_v12_content_attestations(c: &Connection) -> Result<(), MergeRequestError> {
|
||||
let transaction = c.unchecked_transaction()?;
|
||||
verify(&transaction)?;
|
||||
// Keep the exact historical audit bytes independently of the current DTO
|
||||
// projection. An unknown content attestation must not erase its old evidence.
|
||||
transaction.execute_batch(
|
||||
"CREATE TABLE merge_request_legacy_review_archive (
|
||||
workspace_id TEXT NOT NULL,
|
||||
merge_request_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, merge_request_id, event_id)
|
||||
);
|
||||
INSERT INTO merge_request_legacy_review_archive
|
||||
SELECT workspace_id,merge_request_id,event_id,payload_json
|
||||
FROM merge_request_thread_events WHERE kind IN ('review_requested','review');
|
||||
CREATE TRIGGER merge_request_legacy_review_archive_no_update
|
||||
BEFORE UPDATE ON merge_request_legacy_review_archive
|
||||
BEGIN SELECT RAISE(ABORT, 'historical review evidence is immutable'); END;
|
||||
CREATE TRIGGER merge_request_legacy_review_archive_no_delete
|
||||
BEFORE DELETE ON merge_request_legacy_review_archive
|
||||
BEGIN SELECT RAISE(ABORT, 'historical review evidence is retained'); END;",
|
||||
)?;
|
||||
let rows = {
|
||||
let mut statement = transaction.prepare(
|
||||
"SELECT workspace_id,merge_request_id,event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review')",
|
||||
)?;
|
||||
statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
for (workspace_id, merge_request_id, event_id, payload) in rows {
|
||||
let mut value: serde_json::Value = json(&payload)?;
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| MergeRequestError::Corrupt("review payload is not an object".into()))?;
|
||||
object.remove("ticket_item_revision");
|
||||
object.insert(
|
||||
"ticket_content_digest".into(),
|
||||
serde_json::Value::String(String::new()),
|
||||
);
|
||||
transaction.execute(
|
||||
"UPDATE merge_request_thread_events SET payload_json=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND event_id=?3",
|
||||
params![workspace_id, merge_request_id, event_id, value.to_string()],
|
||||
)?;
|
||||
}
|
||||
transaction.execute(
|
||||
"UPDATE merge_request_review_grants SET status='revoked',revoked_at=COALESCE(revoked_at,issued_at) WHERE status='issued'",
|
||||
[],
|
||||
)?;
|
||||
transaction.execute(
|
||||
"UPDATE merge_request_schema SET version=?1 WHERE singleton=1",
|
||||
params![SCHEMA_VERSION],
|
||||
)?;
|
||||
verify(&transaction)?;
|
||||
transaction.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SchemaState {
|
||||
Fresh,
|
||||
|
||||
@@ -24,7 +24,7 @@ fn subjects() -> Vec<MergeRequestReviewSubject> {
|
||||
fn add_review(
|
||||
request: &mut MergeRequest,
|
||||
event_id: &str,
|
||||
revision: &str,
|
||||
digest: &str,
|
||||
snapshot: &[MergeRequestReviewSubject],
|
||||
decision: ReviewDecision,
|
||||
) {
|
||||
@@ -37,7 +37,7 @@ fn add_review(
|
||||
event_id: format!("request-{event_id}"),
|
||||
sequence,
|
||||
subject_ref: subject.subject_ref.clone(),
|
||||
ticket_item_revision: revision.into(),
|
||||
ticket_content_digest: digest.into(),
|
||||
ticket_merge_request_subjects: snapshot.to_vec(),
|
||||
requested_by: actor(),
|
||||
reviewer: actor(),
|
||||
@@ -48,7 +48,7 @@ fn add_review(
|
||||
sequence: sequence + 1,
|
||||
request_event_id: requested.event_id.clone(),
|
||||
subject_ref: requested.subject_ref.clone(),
|
||||
ticket_item_revision: revision.into(),
|
||||
ticket_content_digest: digest.into(),
|
||||
ticket_merge_request_subjects: snapshot.to_vec(),
|
||||
decision,
|
||||
body: String::new(),
|
||||
@@ -84,7 +84,7 @@ fn merged_requests() -> Vec<MergeRequest> {
|
||||
add_review(
|
||||
&mut request,
|
||||
&approval_id,
|
||||
"revision-one",
|
||||
"digest-one",
|
||||
&snapshot,
|
||||
ReviewDecision::Approve,
|
||||
);
|
||||
@@ -109,22 +109,17 @@ fn merged_requests() -> Vec<MergeRequest> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postmerge_latest_approval_attests_the_current_revision_without_replacing_integration() {
|
||||
fn postmerge_latest_approval_attests_the_current_digest_without_replacing_integration() {
|
||||
let mut requests = merged_requests();
|
||||
let snapshot = subjects();
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-two",
|
||||
&snapshot,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("integration-MR-1")),
|
||||
Err(MergeRequestEvidenceError::TicketContentMismatch)
|
||||
);
|
||||
add_review(
|
||||
&mut requests[0],
|
||||
"postmerge",
|
||||
"revision-two",
|
||||
"digest-two",
|
||||
&snapshot,
|
||||
ReviewDecision::Approve,
|
||||
);
|
||||
@@ -134,28 +129,23 @@ fn postmerge_latest_approval_attests_the_current_revision_without_replacing_inte
|
||||
"integration-MR-1"
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, None)
|
||||
requirement_approval(&requests, "digest-two", &snapshot, None)
|
||||
.unwrap()
|
||||
.event_id,
|
||||
"postmerge"
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, Some("postmerge"))
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("postmerge"))
|
||||
.unwrap()
|
||||
.event_id,
|
||||
"postmerge"
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
&snapshot,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
requirement_approval(&requests, "digest-one", &snapshot, Some("integration-MR-1")),
|
||||
Err(MergeRequestEvidenceError::ApprovalNotEffective)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, Some("unknown")),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("unknown")),
|
||||
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
|
||||
);
|
||||
}
|
||||
@@ -167,80 +157,70 @@ fn later_request_changes_prevents_older_requirement_approval_from_being_effectiv
|
||||
add_review(
|
||||
&mut requests[0],
|
||||
"approved",
|
||||
"revision-two",
|
||||
"digest-two",
|
||||
&snapshot,
|
||||
ReviewDecision::Approve,
|
||||
);
|
||||
add_review(
|
||||
&mut requests[0],
|
||||
"changes",
|
||||
"revision-two",
|
||||
"digest-two",
|
||||
&snapshot,
|
||||
ReviewDecision::RequestChanges,
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, None),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, None),
|
||||
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, Some("approved")),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("approved")),
|
||||
Err(MergeRequestEvidenceError::ApprovalNotEffective)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, Some("changes")),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, Some("changes")),
|
||||
Err(MergeRequestEvidenceError::ApprovalNotApproved)
|
||||
);
|
||||
assert!(requests[0].integration_approval().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation() {
|
||||
fn changed_digest_or_any_linked_source_invalidates_a_multiple_mr_attestation() {
|
||||
let requests = merged_requests();
|
||||
let original = subjects();
|
||||
assert!(requirement_approval(&requests, "revision-one", &original, None).is_ok());
|
||||
assert!(requirement_approval(&requests, "digest-one", &original, None).is_ok());
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-two",
|
||||
&original,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
|
||||
requirement_approval(&requests, "digest-two", &original, Some("integration-MR-1")),
|
||||
Err(MergeRequestEvidenceError::TicketContentMismatch)
|
||||
);
|
||||
let mut changed = original.clone();
|
||||
changed[1].subject_ref = "changed-source-two".into();
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
&changed,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
requirement_approval(&requests, "digest-one", &changed, Some("integration-MR-1")),
|
||||
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
|
||||
);
|
||||
let mut reversed = original.clone();
|
||||
reversed.reverse();
|
||||
assert!(requirement_approval(&requests, "revision-one", &reversed, None).is_ok());
|
||||
assert!(requirement_approval(&requests, "digest-one", &reversed, None).is_ok());
|
||||
|
||||
// Even after the caller refreshes the linked request collection, an old
|
||||
// attestation cannot authorize completion against a smaller linked set.
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests[..1],
|
||||
"revision-one",
|
||||
"digest-one",
|
||||
&original[..1],
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-one", &original[..1], None),
|
||||
requirement_approval(&requests, "digest-one", &original[..1], None),
|
||||
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
"digest-one",
|
||||
&[original[0].clone(), original[0].clone()],
|
||||
None
|
||||
),
|
||||
@@ -249,24 +229,24 @@ fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_revision() {
|
||||
fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_digest() {
|
||||
let mut requests = merged_requests();
|
||||
let mut snapshot = subjects();
|
||||
snapshot.pop();
|
||||
requests.pop();
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, None),
|
||||
requirement_approval(&requests, "digest-two", &snapshot, None),
|
||||
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
|
||||
);
|
||||
add_review(
|
||||
&mut requests[0],
|
||||
"refreshed-set",
|
||||
"revision-two",
|
||||
"digest-two",
|
||||
&snapshot,
|
||||
ReviewDecision::Approve,
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-two", &snapshot, None)
|
||||
requirement_approval(&requests, "digest-two", &snapshot, None)
|
||||
.unwrap()
|
||||
.event_id,
|
||||
"refreshed-set"
|
||||
@@ -282,7 +262,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
|
||||
let original = subjects();
|
||||
for (case, expected) in [
|
||||
("missing", MergeRequestEvidenceError::ReviewRequestMissing),
|
||||
("revision", MergeRequestEvidenceError::ReviewRequestMismatch),
|
||||
("digest", MergeRequestEvidenceError::ReviewRequestMismatch),
|
||||
("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch),
|
||||
("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch),
|
||||
("source", MergeRequestEvidenceError::ApprovalSourceMismatch),
|
||||
@@ -294,7 +274,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
|
||||
review.request_event_id = "absent-request".into();
|
||||
}
|
||||
MergeRequestThreadEvent::ReviewRequested(requested) => match case {
|
||||
"revision" => requested.ticket_item_revision = "other-revision".into(),
|
||||
"digest" => requested.ticket_content_digest = "other-digest".into(),
|
||||
"snapshot" => {
|
||||
requested.ticket_merge_request_subjects.pop();
|
||||
}
|
||||
@@ -309,12 +289,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
&original,
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
requirement_approval(&requests, "digest-one", &original, Some("integration-MR-1")),
|
||||
Err(expected),
|
||||
"{case}"
|
||||
);
|
||||
@@ -339,14 +314,14 @@ fn revoked_requirement_approval_is_rejected_and_discovery_uses_another_valid_mr(
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
"revision-one",
|
||||
"digest-one",
|
||||
&subjects(),
|
||||
Some("integration-MR-1")
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ApprovalRevoked)
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "revision-one", &subjects(), None)
|
||||
requirement_approval(&requests, "digest-one", &subjects(), None)
|
||||
.unwrap()
|
||||
.event_id,
|
||||
"integration-MR-2"
|
||||
@@ -441,8 +416,8 @@ fn evidence_error_codes_are_stable_machine_diagnostics_separate_from_messages()
|
||||
"approval_not_effective",
|
||||
),
|
||||
(
|
||||
MergeRequestEvidenceError::ItemRevisionMismatch,
|
||||
"item_revision_mismatch",
|
||||
MergeRequestEvidenceError::TicketContentMismatch,
|
||||
"ticket_content_mismatch",
|
||||
),
|
||||
(
|
||||
MergeRequestEvidenceError::SourceSnapshotMismatch,
|
||||
|
||||
@@ -35,11 +35,24 @@ fn auth_repo(repository_id: &str) -> MergeRequestAuth {
|
||||
..auth()
|
||||
}
|
||||
}
|
||||
fn content_digest(title: &str) -> String {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
connection.execute_batch("CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,title TEXT,body TEXT); CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);").unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_tickets VALUES('W','T',?1,'Requirements')",
|
||||
[title],
|
||||
)
|
||||
.unwrap();
|
||||
ticket::sqlite_ticket_content_digest(&connection, "W", "T")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("db");
|
||||
let c = Connection::open(&p).unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
|
||||
drop(c);
|
||||
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
||||
assignment_id: "A".into(),
|
||||
@@ -50,6 +63,221 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
||||
let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap();
|
||||
(d, s)
|
||||
}
|
||||
/// Frozen v12 fixture: counters must never be promoted to content attestations.
|
||||
fn freeze_v12_review_payloads(connection: &Connection) {
|
||||
let rows = {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review')",
|
||||
).unwrap();
|
||||
statement
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
for (event_id, payload) in rows {
|
||||
let mut value: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("ticket_content_digest");
|
||||
object.insert("ticket_item_revision".into(), serde_json::json!("T:0"));
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE merge_request_thread_events SET payload_json=?2 WHERE event_id=?1",
|
||||
rusqlite::params![event_id, value.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
connection
|
||||
.execute("UPDATE merge_request_schema SET version=12", [])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v12_migration_revokes_old_grants_preserves_integration_and_requires_fresh_content_review() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
let approved = approve(&store, "source", "old-consumed");
|
||||
let completion = CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "integration".into(),
|
||||
approval_event_id: approved.event_id.clone(),
|
||||
current_subject_ref: "source".into(),
|
||||
target_ref_before: "before".into(),
|
||||
target_ref_after: "after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(5),
|
||||
};
|
||||
let merged = store.complete(completion.clone()).unwrap();
|
||||
request(&store, "source", "old-issued");
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
freeze_v12_review_payloads(&connection);
|
||||
let original_reviews = {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review') ORDER BY event_id",
|
||||
).unwrap();
|
||||
statement
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
migrate(&connection).unwrap();
|
||||
let archived_reviews = {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT event_id,payload_json FROM merge_request_legacy_review_archive ORDER BY event_id",
|
||||
).unwrap();
|
||||
statement
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
archived_reviews, original_reviews,
|
||||
"historical review bytes must survive conversion"
|
||||
);
|
||||
assert!(
|
||||
connection
|
||||
.execute_batch("UPDATE merge_request_legacy_review_archive SET payload_json='{}'")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
connection
|
||||
.execute_batch("DELETE FROM merge_request_legacy_review_archive")
|
||||
.is_err()
|
||||
);
|
||||
let grants: Vec<(String, String, Option<String>)> = connection.prepare(
|
||||
"SELECT capability_token,status,revoked_at FROM merge_request_review_grants ORDER BY capability_token",
|
||||
).unwrap().query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.unwrap().collect::<Result<_, _>>().unwrap();
|
||||
assert_eq!(grants[0].0, "old-consumed");
|
||||
assert_eq!(grants[0].1, "consumed");
|
||||
assert_eq!(grants[1].0, "old-issued");
|
||||
assert_eq!(grants[1].1, "revoked");
|
||||
assert!(grants[1].2.is_some());
|
||||
let legacy_terms: i64 = connection.query_row(
|
||||
"SELECT COUNT(*) FROM merge_request_thread_events WHERE payload_json LIKE '%ticket_item_revision%'",
|
||||
[], |row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(legacy_terms, 0);
|
||||
let saved = store.get_by_id("W", "MR").unwrap();
|
||||
assert_eq!(saved.merged_result().unwrap(), &merged);
|
||||
let integration = saved.integration_approval().unwrap();
|
||||
assert_eq!(integration.event_id, approved.event_id);
|
||||
assert!(integration.ticket_content_digest.is_empty());
|
||||
let snapshot = vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "source".into(),
|
||||
}];
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&[saved],
|
||||
&content_digest("Title"),
|
||||
&snapshot,
|
||||
Some(&approved.event_id)
|
||||
),
|
||||
Err(MergeRequestEvidenceError::TicketContentMismatch),
|
||||
);
|
||||
assert!(matches!(
|
||||
store.authorize_review_submission("MR", "old-issued"),
|
||||
Err(MergeRequestError::Unauthorized(_)),
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "source".into(),
|
||||
capability_token: "old-issued".into(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: "late old review".into(),
|
||||
findings: vec![],
|
||||
now: at(6),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
store.complete(completion).unwrap(),
|
||||
merged,
|
||||
"recorded merge replay stays intact"
|
||||
);
|
||||
let fresh = approve(&store, "source", "fresh");
|
||||
assert_eq!(fresh.ticket_content_digest, content_digest("Title"));
|
||||
migrate(&connection).unwrap();
|
||||
let requests = store.list_for_ticket("W", "T").unwrap();
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&requests,
|
||||
&content_digest("Title"),
|
||||
&snapshot,
|
||||
Some(&fresh.event_id)
|
||||
)
|
||||
.unwrap(),
|
||||
&fresh,
|
||||
);
|
||||
assert_eq!(requests[0].merged_result().unwrap(), &merged);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT version FROM merge_request_schema", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
13
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v12_migration_rolls_back_payload_and_grant_changes_when_schema_update_fails() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
request(&store, "source", "old-issued");
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
freeze_v12_review_payloads(&connection);
|
||||
let before: String = connection
|
||||
.query_row(
|
||||
"SELECT payload_json FROM merge_request_thread_events WHERE kind='review_requested'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
connection.execute_batch(
|
||||
"CREATE TRIGGER reject_schema_update BEFORE UPDATE ON merge_request_schema BEGIN SELECT RAISE(ABORT,'injected migration failure'); END;",
|
||||
).unwrap();
|
||||
assert!(migrate(&connection).is_err());
|
||||
assert!(
|
||||
connection
|
||||
.prepare("SELECT * FROM merge_request_legacy_review_archive")
|
||||
.is_err(),
|
||||
"failed conversion must roll back its archive too"
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT version FROM merge_request_schema", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
12
|
||||
);
|
||||
assert_eq!(connection.query_row("SELECT payload_json FROM merge_request_thread_events WHERE kind='review_requested'", [], |row| row.get::<_, String>(0)).unwrap(), before);
|
||||
assert_eq!(connection.query_row("SELECT status FROM merge_request_review_grants WHERE capability_token='old-issued'", [], |row| row.get::<_, String>(0)).unwrap(), "issued");
|
||||
connection
|
||||
.execute_batch("DROP TRIGGER reject_schema_update")
|
||||
.unwrap();
|
||||
migrate(&connection).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.authorize_review_submission("MR", "old-issued")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn open_for(s: &MergeRequestStore, merge_request_id: &str, repository_id: &str) {
|
||||
s.open_merge_request(OpenMergeRequest {
|
||||
merge_request_id: merge_request_id.into(),
|
||||
@@ -108,7 +336,7 @@ fn request_for(
|
||||
.collect();
|
||||
s.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: merge_request_id.into(),
|
||||
ticket_item_revision: "t".into(),
|
||||
ticket_content_digest: content_digest("Title"),
|
||||
ticket_merge_request_subjects,
|
||||
ticket_id: "T".into(),
|
||||
subject_ref: subject.into(),
|
||||
@@ -236,13 +464,13 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
|
||||
assert!(active);
|
||||
let json = serde_json::to_string(&mr).unwrap();
|
||||
for banned in [
|
||||
"revision_id",
|
||||
"digest_id",
|
||||
"attempt_id",
|
||||
"base_commit",
|
||||
"head_commit",
|
||||
"source_commit",
|
||||
"result_commit",
|
||||
"current_revision",
|
||||
"current_digest",
|
||||
] {
|
||||
assert!(!json.contains(banned), "{banned} in {json}")
|
||||
}
|
||||
@@ -375,7 +603,7 @@ fn review_revocation_invalidates_readiness() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_schema_uses_version_12_and_reopens_as_current() {
|
||||
fn fresh_schema_uses_version_13_and_reopens_as_current() {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
c.execute_batch(
|
||||
"CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
|
||||
@@ -388,7 +616,7 @@ fn fresh_schema_uses_version_12_and_reopens_as_current() {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
12
|
||||
13
|
||||
);
|
||||
merge_request::migrate(&c).unwrap();
|
||||
}
|
||||
@@ -724,9 +952,8 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
|
||||
let stale = approve(&store, "subject", "token-stale");
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
[],
|
||||
.execute_batch(
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
@@ -750,7 +977,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
|
||||
readiness
|
||||
.blockers
|
||||
.iter()
|
||||
.any(|blocker| blocker.contains("Ticket revision"))
|
||||
.any(|blocker| blocker.contains("Ticket digest"))
|
||||
);
|
||||
|
||||
let stale_completion = CompleteMergeRequest {
|
||||
@@ -789,7 +1016,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
|
||||
.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
ticket_item_revision: "T:1".into(),
|
||||
ticket_content_digest: content_digest("Rescoped"),
|
||||
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "subject".into(),
|
||||
@@ -852,9 +1079,8 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
.unwrap();
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
[],
|
||||
.execute_batch(
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
@@ -873,11 +1099,11 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&before_refresh,
|
||||
"T:1",
|
||||
&content_digest("Rescoped"),
|
||||
&snapshot,
|
||||
Some(&integration_approval.event_id),
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
|
||||
Err(MergeRequestEvidenceError::TicketContentMismatch)
|
||||
);
|
||||
assert_eq!(before_refresh[0].merged_result().unwrap(), &merge);
|
||||
assert_eq!(
|
||||
@@ -899,7 +1125,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
ticket_item_revision: "T:1".into(),
|
||||
ticket_content_digest: content_digest("Rescoped"),
|
||||
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "subject".into(),
|
||||
@@ -927,7 +1153,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&refreshed,
|
||||
"T:1",
|
||||
&content_digest("Rescoped"),
|
||||
&snapshot,
|
||||
Some(&requirement_review.event_id),
|
||||
)
|
||||
@@ -935,7 +1161,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
&requirement_review
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(),
|
||||
requirement_approval(&refreshed, &content_digest("Rescoped"), &snapshot, None).unwrap(),
|
||||
&requirement_review
|
||||
);
|
||||
assert_eq!(refreshed[0].merged_result().unwrap(), &merge);
|
||||
@@ -970,7 +1196,7 @@ fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() {
|
||||
let result = store.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
ticket_item_revision: "t".into(),
|
||||
ticket_content_digest: content_digest("Title"),
|
||||
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "subject-one".into(),
|
||||
@@ -1058,7 +1284,13 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "t", &snapshot, Some(&second.event_id)).unwrap(),
|
||||
requirement_approval(
|
||||
&requests,
|
||||
&content_digest("Title"),
|
||||
&snapshot,
|
||||
Some(&second.event_id)
|
||||
)
|
||||
.unwrap(),
|
||||
&second
|
||||
);
|
||||
let first_request = requests
|
||||
@@ -1217,9 +1449,7 @@ fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_app
|
||||
match case {
|
||||
"source-mismatch" => altered.subject_ref = "different-source".into(),
|
||||
"not-approved" => altered.decision = ReviewDecision::RequestChanges,
|
||||
"request-mismatch" => {
|
||||
altered.ticket_item_revision = "different-revision".into()
|
||||
}
|
||||
"request-mismatch" => altered.ticket_content_digest = "different-digest".into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
connection.execute(
|
||||
|
||||
+45
-30
@@ -103,11 +103,15 @@ impl AuthenticatedInputSource {
|
||||
|
||||
/// Caller-owned identity for one state-changing Worker command.
|
||||
///
|
||||
/// A controller accepts command ids in strictly increasing order. Exact retries
|
||||
/// of an accepted id must retain the same command kind.
|
||||
/// Commands operate on the controller state at admission time, not a caller's
|
||||
/// previously observed Run. A controller accepts command ids in strictly
|
||||
/// increasing order; retrying an accepted id cannot apply it to a later Run.
|
||||
/// Exact retries must retain the same command kind. Removed preconditions are
|
||||
/// rejected at the wire boundary rather than treated as arrival-time commands.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerCommandEnvelope {
|
||||
pub command_id: u64,
|
||||
}
|
||||
@@ -312,16 +316,15 @@ pub enum Method {
|
||||
/// are immutable and therefore cannot be cancelled here.
|
||||
CancelPendingSubmission {
|
||||
submission_id: String,
|
||||
expected_revision: u64,
|
||||
},
|
||||
/// Remove every queued submission while preserving the active run.
|
||||
/// Remove exactly the observed ordered set of submissions, preserving the
|
||||
/// active run. Reject if the FIFO content changed before admission.
|
||||
ClearPendingSubmissions {
|
||||
expected_revision: u64,
|
||||
expected_submission_ids: Vec<String>,
|
||||
},
|
||||
/// Activate the next queued submission while the Worker is idle. This is an
|
||||
/// explicit recovery operation and never resumes a paused run implicitly.
|
||||
ContinuePending {
|
||||
expected_revision: u64,
|
||||
expected_head_id: String,
|
||||
},
|
||||
Resume {
|
||||
@@ -365,7 +368,7 @@ pub enum Method {
|
||||
ListCompletions {
|
||||
kind: CompletionKind,
|
||||
prefix: String,
|
||||
/// Client query generation. Echoed verbatim so identical-prefix ABA
|
||||
/// Client query identity. Echoed verbatim so identical-prefix ABA
|
||||
/// responses cannot cross edits, target switches, or permission changes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
@@ -766,7 +769,6 @@ pub enum InternalWorkerKind {
|
||||
pub struct CompactionLifecycle {
|
||||
pub schema_version: u32,
|
||||
pub compaction_id: String,
|
||||
pub revision: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub internal_worker: Option<InternalWorkerRef>,
|
||||
pub state: CompactionLifecycleState,
|
||||
@@ -806,14 +808,13 @@ pub struct InternalWorkerRef {
|
||||
pub kind: InternalWorkerKind,
|
||||
}
|
||||
|
||||
/// Reconnect state for one visible Internal Worker. The revision fences live
|
||||
/// `Event::InternalWorker` updates that raced with parent snapshot assembly.
|
||||
/// Reconnect state for one visible Internal Worker. The parent serializes
|
||||
/// snapshot publication and subsequent live events on the same ordered stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct InternalWorkerSnapshot {
|
||||
pub worker: InternalWorkerRef,
|
||||
pub revision: u64,
|
||||
pub session: SessionSnapshot,
|
||||
/// Public execution metadata for this child. Older snapshots and service
|
||||
/// workers may omit it; clients must never substitute the parent's values.
|
||||
@@ -869,12 +870,11 @@ pub struct PendingSubmissionSummary {
|
||||
pub byte_len: u64,
|
||||
}
|
||||
|
||||
/// Revisioned session-owned FIFO projection used by snapshots and live events.
|
||||
/// Session-owned FIFO content projection used by snapshots and live events.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct PendingSubmissionsSnapshot {
|
||||
pub revision: u64,
|
||||
#[serde(default)]
|
||||
pub notification_count: u32,
|
||||
/// Ordered display-only previews of waiting notifications, each bounded like
|
||||
@@ -1356,7 +1356,7 @@ pub enum Event {
|
||||
Snapshot {
|
||||
session: SessionSnapshot,
|
||||
greeting: Greeting,
|
||||
/// Full revisioned live execution state. `Stopped` remains Runtime
|
||||
/// Full live execution state. `Stopped` remains Runtime
|
||||
/// catalog authority and is deliberately not represented here.
|
||||
state: WorkerStateSnapshot,
|
||||
/// Unfinished model output that has already streamed in the current
|
||||
@@ -1370,10 +1370,10 @@ pub enum Event {
|
||||
},
|
||||
/// A live event from a parent-owned Internal Worker. The payload reuses the
|
||||
/// normal Worker event vocabulary while the wrapper carries stable origin
|
||||
/// identity and a per-child revision fence.
|
||||
/// identity. Apply in arrival order on the parent's serialized stream; a
|
||||
/// reconnect snapshot replaces the projection before live events resume.
|
||||
InternalWorker {
|
||||
worker: InternalWorkerRef,
|
||||
revision: u64,
|
||||
event: Box<Event>,
|
||||
},
|
||||
/// Terminal removal fence for one parent-owned Internal Worker session.
|
||||
@@ -1383,7 +1383,6 @@ pub enum Event {
|
||||
/// the projection.
|
||||
InternalWorkerRemoved {
|
||||
worker: InternalWorkerRef,
|
||||
revision: u64,
|
||||
},
|
||||
/// Server-side segment log rotated to a fresh `SegmentStart`.
|
||||
///
|
||||
@@ -2276,6 +2275,28 @@ mod tests {
|
||||
assert!(error.to_string().contains("params"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_commands_reject_removed_nested_preconditions() {
|
||||
for method in ["pause", "cancel", "resume", "compact", "shutdown"] {
|
||||
let wire = serde_json::json!({
|
||||
"method": method,
|
||||
"params": { "command": { "command_id": 11 } }
|
||||
});
|
||||
// Current commands are arrival-time operations, not Run-bound CAS.
|
||||
assert!(serde_json::from_value::<Method>(wire.clone()).is_ok());
|
||||
for field in [
|
||||
"expected_execution_generation",
|
||||
"expected_worker_state_revision",
|
||||
] {
|
||||
let mut old_command = wire.clone();
|
||||
old_command["params"]["command"][field] = serde_json::json!(1);
|
||||
let error = serde_json::from_value::<Method>(old_command)
|
||||
.expect_err("removed command preconditions must not be silently ignored");
|
||||
assert!(error.to_string().contains(field), "{method}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_methods_roundtrip_with_command_identity() {
|
||||
for method in [
|
||||
@@ -3048,7 +3069,6 @@ mod tests {
|
||||
CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state,
|
||||
started_at_ms: 1_700_000_000_000,
|
||||
@@ -3069,7 +3089,9 @@ mod tests {
|
||||
assert_eq!(parsed["event"], "compact_start");
|
||||
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
|
||||
assert!(
|
||||
matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.state == CompactionLifecycleState::Running)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3292,7 +3314,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 7,
|
||||
event: Box::new(Event::TextDone {
|
||||
text: "result".into(),
|
||||
}),
|
||||
@@ -3300,14 +3321,9 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::InternalWorker {
|
||||
worker,
|
||||
revision,
|
||||
event,
|
||||
} => {
|
||||
Event::InternalWorker { worker, event } => {
|
||||
assert_eq!(worker.session_id, "session-1");
|
||||
assert_eq!(worker.parent_session_id.as_deref(), Some("parent-session"));
|
||||
assert_eq!(revision, 7);
|
||||
assert!(matches!(*event, Event::TextDone { ref text } if text == "result"));
|
||||
}
|
||||
other => panic!("expected internal Worker event, got {other:?}"),
|
||||
@@ -3323,21 +3339,20 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 8,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
Event::InternalWorkerRemoved { worker, revision }
|
||||
if worker.session_id == "session-1" && revision == 8
|
||||
Event::InternalWorkerRemoved { worker }
|
||||
if worker.session_id == "session-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_notification_previews_are_optional_and_roundtrip() {
|
||||
let legacy = serde_json::json!({
|
||||
"revision": 1, "notification_count": 1,
|
||||
"notification_count": 1,
|
||||
"head_id": "notification-head", "submissions": []
|
||||
});
|
||||
let mut snapshot: PendingSubmissionsSnapshot =
|
||||
|
||||
@@ -322,7 +322,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
parent_session_id: Some("fixture-parent-session".into()),
|
||||
kind: InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
|
||||
@@ -250,7 +250,6 @@ pub struct WorkingDirectoryRepository {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<RepositorySelector>,
|
||||
@@ -291,9 +290,6 @@ pub struct BackendResourceHandle {
|
||||
pub operation: BackendResourceOperation,
|
||||
pub expires_at_unix_seconds: i64,
|
||||
pub nonce: String,
|
||||
pub revision: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation: Option<u64>,
|
||||
pub max_bytes: u64,
|
||||
pub content_type: String,
|
||||
pub redaction: ResourceRedactionPolicy,
|
||||
@@ -305,14 +301,14 @@ pub struct BackendResourceHandle {
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct RepositorySshCredentialCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub public_key_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||
pub host_trust_id: String,
|
||||
pub host_trust_revision: u64,
|
||||
pub host_key_fingerprint: String,
|
||||
pub access: RepositoryAccessMode,
|
||||
pub expires_at_epoch_seconds: u64,
|
||||
pub repository_id: String,
|
||||
@@ -326,7 +322,6 @@ pub struct RepositoryMaterializationContext {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub operation_id: String,
|
||||
pub config_revision: u64,
|
||||
pub config_projection_digest: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||
@@ -427,7 +422,7 @@ pub struct BackendJobExecutionBinding {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_revision: Option<String>,
|
||||
pub input_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subjektiv_consolidation: bool,
|
||||
}
|
||||
@@ -689,13 +684,11 @@ pub struct WorkerRetentionExecutionRequest {
|
||||
pub workspace_id: String,
|
||||
pub source_runtime_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub expected_worker_revision: String,
|
||||
pub source_created_at: String,
|
||||
pub removed_at: String,
|
||||
pub effective_profile: Option<String>,
|
||||
pub retention_class: Option<String>,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||
}
|
||||
@@ -718,7 +711,6 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub content_bytes: u64,
|
||||
pub content_file_count: u64,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
}
|
||||
@@ -727,7 +719,6 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub struct WorkerRetentionExecutionResult {
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
pub expected_worker_revision: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||
|
||||
@@ -41,6 +41,39 @@ fn canonical_positive_decimal(value: &str) -> bool {
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& value.parse::<i64>().is_ok_and(|value| value > 0)
|
||||
}
|
||||
fn node_mutation_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.len() > 128 || value.chars().any(char::is_control) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"invalid committed mutation request ID",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn mutation_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let value = node_mutation_id(deserializer)?;
|
||||
if value.is_empty() {
|
||||
return Err(serde::de::Error::custom(
|
||||
"expected a committed mutation request ID",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn optional_mutation_id<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<String>, D::Error> {
|
||||
Option::<String>::deserialize(deserializer)?
|
||||
.map(|value| {
|
||||
if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
|
||||
Err(serde::de::Error::custom(
|
||||
"invalid committed mutation request ID",
|
||||
))
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.len() > DRIVE_TEXT_MAX_BYTES {
|
||||
@@ -110,9 +143,10 @@ pub struct DriveEntry {
|
||||
pub parent: Option<DriveEntryRef>,
|
||||
pub name: String,
|
||||
pub kind: DriveEntryKind,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub revision: String,
|
||||
#[serde(deserialize_with = "node_mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
/// Request ID of the last committed mutation, or empty for the immutable root.
|
||||
pub last_mutation_id: String,
|
||||
/// File length in bytes; absent for folders.
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub size: Option<u32>,
|
||||
@@ -201,10 +235,10 @@ pub struct DriveReadChunkQuery {
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
/// Bind a multi-chunk read to the same file revision.
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub expected_revision: String,
|
||||
/// Require each chunk to match the same committed mutation observed in metadata.
|
||||
#[serde(deserialize_with = "mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
pub expected_mutation_id: String,
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub offset: u32,
|
||||
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
|
||||
@@ -219,8 +253,8 @@ pub struct DriveDownloadQuery {
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub expected_revision: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_mutation_id")]
|
||||
pub expected_mutation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -248,26 +282,26 @@ pub enum DriveMutation {
|
||||
},
|
||||
UpdateText {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
#[serde(deserialize_with = "mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
expected_mutation_id: String,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
text: String,
|
||||
content_type: String,
|
||||
},
|
||||
Relocate {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
#[serde(deserialize_with = "mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
expected_mutation_id: String,
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
},
|
||||
Delete {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
#[serde(deserialize_with = "mutation_id")]
|
||||
#[schemars(length(max = 128))]
|
||||
expected_mutation_id: String,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -287,8 +321,8 @@ pub enum DriveUploadOperation {
|
||||
Update,
|
||||
}
|
||||
/// Flat query metadata accompanies the binary body. The service rejects invalid
|
||||
/// combinations: create requires parent_id/name and forbids id/expected_revision;
|
||||
/// update requires id/expected_revision and forbids parent_id/name. Both targets
|
||||
/// combinations: create requires parent_id/name and forbids id/expected_mutation_id;
|
||||
/// update requires id/expected_mutation_id and forbids parent_id/name. Both targets
|
||||
/// are bound by entry_workspace_id; declared size and SHA256 must match the body.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -303,8 +337,8 @@ pub struct DriveUploadQuery {
|
||||
pub name: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub expected_revision: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_mutation_id")]
|
||||
pub expected_mutation_id: Option<String>,
|
||||
pub content_type: String,
|
||||
/// Values above the file limit still decode as u32 so the service returns
|
||||
/// typed Limit rather than a generic request-decoding error.
|
||||
@@ -422,7 +456,7 @@ impl DriveApiError {
|
||||
message: match code {
|
||||
Denied => "Drive access denied",
|
||||
NotFound => "Drive resource not found",
|
||||
Conflict => "Drive revision or name conflict",
|
||||
Conflict => "Drive content, location, or name conflict",
|
||||
Invalid => "Invalid Drive request",
|
||||
Limit => "Drive request exceeds a limit",
|
||||
StorageUnavailable => "Drive storage unavailable",
|
||||
@@ -614,7 +648,7 @@ mod tests {
|
||||
let value = json!({
|
||||
"entry":{"workspace_id":"a","node_id":"7"},
|
||||
"parent":{"workspace_id":"a","node_id":"1"},
|
||||
"name":"file.txt","kind":"file","revision":"2","size":5,
|
||||
"name":"file.txt","kind":"file","last_mutation_id":"2","size":5,
|
||||
"content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z",
|
||||
"latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7"
|
||||
});
|
||||
@@ -665,7 +699,7 @@ mod tests {
|
||||
fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() {
|
||||
let valid = json!({"request_id":"request-a", "mutation":{
|
||||
"operation":"update_text", "id":{"workspace_id":"a","node_id":"7"},
|
||||
"expected_revision":"9007199254740993", "text":"hello", "content_type":"text/plain"
|
||||
"expected_mutation_id":"write-資料/one", "text":"hello", "content_type":"text/plain"
|
||||
}});
|
||||
let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), valid);
|
||||
@@ -675,9 +709,16 @@ mod tests {
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["id"] = json!("7");
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
for bad in [
|
||||
"".to_string(),
|
||||
"\ncontrol".to_string(),
|
||||
"x".repeat(129),
|
||||
"é".repeat(65),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["expected_revision"] = json!("01");
|
||||
invalid["mutation"]["expected_mutation_id"] = json!(bad);
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
}
|
||||
// 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"),
|
||||
(
|
||||
|
||||
+236
-316
File diff suppressed because it is too large
Load Diff
@@ -1996,33 +1996,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
|
||||
let mut fixture: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
fixture["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"] = serde_json::json!({"type": "integer", "format": "uint64", "minimum": 0, "maximum": 9_007_199_254_740_991_u64});
|
||||
fixture["components"]["schemas"]["RepositorySummary"]["required"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(serde_json::json!("size_bytes"));
|
||||
let mut document = fixture.clone();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] =
|
||||
serde_json::json!(9_007_199_254_740_992_u64);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("safe-number"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] =
|
||||
let mut document = fixture.clone();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["minimum"] =
|
||||
serde_json::json!(-1);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("safe-number"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
|
||||
let mut document = fixture.clone();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] =
|
||||
serde_json::json!(9_007_199_254_740_990_u64);
|
||||
let output = generate_repository_typescript(&document.to_string()).unwrap();
|
||||
assert!(output.contains(
|
||||
"source_revision: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }"
|
||||
"size_bytes: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }"
|
||||
));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
|
||||
let mut document = fixture.clone();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["format"] =
|
||||
serde_json::json!("int64");
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("unsupported integer format"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
let mut document = fixture.clone();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
|
||||
@@ -82,9 +82,6 @@ pub struct WorkspaceConfigNode {
|
||||
pub struct WorkspaceConfigObserveResponse {
|
||||
pub connection_id: String,
|
||||
pub validator: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub revision: u64,
|
||||
pub digest: String,
|
||||
/// Canonical commit entrypoints from this exact metadata snapshot.
|
||||
pub entrypoints: Vec<String>,
|
||||
@@ -121,9 +118,6 @@ pub struct WorkspaceConfigCommitRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigCommitResponse {
|
||||
pub validator: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub revision: u64,
|
||||
pub digest: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -178,11 +172,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() {
|
||||
let value = serde_json::json!({"connection_id":"c", "validator":"v", "revision":7, "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]});
|
||||
let value = serde_json::json!({"connection_id":"c", "validator":"v", "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]});
|
||||
let response: WorkspaceConfigObserveResponse =
|
||||
serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||
for field in ["revision", "digest", "entrypoints"] {
|
||||
for field in ["validator", "digest", "entrypoints"] {
|
||||
let mut missing = value.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(missing).is_err());
|
||||
@@ -208,6 +202,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_commit_response_is_content_based() {
|
||||
let value = serde_json::json!({"validator":"v", "digest":"d"});
|
||||
let response: WorkspaceConfigCommitResponse =
|
||||
serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||
for field in ["validator", "digest"] {
|
||||
let mut missing = value.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(serde_json::from_value::<WorkspaceConfigCommitResponse>(missing).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() {
|
||||
for classification in [
|
||||
|
||||
@@ -51,7 +51,9 @@ pub enum LoggedSessionHistoryOrigin {
|
||||
FlowInstruction {
|
||||
selector: String,
|
||||
definition_id: String,
|
||||
definition_revision: u64,
|
||||
/// Exact compiled source used for these instructions; absent in older logs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
definition_digest: Option<String>,
|
||||
instance_id: String,
|
||||
state_id: String,
|
||||
},
|
||||
|
||||
@@ -3662,7 +3662,7 @@ mod tests {
|
||||
LoggedSessionHistoryOrigin::FlowInstruction {
|
||||
selector: "builtin:coder-review".into(),
|
||||
definition_id: "flow-definition".into(),
|
||||
definition_revision: 7,
|
||||
definition_digest: Some("sha256:flow-source".into()),
|
||||
instance_id: "flow-instance".into(),
|
||||
state_id: "implement".into(),
|
||||
},
|
||||
@@ -3695,7 +3695,7 @@ mod tests {
|
||||
origin: LoggedSessionHistoryOrigin::FlowInstruction {
|
||||
selector: "builtin:coder-review".into(),
|
||||
definition_id: "flow-definition".into(),
|
||||
definition_revision: 7,
|
||||
definition_digest: Some("sha256:flow-source".into()),
|
||||
instance_id: "flow-instance".into(),
|
||||
state_id: "implement".into(),
|
||||
},
|
||||
|
||||
@@ -1156,7 +1156,7 @@ mod tests {
|
||||
)))],
|
||||
extensions: vec![SessionExtension::new(
|
||||
"flow.runtime.v1",
|
||||
serde_json::json!({ "state": "implement", "revision": 0 }),
|
||||
serde_json::json!({ "state": "implement" }),
|
||||
)],
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
|
||||
@@ -77,7 +77,6 @@ impl SystemReminder {
|
||||
pub struct PromptRenderProvenance {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
pub config_revision: u64,
|
||||
pub source_digest: String,
|
||||
pub projection_digest: String,
|
||||
pub logical_name: String,
|
||||
@@ -176,10 +175,14 @@ pub enum SystemItem {
|
||||
},
|
||||
|
||||
/// A Host-fetched change to user-managed Subject behavior. Each changed
|
||||
/// revision is appended rather than rewriting prior prompt/session content.
|
||||
/// body is appended rather than rewriting prior prompt/session content.
|
||||
SubjectBehaviorRefresh {
|
||||
body: String,
|
||||
behavior_revision: u64,
|
||||
/// Exact user-managed behavior acknowledged after durable commit. Old
|
||||
/// logs omit this value; an empty value is not an acknowledgement of a
|
||||
/// nonempty behavior and does not replace their stored rendered body.
|
||||
#[serde(default)]
|
||||
behavior_md: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
@@ -292,22 +295,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
|
||||
fn subject_behavior_refresh_round_trips_with_exact_body() {
|
||||
let item = SystemItem::SubjectBehaviorRefresh {
|
||||
body: "current Subject context".to_string(),
|
||||
behavior_revision: 7,
|
||||
behavior_md: "exact behavior".to_string(),
|
||||
prompt_provenance: None,
|
||||
};
|
||||
let raw = serde_json::to_string(&item).unwrap();
|
||||
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed.history_text(), "current Subject context");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision: 7,
|
||||
..
|
||||
assert!(
|
||||
matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior")
|
||||
);
|
||||
}
|
||||
));
|
||||
|
||||
#[test]
|
||||
fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() {
|
||||
let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#;
|
||||
let item: SystemItem = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(item.history_text(), "exact archived body");
|
||||
assert!(
|
||||
serde_json::to_value(item)
|
||||
.unwrap()
|
||||
.get("behavior_revision")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,7 +13,10 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
const SCHEMA_VERSION: i64 = 2;
|
||||
#[path = "job_store/legacy_migrations.rs"]
|
||||
mod legacy_migrations;
|
||||
|
||||
const SCHEMA_VERSION: i64 = 3;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -69,7 +72,10 @@ impl JobSnapshot {
|
||||
pub fn outcome(&self) -> job::JobOutcome {
|
||||
job::JobOutcome {
|
||||
job_id: self.request.job_id.clone(),
|
||||
input_revision: self.request.input_revision.clone(),
|
||||
input_digest: self
|
||||
.request
|
||||
.input_digest()
|
||||
.expect("validated immutable Job input"),
|
||||
attempt_id: self.attempt.attempt_id.clone(),
|
||||
attempt: self.attempt.number,
|
||||
state: match self.state {
|
||||
@@ -135,8 +141,8 @@ pub enum JobStoreError {
|
||||
ConcurrentLimit,
|
||||
#[error("result or lifecycle operation is not bound to the current Job attempt")]
|
||||
AttemptMismatch,
|
||||
#[error("result input revision does not match the immutable request")]
|
||||
RevisionMismatch,
|
||||
#[error("result input digest does not match the immutable request")]
|
||||
DigestMismatch,
|
||||
#[error("accepted Job result cannot be changed")]
|
||||
ResultConflict,
|
||||
#[error("invalid Job transition: {0}")]
|
||||
@@ -176,8 +182,11 @@ impl JobStore {
|
||||
tx.execute_batch(GRANT_SCHEMA)?;
|
||||
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||
}
|
||||
1 => {
|
||||
1 | 2 => {
|
||||
if version == 1 {
|
||||
tx.execute_batch(GRANT_SCHEMA)?;
|
||||
}
|
||||
legacy_migrations::migrate_legacy_jobs(&tx)?;
|
||||
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||
}
|
||||
SCHEMA_VERSION => {}
|
||||
@@ -370,8 +379,8 @@ impl JobStore {
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let snapshot = require_snapshot(&tx, &bound.job_id)?;
|
||||
check_attempt(&snapshot, &bound.attempt_id)?;
|
||||
if snapshot.request.input_revision != bound.input_revision {
|
||||
return Err(JobStoreError::RevisionMismatch);
|
||||
if snapshot.request.input_digest()? != bound.input_digest {
|
||||
return Err(JobStoreError::DigestMismatch);
|
||||
}
|
||||
let (digest, encoded) =
|
||||
job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?;
|
||||
@@ -527,13 +536,13 @@ fn insert_attempt(
|
||||
number: u8,
|
||||
) -> Result<(), JobStoreError> {
|
||||
conn.execute(
|
||||
"INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state)
|
||||
"INSERT INTO job_attempts (job_id, number, attempt_id, input_digest, state)
|
||||
VALUES (?1, ?2, ?3, ?4, 'reserved')",
|
||||
params![
|
||||
request.job_id,
|
||||
number,
|
||||
job::attempt_id(&request.job_id, number),
|
||||
request.input_revision
|
||||
request.input_digest()?
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -669,7 +678,7 @@ CREATE TABLE job_attempts (
|
||||
job_id TEXT NOT NULL REFERENCES job_intents(job_id),
|
||||
number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255),
|
||||
attempt_id TEXT NOT NULL UNIQUE,
|
||||
input_revision TEXT NOT NULL,
|
||||
input_digest TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown', 'cancelled')),
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
@@ -685,7 +694,7 @@ CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF
|
||||
job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents
|
||||
BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END;
|
||||
CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF
|
||||
job_id, number, attempt_id, input_revision ON job_attempts
|
||||
job_id, number, attempt_id, input_digest ON job_attempts
|
||||
BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END;
|
||||
CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts
|
||||
WHEN OLD.result_json IS NOT NULL AND
|
||||
@@ -705,7 +714,6 @@ mod tests {
|
||||
JobRequest {
|
||||
job_id: id.into(),
|
||||
purpose: "fixture".into(),
|
||||
input_revision: "revision-1".into(),
|
||||
input_ref: "fixture:input".into(),
|
||||
input: json!({"immutable": [1, 2, 3]}),
|
||||
instruction: "Produce a structured result".into(),
|
||||
@@ -725,7 +733,10 @@ mod tests {
|
||||
JobResultSubmission {
|
||||
job_id: snapshot.request.job_id.clone(),
|
||||
attempt_id: snapshot.attempt.attempt_id.clone(),
|
||||
input_revision: snapshot.request.input_revision.clone(),
|
||||
input_digest: snapshot
|
||||
.request
|
||||
.input_digest()
|
||||
.expect("validated immutable Job input"),
|
||||
result,
|
||||
}
|
||||
}
|
||||
@@ -762,10 +773,10 @@ mod tests {
|
||||
for sql in [
|
||||
"UPDATE job_intents SET request_json = '{}' WHERE job_id = 'identity'",
|
||||
"UPDATE job_intents SET fingerprint = 'changed' WHERE job_id = 'identity'",
|
||||
"UPDATE job_attempts SET input_revision = 'changed' WHERE job_id = 'identity'",
|
||||
"UPDATE job_attempts SET input_digest = 'changed' WHERE job_id = 'identity'",
|
||||
"UPDATE job_attempts SET attempt_id = 'changed' WHERE job_id = 'identity'",
|
||||
"UPDATE job_intents SET current_attempt = 2 WHERE job_id = 'identity'",
|
||||
"INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state)
|
||||
"INSERT INTO job_attempts (job_id, number, attempt_id, input_digest, state)
|
||||
VALUES ('absent', 1, 'absent:attempt:1', 'r', 'reserved')",
|
||||
] {
|
||||
assert!(
|
||||
@@ -818,9 +829,6 @@ mod tests {
|
||||
assert_eq!(store.reserve(original.clone()).unwrap(), snapshot);
|
||||
let mut variants = Vec::new();
|
||||
let mut changed = original.clone();
|
||||
changed.input_revision = "revision-2".into();
|
||||
variants.push(changed);
|
||||
let mut changed = original.clone();
|
||||
changed.input = json!({"changed": true});
|
||||
variants.push(changed);
|
||||
let mut changed = original.clone();
|
||||
@@ -917,12 +925,16 @@ mod tests {
|
||||
Err(JobStoreError::AttemptMismatch)
|
||||
));
|
||||
let prior: (String, String, String) = store.conn.query_row(
|
||||
"SELECT state, failure, input_revision FROM job_attempts WHERE job_id = 'retry' AND number = 1",
|
||||
"SELECT state, failure, input_digest FROM job_attempts WHERE job_id = 'retry' AND number = 1",
|
||||
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
prior,
|
||||
("failed".into(), "timeout".into(), "revision-1".into())
|
||||
(
|
||||
"failed".into(),
|
||||
"timeout".into(),
|
||||
original.input_digest().unwrap()
|
||||
)
|
||||
);
|
||||
store
|
||||
.finish(
|
||||
@@ -960,10 +972,10 @@ mod tests {
|
||||
Err(JobStoreError::AttemptMismatch)
|
||||
));
|
||||
let mut invalid = valid.clone();
|
||||
invalid.input_revision = "revision-2".into();
|
||||
invalid.input_digest = "wrong-digest".into();
|
||||
assert!(matches!(
|
||||
store.accept(&invalid),
|
||||
Err(JobStoreError::RevisionMismatch)
|
||||
Err(JobStoreError::DigestMismatch)
|
||||
));
|
||||
let mut invalid = valid.clone();
|
||||
invalid.result = json!("x".repeat(32));
|
||||
@@ -1537,7 +1549,7 @@ mod tests {
|
||||
let absent = JobResultSubmission {
|
||||
job_id: "missing".into(),
|
||||
attempt_id: "attempt".into(),
|
||||
input_revision: "r".into(),
|
||||
input_digest: request("missing").input_digest().unwrap(),
|
||||
result: json!({}),
|
||||
};
|
||||
assert!(matches!(
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
use super::*;
|
||||
|
||||
/// Frozen schema-1/2 adapter. Preserve original intent and attempt bindings
|
||||
/// before replacing counters with identities derived from immutable input.
|
||||
pub(super) fn migrate_legacy_jobs(conn: &Connection) -> Result<(), JobStoreError> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE legacy_job_archive (
|
||||
job_id TEXT PRIMARY KEY NOT NULL,
|
||||
request_json TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
attempts_json TEXT NOT NULL
|
||||
);
|
||||
DROP TRIGGER job_intent_immutable;
|
||||
DROP TRIGGER job_attempt_immutable;",
|
||||
)?;
|
||||
let intents = conn
|
||||
.prepare("SELECT job_id, request_json, fingerprint FROM job_intents")?
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for (id, original, fingerprint) in intents {
|
||||
let attempts = conn.prepare(
|
||||
"SELECT number, attempt_id, input_revision, state, result_json, result_digest, failure
|
||||
FROM job_attempts WHERE job_id = ?1 ORDER BY number",
|
||||
)?.query_map([&id], |r| Ok(serde_json::json!({
|
||||
"number":r.get::<_, u8>(0)?, "attempt_id":r.get::<_, String>(1)?,
|
||||
"input_revision":r.get::<_, String>(2)?, "state":r.get::<_, String>(3)?,
|
||||
"result_json":r.get::<_, Option<String>>(4)?, "result_digest":r.get::<_, Option<String>>(5)?,
|
||||
"failure":r.get::<_, Option<String>>(6)?
|
||||
})))?.collect::<Result<Vec<_>, _>>()?;
|
||||
conn.execute(
|
||||
"INSERT INTO legacy_job_archive VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, original, fingerprint, serde_json::to_string(&attempts)?],
|
||||
)?;
|
||||
let mut value: Value = serde_json::from_str(&original)?;
|
||||
value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| JobStoreError::Corrupt("legacy request is not an object".into()))?
|
||||
.remove("input_revision");
|
||||
let request: JobRequest = serde_json::from_value(value)?;
|
||||
if request.job_id != id {
|
||||
return Err(JobStoreError::Corrupt(
|
||||
"legacy request Job id mismatch".into(),
|
||||
));
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE job_intents SET request_json = ?2, fingerprint = ?3 WHERE job_id = ?1",
|
||||
params![id, serde_json::to_string(&request)?, request.fingerprint()?],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE job_attempts SET input_revision = ?2 WHERE job_id = ?1",
|
||||
params![id, request.input_digest()?],
|
||||
)?;
|
||||
}
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE job_attempts RENAME COLUMN input_revision TO input_digest;
|
||||
CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF
|
||||
job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents
|
||||
BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END;
|
||||
CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF
|
||||
job_id, number, attempt_id, input_digest ON job_attempts
|
||||
BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END;
|
||||
CREATE TRIGGER legacy_job_archive_immutable BEFORE UPDATE ON legacy_job_archive
|
||||
BEGIN SELECT RAISE(ABORT, 'immutable legacy Job archive'); END;
|
||||
CREATE TRIGGER legacy_job_archive_retained BEFORE DELETE ON legacy_job_archive
|
||||
BEGIN SELECT RAISE(ABORT, 'retained legacy Job archive'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn request(id: &str) -> JobRequest {
|
||||
JobRequest {
|
||||
job_id: id.into(),
|
||||
purpose: "migration".into(),
|
||||
input_ref: "fixture:input".into(),
|
||||
input: json!({"immutable":[1,2,3]}),
|
||||
instruction: "Return a result".into(),
|
||||
profile: "builtin:job".into(),
|
||||
serialization_key: None,
|
||||
limits: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seed(path: &Path, version: i64) -> (String, String, String) {
|
||||
let mut conn = Connection::open(path).unwrap();
|
||||
feature_storage::configure_connection(&conn).unwrap();
|
||||
let tx = conn.transaction().unwrap();
|
||||
tx.execute_batch(&SCHEMA.replace("input_digest", "input_revision"))
|
||||
.unwrap();
|
||||
if version == 2 {
|
||||
tx.execute_batch(GRANT_SCHEMA).unwrap();
|
||||
}
|
||||
let mut original = serde_json::to_value(request("done")).unwrap();
|
||||
original["input_revision"] = json!("old-token");
|
||||
let original = serde_json::to_string_pretty(&original).unwrap();
|
||||
let receipt = "{\"accepted\":true,\"input_revision\":\"receipt-domain-data\"}".to_string();
|
||||
let digest = job::result_digest(
|
||||
&serde_json::from_str(&receipt).unwrap(),
|
||||
job::ABSOLUTE_MAX_RESULT_BYTES,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
tx.execute("INSERT INTO job_intents (job_id,request_json,fingerprint,max_concurrent,state,current_attempt,acknowledged)
|
||||
VALUES ('done',?1,'legacy-fingerprint',8,'completed',2,1)", [&original]).unwrap();
|
||||
tx.execute(
|
||||
"INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state,failure)
|
||||
VALUES ('done',1,'done:attempt:1','old-token','failed','timeout')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
tx.execute("INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state,result_json,result_digest)
|
||||
VALUES ('done',2,'done:attempt:2','old-token','completed',?1,?2)", params![receipt,digest]).unwrap();
|
||||
let grant = "{\"subject_id\":\"local-subject\",\"candidate_ids\":[\"c1\"]}".to_string();
|
||||
if version == 2 {
|
||||
tx.execute("INSERT INTO job_domain_grants VALUES ('done',?1)", [&grant])
|
||||
.unwrap();
|
||||
}
|
||||
for (id, state) in [("pending", "reserved"), ("running", "dispatched")] {
|
||||
let mut value = serde_json::to_value(request(id)).unwrap();
|
||||
value["input_revision"] = json!("old-token");
|
||||
tx.execute("INSERT INTO job_intents (job_id,request_json,fingerprint,max_concurrent,state,current_attempt)
|
||||
VALUES (?1,?2,'legacy-fingerprint',8,'pending',1)", params![id,serde_json::to_string(&value).unwrap()]).unwrap();
|
||||
tx.execute(
|
||||
"INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state)
|
||||
VALUES (?1,1,?2,'old-token',?3)",
|
||||
params![id, job::attempt_id(id, 1), state],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
tx.pragma_update(None, "user_version", version).unwrap();
|
||||
tx.commit().unwrap();
|
||||
(original, receipt, grant)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_versions_preserve_intent_receipts_attempts_grants_and_replay() {
|
||||
for version in [1, 2] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("jobs.sqlite3");
|
||||
let (original, receipt, grant) = seed(&path, version);
|
||||
let mut store = JobStore::open(&path).unwrap();
|
||||
let grant_value =
|
||||
(version == 2).then(|| serde_json::from_str::<Value>(&grant).unwrap());
|
||||
let snapshot = store
|
||||
.reserve_granted(request("done"), grant_value.clone())
|
||||
.unwrap();
|
||||
assert!(snapshot.acknowledged);
|
||||
assert_eq!(snapshot.state, JobState::Completed);
|
||||
assert_eq!(snapshot.attempt.number, 2);
|
||||
assert_eq!(
|
||||
snapshot.result,
|
||||
Some(serde_json::from_str(&receipt).unwrap())
|
||||
);
|
||||
let replay = JobResultSubmission {
|
||||
job_id: "done".into(),
|
||||
attempt_id: snapshot.attempt.attempt_id.clone(),
|
||||
input_digest: request("done").input_digest().unwrap(),
|
||||
result: snapshot.result.clone().unwrap(),
|
||||
};
|
||||
assert_eq!(store.accept(&replay).unwrap(), snapshot);
|
||||
let mut stale = replay.clone();
|
||||
stale.attempt_id = "done:attempt:1".into();
|
||||
assert!(matches!(
|
||||
store.accept(&stale),
|
||||
Err(JobStoreError::AttemptMismatch)
|
||||
));
|
||||
let mut changed = request("done");
|
||||
changed.input = json!({"different":true});
|
||||
assert!(matches!(
|
||||
store.reserve_granted(changed, grant_value.clone()),
|
||||
Err(JobStoreError::IntentConflict(_))
|
||||
));
|
||||
let archived: (String, String, String) = store.conn.query_row(
|
||||
"SELECT request_json,fingerprint,attempts_json FROM legacy_job_archive WHERE job_id='done'", [],
|
||||
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap();
|
||||
assert_eq!(archived.0, original);
|
||||
assert_eq!(archived.1, "legacy-fingerprint");
|
||||
let attempts: Value = serde_json::from_str(&archived.2).unwrap();
|
||||
assert_eq!(attempts[0]["failure"], "timeout");
|
||||
assert_eq!(attempts[1]["input_revision"], "old-token");
|
||||
assert_eq!(attempts[1]["result_json"], receipt);
|
||||
if version == 2 {
|
||||
let persisted: String = store
|
||||
.conn
|
||||
.query_row("SELECT grant_json FROM job_domain_grants", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(persisted, grant);
|
||||
}
|
||||
let digests: Vec<String> = store
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT input_digest FROM job_attempts WHERE job_id='done' ORDER BY number",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(digests, vec![request("done").input_digest().unwrap(); 2]);
|
||||
assert_eq!(store.get("running").unwrap().state, JobState::Unknown);
|
||||
assert_eq!(
|
||||
store.reserve(request("pending")).unwrap().attempt.state,
|
||||
JobAttemptState::Reserved
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.conn
|
||||
.execute_batch("DELETE FROM legacy_job_archive")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.conn
|
||||
.execute_batch("UPDATE legacy_job_archive SET fingerprint='changed'")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.conn
|
||||
.execute_batch("UPDATE job_intents SET fingerprint='changed'")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.conn
|
||||
.execute_batch("UPDATE job_attempts SET input_digest='changed'")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.unwrap()
|
||||
.exists([])
|
||||
.unwrap()
|
||||
);
|
||||
drop(store);
|
||||
let mut store = JobStore::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
store.reserve_granted(request("done"), grant_value).unwrap(),
|
||||
snapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_migration_rolls_back_schema_archive_and_immutable_triggers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("jobs.sqlite3");
|
||||
seed(&path, 2);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP TRIGGER job_intent_immutable;
|
||||
UPDATE job_intents SET request_json='{}' WHERE job_id='running';
|
||||
CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF request_json ON job_intents
|
||||
BEGIN SELECT RAISE(ABORT,'immutable'); END;",
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
assert!(JobStore::open(&path).is_err());
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
conn.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
assert!(conn.prepare("SELECT * FROM legacy_job_archive").is_err());
|
||||
let binding: String = conn
|
||||
.query_row(
|
||||
"SELECT input_revision FROM job_attempts WHERE job_id='done' AND number=2",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(binding, "old-token");
|
||||
assert!(
|
||||
conn.execute_batch("UPDATE job_intents SET request_json='{}' WHERE job_id='done'")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
conn.execute_batch("UPDATE job_attempts SET input_revision='changed'")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,10 @@ impl StandaloneJobs {
|
||||
store: self.store.clone(),
|
||||
job_id: job_id.into(),
|
||||
attempt_id: snapshot.attempt.attempt_id.clone(),
|
||||
input_revision: snapshot.request.input_revision.clone(),
|
||||
input_digest: snapshot
|
||||
.request
|
||||
.input_digest()
|
||||
.map_err(JobStoreError::from)?,
|
||||
deadline,
|
||||
});
|
||||
let grant = match (
|
||||
@@ -295,7 +298,10 @@ impl StandaloneJobs {
|
||||
store: store.clone(),
|
||||
job_id: id.clone(),
|
||||
attempt_id: attempt_id.clone(),
|
||||
input_revision: snapshot.request.input_revision.clone(),
|
||||
input_digest: snapshot
|
||||
.request
|
||||
.input_digest()
|
||||
.map_err(JobStoreError::from)?,
|
||||
deadline,
|
||||
});
|
||||
let (cancel, mut cancellation) = watch::channel(false);
|
||||
@@ -451,14 +457,14 @@ struct BoundResultSink {
|
||||
store: Arc<Mutex<Option<JobStore>>>,
|
||||
job_id: String,
|
||||
attempt_id: String,
|
||||
input_revision: String,
|
||||
input_digest: String,
|
||||
deadline: tokio::time::Instant,
|
||||
}
|
||||
impl worker::job::JobResultSink for BoundResultSink {
|
||||
fn submit(&self, submission: JobResultSubmission) -> Result<(), String> {
|
||||
if submission.job_id != self.job_id
|
||||
|| submission.attempt_id != self.attempt_id
|
||||
|| submission.input_revision != self.input_revision
|
||||
|| submission.input_digest != self.input_digest
|
||||
{
|
||||
return Err("Job result capability binding mismatch".into());
|
||||
}
|
||||
@@ -482,7 +488,7 @@ struct BoundAttemptFence {
|
||||
store: Arc<Mutex<Option<JobStore>>>,
|
||||
job_id: String,
|
||||
attempt_id: String,
|
||||
input_revision: String,
|
||||
input_digest: String,
|
||||
deadline: tokio::time::Instant,
|
||||
}
|
||||
impl JobAttemptFence for BoundAttemptFence {
|
||||
@@ -499,7 +505,7 @@ impl JobAttemptFence for BoundAttemptFence {
|
||||
if snapshot.state != JobState::Pending
|
||||
|| snapshot.attempt.state != JobAttemptState::Dispatched
|
||||
|| snapshot.attempt.attempt_id != self.attempt_id
|
||||
|| snapshot.request.input_revision != self.input_revision
|
||||
|| snapshot.request.input_digest().map_err(|e| e.to_string())? != self.input_digest
|
||||
{
|
||||
return Err("Job domain grant is not bound to the current live attempt".into());
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ fn request(id: &str) -> JobRequest {
|
||||
JobRequest {
|
||||
job_id: id.into(),
|
||||
purpose: "test".into(),
|
||||
input_revision: "r1".into(),
|
||||
input_ref: "fixture:input".into(),
|
||||
input: serde_json::json!({"data":42}),
|
||||
instruction: "Return a structured result".into(),
|
||||
@@ -25,7 +24,7 @@ fn sink(jobs: &StandaloneJobs, id: &str, attempt: &str) -> BoundResultSink {
|
||||
store: jobs.store.clone(),
|
||||
job_id: id.into(),
|
||||
attempt_id: attempt.into(),
|
||||
input_revision: "r1".into(),
|
||||
input_digest: request(id).input_digest().unwrap(),
|
||||
deadline: tokio::time::Instant::now() + Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
@@ -33,7 +32,7 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission {
|
||||
JobResultSubmission {
|
||||
job_id: id.into(),
|
||||
attempt_id: attempt.into(),
|
||||
input_revision: "r1".into(),
|
||||
input_digest: request(id).input_digest().unwrap(),
|
||||
result: serde_json::json!({"valid":true}),
|
||||
}
|
||||
}
|
||||
@@ -99,7 +98,7 @@ type CapturedDomainAttempt = (JobSnapshot, Arc<dyn JobAttemptFence>);
|
||||
#[derive(Default)]
|
||||
struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>);
|
||||
fn domain_grant(id: &str) -> Value {
|
||||
json!({"consumer":"test-domain", "job_id":id, "input_revision":"r1"})
|
||||
json!({"consumer":"test-domain", "job_id":id, "input_digest":request(id).input_digest().unwrap()})
|
||||
}
|
||||
fn domain_request(id: &str) -> JobRequest {
|
||||
let mut intent = request(id);
|
||||
@@ -244,7 +243,7 @@ async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_f
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispatch() {
|
||||
async fn foreign_job_digest_and_consumer_grants_are_refused_before_model_dispatch() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let jobs =
|
||||
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
|
||||
@@ -253,12 +252,12 @@ async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispa
|
||||
for (id, grant) in [
|
||||
("foreign-job", domain_grant("another-job")),
|
||||
(
|
||||
"foreign-revision",
|
||||
json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}),
|
||||
"foreign-digest",
|
||||
json!({"consumer":"test-domain","job_id":"foreign-digest","input_digest":"r0"}),
|
||||
),
|
||||
(
|
||||
"foreign-consumer",
|
||||
json!({"consumer":"another-domain","job_id":"foreign-consumer","input_revision":"r1"}),
|
||||
json!({"consumer":"another-domain","job_id":"foreign-consumer","input_digest":request("foreign-consumer").input_digest().unwrap()}),
|
||||
),
|
||||
] {
|
||||
let reserved = jobs.request_granted(domain_request(id), grant).unwrap();
|
||||
@@ -378,7 +377,7 @@ async fn bound_domain_does_not_change_root_generic_result_only_execution() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline() {
|
||||
async fn host_result_capability_cannot_cross_job_attempt_digest_or_deadline() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let jobs =
|
||||
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
|
||||
@@ -398,7 +397,7 @@ async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline()
|
||||
);
|
||||
assert!(bound.submit(result("first", "first:attempt:2")).is_err());
|
||||
let mut stale = result("first", &first.attempt.attempt_id);
|
||||
stale.input_revision = "r0".into();
|
||||
stale.input_digest = "r0".into();
|
||||
assert!(bound.submit(stale).is_err());
|
||||
let mut expired = sink(&jobs, "first", &first.attempt.attempt_id);
|
||||
expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1);
|
||||
|
||||
+146
-26
@@ -17,7 +17,7 @@ const LEASE_FILE: &str = "lease.json";
|
||||
const LEASE_LOCK_FILE: &str = "lease.lock";
|
||||
const SESSIONS_DIR: &str = "sessions";
|
||||
const WORKER_DIR: &str = "worker";
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
const SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StandaloneCwdIdentity {
|
||||
@@ -79,7 +79,6 @@ pub enum StandaloneShutdownReason {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StandaloneWorkerRecord {
|
||||
pub schema_version: u32,
|
||||
pub revision: u64,
|
||||
pub worker_id: WorkerId,
|
||||
/// User-facing Worker name resolved from the profile.
|
||||
pub worker_name: String,
|
||||
@@ -194,7 +193,6 @@ impl StandaloneWorkerStore {
|
||||
let now = now_unix_ms()?;
|
||||
let record = StandaloneWorkerRecord {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
revision: 1,
|
||||
worker_id: allocation.worker_id,
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
storage_key,
|
||||
@@ -326,13 +324,12 @@ impl StandaloneWorkerStore {
|
||||
active_segment_id: Option<SegmentId>,
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let mut next = record.clone();
|
||||
next.revision = next.revision.saturating_add(1);
|
||||
next.updated_at_unix_ms = now_unix_ms()?;
|
||||
next.active_session_id = active_session_id;
|
||||
next.active_segment_id = active_segment_id;
|
||||
next.status = StandaloneWorkerStatus::Active;
|
||||
next.shutdown_reason = None;
|
||||
self.commit_record(Some(record.revision), &next)?;
|
||||
self.commit_record(Some(record), &next)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
@@ -344,13 +341,12 @@ impl StandaloneWorkerStore {
|
||||
reason: StandaloneShutdownReason,
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let mut next = record.clone();
|
||||
next.revision = next.revision.saturating_add(1);
|
||||
next.updated_at_unix_ms = now_unix_ms()?;
|
||||
next.active_session_id = active_session_id;
|
||||
next.active_segment_id = active_segment_id;
|
||||
next.status = StandaloneWorkerStatus::Stopped;
|
||||
next.shutdown_reason = Some(reason);
|
||||
self.commit_record(Some(record.revision), &next)?;
|
||||
self.commit_record(Some(record), &next)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
@@ -405,7 +401,7 @@ impl StandaloneWorkerStore {
|
||||
|
||||
fn commit_record(
|
||||
&self,
|
||||
expected_revision: Option<u64>,
|
||||
expected_record: Option<&StandaloneWorkerRecord>,
|
||||
next: &StandaloneWorkerRecord,
|
||||
) -> Result<(), StandaloneStoreError> {
|
||||
let dir = self.worker_dir(next.worker_id);
|
||||
@@ -421,19 +417,21 @@ impl StandaloneWorkerStore {
|
||||
StandaloneStoreError::Io(error)
|
||||
}
|
||||
})?;
|
||||
writeln!(marker_file, "{}", next.revision).map_err(StandaloneStoreError::Io)?;
|
||||
writeln!(marker_file, "{}", next.worker_id).map_err(StandaloneStoreError::Io)?;
|
||||
marker_file.sync_all().map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&dir)?;
|
||||
|
||||
if let Some(expected) = expected_revision {
|
||||
if let Some(expected) = expected_record {
|
||||
let current = self.load_record_while_committing(next.worker_id)?;
|
||||
if current.revision != expected {
|
||||
let _ = fs::remove_file(&marker);
|
||||
return Err(StandaloneStoreError::RevisionConflict {
|
||||
id: next.worker_id,
|
||||
expected,
|
||||
found: current.revision,
|
||||
});
|
||||
// The create-new commit marker excludes other writers across this
|
||||
// comparison and replacement. Compare the observed metadata itself,
|
||||
// including its manifest, pointer and lifecycle state, not a counter.
|
||||
if serde_json::to_value(¤t).map_err(StandaloneStoreError::Json)?
|
||||
!= serde_json::to_value(expected).map_err(StandaloneStoreError::Json)?
|
||||
{
|
||||
fs::remove_file(&marker).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&dir)?;
|
||||
return Err(StandaloneStoreError::MetadataChanged(next.worker_id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,6 +679,19 @@ fn decode_worker_record(
|
||||
"standalone Worker record must be an object",
|
||||
))
|
||||
})?;
|
||||
// Frozen schema-1 adapter: the counter was not domain data. Preserve
|
||||
// every metadata field and use content comparison for future writes.
|
||||
if object
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
== Some(1)
|
||||
{
|
||||
object.remove("revision");
|
||||
object.insert(
|
||||
"schema_version".to_string(),
|
||||
serde_json::json!(SCHEMA_VERSION),
|
||||
);
|
||||
}
|
||||
let persisted_manifest = object.remove("manifest").ok_or_else(|| {
|
||||
serde_json::Error::io(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
@@ -770,14 +781,8 @@ pub enum StandaloneStoreError {
|
||||
LeaseOwnershipLost,
|
||||
#[error("standalone Worker {0} must be stopped before deletion")]
|
||||
DeleteActive(WorkerId),
|
||||
#[error(
|
||||
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})"
|
||||
)]
|
||||
RevisionConflict {
|
||||
id: WorkerId,
|
||||
expected: u64,
|
||||
found: u64,
|
||||
},
|
||||
#[error("standalone Worker {0} metadata changed since it was read")]
|
||||
MetadataChanged(WorkerId),
|
||||
#[error("system clock is before the Unix epoch or out of range")]
|
||||
Clock,
|
||||
#[error("standalone metadata serialization failed")]
|
||||
@@ -816,7 +821,6 @@ permission = "write"
|
||||
let manifest = test_manifest();
|
||||
let record = StandaloneWorkerRecord {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
revision: 6,
|
||||
worker_id,
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
storage_key: "standalone-test".to_string(),
|
||||
@@ -859,6 +863,122 @@ permission = "write"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_write_compares_observed_content_even_with_equal_timestamps() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = StandaloneWorkerStore::open(root.path().join("workers")).unwrap();
|
||||
let allocation = store
|
||||
.allocate(root.path(), StaleLeasePolicy::Reject)
|
||||
.unwrap();
|
||||
let record = store
|
||||
.commit_created(
|
||||
&allocation,
|
||||
test_manifest(),
|
||||
"metadata-test".into(),
|
||||
session_store::new_session_id(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
// Simulate another committed pointer change in the same clock tick.
|
||||
let mut changed = record.clone();
|
||||
changed.active_session_id = session_store::new_session_id();
|
||||
store.commit_record(Some(&record), &changed).unwrap();
|
||||
assert!(matches!(
|
||||
store.mark_stopped(
|
||||
&record,
|
||||
record.active_session_id,
|
||||
None,
|
||||
StandaloneShutdownReason::UserExit
|
||||
),
|
||||
Err(StandaloneStoreError::MetadataChanged(_))
|
||||
));
|
||||
assert_eq!(
|
||||
store.load(record.worker_id).unwrap().active_session_id,
|
||||
changed.active_session_id
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.worker_dir(record.worker_id)
|
||||
.join(COMMIT_MARKER)
|
||||
.exists()
|
||||
);
|
||||
let stopped = store
|
||||
.mark_stopped(
|
||||
&changed,
|
||||
changed.active_session_id,
|
||||
None,
|
||||
StandaloneShutdownReason::UserExit,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stopped.status, StandaloneWorkerStatus::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_one_metadata_preserves_content_without_an_update_counter() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = StandaloneWorkerStore::open(root.path().join("workers")).unwrap();
|
||||
let allocation = store
|
||||
.allocate(root.path(), StaleLeasePolicy::Reject)
|
||||
.unwrap();
|
||||
let record = store
|
||||
.commit_created_connected(
|
||||
&allocation,
|
||||
test_manifest(),
|
||||
"migration-test".into(),
|
||||
session_store::new_session_id(),
|
||||
None,
|
||||
Some(crate::subjektiv::StandaloneSubjectBinding {
|
||||
scope_id: "local-scope".into(),
|
||||
subject_id: "local-subject".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let record = store
|
||||
.mark_stopped(
|
||||
&record,
|
||||
record.active_session_id,
|
||||
Some(session_store::new_segment_id()),
|
||||
StandaloneShutdownReason::UserExit,
|
||||
)
|
||||
.unwrap();
|
||||
let mut persisted = Vec::new();
|
||||
write_worker_record(&mut persisted, &record).unwrap();
|
||||
let mut legacy: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
|
||||
legacy["schema_version"] = serde_json::json!(1);
|
||||
legacy["revision"] = serde_json::json!(42);
|
||||
let legacy_bytes = serde_json::to_vec(&legacy).unwrap();
|
||||
fs::write(
|
||||
store.worker_dir(record.worker_id).join(RECORD_FILE),
|
||||
&legacy_bytes,
|
||||
)
|
||||
.unwrap();
|
||||
let migrated = store.load(record.worker_id).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&migrated).unwrap(),
|
||||
serde_json::to_value(&record).unwrap()
|
||||
);
|
||||
let mut encoded = Vec::new();
|
||||
write_worker_record(&mut encoded, &migrated).unwrap();
|
||||
let saved: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
|
||||
assert!(saved.get("revision").is_none());
|
||||
assert_eq!(saved["schema_version"], 2);
|
||||
let resumed = store
|
||||
.update_active_pointer(
|
||||
&migrated,
|
||||
migrated.active_session_id,
|
||||
migrated.active_segment_id,
|
||||
)
|
||||
.unwrap();
|
||||
let disk: serde_json::Value = serde_json::from_slice(
|
||||
&fs::read(store.worker_dir(record.worker_id).join(RECORD_FILE)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(disk.get("revision").is_none());
|
||||
assert_eq!(disk["schema_version"], 2);
|
||||
assert_eq!(resumed.subject, record.subject);
|
||||
assert_eq!(resumed.active_segment_id, record.active_segment_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lease_liveness_requires_positive_live_or_stale_evidence() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -352,7 +352,7 @@ fn operation_error(error: subjektiv::api::OperationError) -> SubjektivHostError
|
||||
} else if message.contains("candidate_decision_conflict") {
|
||||
"candidate_decision_conflict"
|
||||
} else {
|
||||
"revision_conflict"
|
||||
"change_conflict"
|
||||
}
|
||||
.into(),
|
||||
message,
|
||||
@@ -655,7 +655,7 @@ impl LocalSubjectHost {
|
||||
worker_id: Some(self.worker_id.to_string()),
|
||||
flow_selector: None,
|
||||
flow_definition_id: None,
|
||||
flow_definition_revision: None,
|
||||
flow_definition_fingerprint: None,
|
||||
};
|
||||
anchor.origin = Some(origin.clone());
|
||||
source.origin = Some(origin);
|
||||
@@ -835,8 +835,7 @@ impl SubjektivHost for LocalSubjectHost {
|
||||
job::DEFAULT_MAX_RESULT_BYTES,
|
||||
)
|
||||
.map_err(operation_error)?;
|
||||
let subject = self
|
||||
.catalog
|
||||
self.catalog
|
||||
.store
|
||||
.subject(&self.binding.subject_id)
|
||||
.map_err(domain_error)?
|
||||
@@ -847,7 +846,7 @@ impl SubjektivHost for LocalSubjectHost {
|
||||
};
|
||||
let request=job::JobRequest {
|
||||
job_id:format!("subjektiv-consolidation:{}",uuid::Uuid::now_v7()),purpose:"subjektiv_consolidation".into(),
|
||||
input_revision:subject.store_revision.to_string(),input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id),
|
||||
input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id),
|
||||
input:serde_json::json!({"subject_id":self.binding.subject_id,"candidate_ids":grant.candidate_ids}),
|
||||
instruction:"Resolve ONLY candidate_ids with MemoryStagingList/Read, recall and MemoryApplyCandidate. Already resolved IDs are durable receipts; do not repeat them. Leave new candidates for a later Job. When the entire immutable batch has a disposition, call SubmitJobResult with result: {subject_id, candidate_ids}. The Host awaits clean-context surface generation and verifies actual receipts/outcome before accepting success; never supply surface claims.".into(),
|
||||
profile:"builtin:standalone-subjektiv-consolidation".into(),serialization_key:Some(format!("subjektiv:{}",self.binding.subject_id)),
|
||||
|
||||
@@ -487,7 +487,7 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
|
||||
source: manifest::ProfileRegistrySource::User,
|
||||
name: "user-standalone".to_string(),
|
||||
path: None,
|
||||
provenance: Some("user-config-revision-7".to_string()),
|
||||
provenance: Some("user-config-digest".to_string()),
|
||||
},
|
||||
profile: Some(manifest::ProfileMetadata {
|
||||
name: Some("User standalone".to_string()),
|
||||
|
||||
@@ -91,7 +91,6 @@ fn request(id: &str) -> JobRequest {
|
||||
JobRequest {
|
||||
job_id: id.into(),
|
||||
purpose: "generic_test".into(),
|
||||
input_revision: "r1".into(),
|
||||
input_ref: "test:immutable".into(),
|
||||
input: serde_json::json!({"test_input":42}),
|
||||
instruction: "Return a structured validity result".into(),
|
||||
|
||||
@@ -273,7 +273,7 @@ impl ScriptedClient {
|
||||
"SubmitMemorySurface",
|
||||
json!({"points":[{
|
||||
"body_md":format!("- {}", material["body_md"].as_str().unwrap()),
|
||||
"memory_refs":[{"memory_id":material["memory_id"], "revision":material["revision"]}]
|
||||
"memory_refs":[{"memory_id":material["memory_id"], "change_id":material["change_id"]}]
|
||||
}]}),
|
||||
)
|
||||
}
|
||||
@@ -1155,7 +1155,7 @@ async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and
|
||||
);
|
||||
let memory = store.list_memories(&subject.id).unwrap().pop().unwrap();
|
||||
assert_eq!(memory.claim, claim);
|
||||
assert_eq!(memory.revision, 1);
|
||||
assert!(!memory.change_id.is_empty());
|
||||
assert_eq!(memory.source_candidate_ids, vec![ids[0].as_str().unwrap()]);
|
||||
let surface = store.resident_surface(&subject.id).unwrap();
|
||||
assert_eq!(surface.availability, subjektiv::SurfaceAvailability::Ready);
|
||||
@@ -1167,14 +1167,14 @@ async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and
|
||||
assert!(snapshot.body_md.contains(claim));
|
||||
assert_eq!(
|
||||
snapshot.memory_refs,
|
||||
vec![subjektiv::MemoryRevisionRef {
|
||||
vec![subjektiv::MemoryChangeRef {
|
||||
memory_id: memory.id.clone(),
|
||||
revision: 1
|
||||
change_id: memory.change_id.clone()
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.built_from_store_revision,
|
||||
accepted["surface"]["store_revision"].as_u64().unwrap()
|
||||
snapshot.built_from_memory_fingerprint,
|
||||
accepted["surface"]["memory_fingerprint"].as_str().unwrap()
|
||||
);
|
||||
memory
|
||||
});
|
||||
@@ -1260,7 +1260,7 @@ async fn transport_loss_after_candidate_apply_preserves_memory_and_later_job_onl
|
||||
let memory = store.list_memories(&subject.id).unwrap();
|
||||
assert_eq!(memory.len(), 1);
|
||||
assert_eq!(memory[0].claim, claim);
|
||||
assert_eq!(memory[0].revision, 1);
|
||||
assert!(!memory[0].change_id.is_empty());
|
||||
assert_ne!(
|
||||
store.resident_surface(&subject.id).unwrap().availability,
|
||||
subjektiv::SurfaceAvailability::Ready
|
||||
|
||||
@@ -13,6 +13,7 @@ manifest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
server-api.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
@@ -17,7 +17,9 @@ identity under its operator-managed state directory, **not a fake Workspace**.
|
||||
Subject IDs are independently issued random domain identities. An ID is a lookup
|
||||
key, never an authorization credential.
|
||||
|
||||
Existing SQL schema and migrations are retained. In particular,
|
||||
Historical migration plans remain frozen. Migration 7 replaces state counters
|
||||
with opaque Memory change identities and explicit predecessor history; the
|
||||
original serialized records are retained in an immutable audit archive.
|
||||
`store_scope.workspace_id` still stores the physical storage scope, under its
|
||||
historical column name. Existing Backend placement and snapshot manifest keys
|
||||
are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue
|
||||
@@ -29,7 +31,7 @@ Workspace value. `workspace_id()` is a Backend compatibility alias for
|
||||
|
||||
`api::execute(store, &HostOperationContext, operation)` is the common dispatcher
|
||||
used by Backend and local Hosts. It owns query/cursor rules, bounded body and
|
||||
nested provenance projections, immutable revision reads, proposal validation,
|
||||
nested provenance projections, immutable change reads, proposal validation,
|
||||
explicit staging/receipt rules, resident context, candidate decisions and
|
||||
surface generation/publication/failure projections.
|
||||
|
||||
@@ -72,5 +74,24 @@ helpers never run or replay a Job. The store's `prepare_job_surface_generation`,
|
||||
`require_job_surface_generation`, and `validate_job_surface_outcome` are public
|
||||
trusted-domain methods for both Backend and local Hosts.
|
||||
|
||||
## Content conditions and history
|
||||
|
||||
Memory updates require the exact `expected_change_id` read by the caller.
|
||||
Each committed change receives a new opaque ID and retains `previous_change_id`;
|
||||
IDs are never sorted or incremented to infer history. Body/evidence continuations
|
||||
pin that immutable ID, and history cursors pin a predecessor-chain head.
|
||||
|
||||
Subject behavior updates compare `expected_behavior_md` byte-for-byte and do not
|
||||
participate in Memory freshness. Surface runs, publications and query cursors use
|
||||
`memory_fingerprint`, a SHA-256 digest of the length-framed, ordered current
|
||||
`(memory_id, change_id)` identities. The Subject stores no redundant freshness
|
||||
counter. Publication remains first-wins for the captured Memory input set.
|
||||
|
||||
Old Surface input sets cannot be proven from historical counters. Migration 7
|
||||
retains those snapshots and runs as archival history with deliberately detached
|
||||
input identities, marks existing surfaces stale, and requires regeneration.
|
||||
Old Flow provenance keeps its exact original bytes in the archive; a definition
|
||||
fingerprint is not fabricated from an old numeric label.
|
||||
|
||||
Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`,
|
||||
`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`.
|
||||
`serde`, `serde_json`, `server-api`, `sha2`, `thiserror`, `uuid`; tests use `tempfile`.
|
||||
|
||||
+113
-114
@@ -24,7 +24,7 @@ pub const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024;
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubjektivQueryCursor {
|
||||
subject_id: String,
|
||||
store_revision: u64,
|
||||
memory_fingerprint: String,
|
||||
query: Option<String>,
|
||||
kinds: Vec<String>,
|
||||
states: Vec<String>,
|
||||
@@ -33,10 +33,10 @@ struct SubjektivQueryCursor {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubjektivRevisionCursor {
|
||||
struct SubjektivChangeCursor {
|
||||
subject_id: String,
|
||||
memory_id: String,
|
||||
max_revision: u64,
|
||||
head_change_id: String,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ struct SubjektivRevisionCursor {
|
||||
struct SubjektivEvidenceCursor {
|
||||
subject_id: String,
|
||||
memory_id: String,
|
||||
revision: u64,
|
||||
/// Candidate/derivation reference offset within the immutable revision.
|
||||
change_id: String,
|
||||
/// Candidate/derivation reference offset within the immutable change_id.
|
||||
offset: usize,
|
||||
/// Nested offsets keep compatible historical candidates with more anchors
|
||||
/// resumable without admitting new over-limit records.
|
||||
@@ -92,14 +92,14 @@ pub fn subjektiv_surface_prepare_response(
|
||||
server_api::SubjektivSurfacePrepareResponse {
|
||||
generation_id: generation.id,
|
||||
current_snapshot_id: None,
|
||||
store_revision: generation.store_revision,
|
||||
memory_fingerprint: generation.memory_fingerprint,
|
||||
active_memory_count: generation.active_memory_count,
|
||||
materials: generation
|
||||
.materials
|
||||
.into_iter()
|
||||
.map(|material| server_api::SubjektivSurfaceMaterial {
|
||||
memory_id: material.memory_id,
|
||||
revision: material.revision,
|
||||
change_id: material.change_id.clone(),
|
||||
kind: material.kind,
|
||||
body_md: material.body_md,
|
||||
why_useful: material.why_useful,
|
||||
@@ -120,7 +120,7 @@ pub fn subjektiv_candidate_summary(
|
||||
candidate_id: candidate.id,
|
||||
kind: candidate.kind,
|
||||
claim: candidate.claim,
|
||||
revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata),
|
||||
change_proposal: candidate.change_proposal.map(subjektiv_proposal_metadata),
|
||||
created_at: candidate.created_at,
|
||||
}
|
||||
}
|
||||
@@ -137,30 +137,26 @@ pub fn subjektiv_candidate(
|
||||
source: candidate.source,
|
||||
evidence: candidate.evidence,
|
||||
source_refs: candidate.source_refs,
|
||||
revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata),
|
||||
change_proposal: candidate.change_proposal.map(subjektiv_proposal_metadata),
|
||||
created_at: candidate.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subjektiv_proposal_metadata(
|
||||
proposal: crate::RevisionProposal,
|
||||
) -> server_api::SubjektivMemoryRevisionProposalMetadata {
|
||||
server_api::SubjektivMemoryRevisionProposalMetadata {
|
||||
proposal: crate::ChangeProposal,
|
||||
) -> server_api::SubjektivMemoryChangeProposalMetadata {
|
||||
server_api::SubjektivMemoryChangeProposalMetadata {
|
||||
memory_id: proposal.memory_id,
|
||||
expected_revision: proposal.expected_revision,
|
||||
expected_change_id: proposal.expected_change_id.clone(),
|
||||
intent: match proposal.intent {
|
||||
crate::RevisionProposalIntent::Revise => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Revise
|
||||
crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
|
||||
crate::ChangeProposalIntent::Resolve => {
|
||||
server_api::SubjektivMemoryChangeIntent::Resolve
|
||||
}
|
||||
crate::RevisionProposalIntent::Resolve => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Resolve
|
||||
}
|
||||
crate::RevisionProposalIntent::Retract => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Retract
|
||||
}
|
||||
crate::RevisionProposalIntent::Reopen => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Reopen
|
||||
crate::ChangeProposalIntent::Retract => {
|
||||
server_api::SubjektivMemoryChangeIntent::Retract
|
||||
}
|
||||
crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
|
||||
},
|
||||
change_reason: proposal.change_reason,
|
||||
}
|
||||
@@ -172,15 +168,13 @@ pub fn subjektiv_candidate_decision(
|
||||
let decision = match input.decision {
|
||||
server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => {
|
||||
let target = match target {
|
||||
server_api::SubjektivMemoryApplyTarget::Create => {
|
||||
crate::MemoryRevisionTarget::Create
|
||||
}
|
||||
server_api::SubjektivMemoryApplyTarget::Create => crate::MemoryChangeTarget::Create,
|
||||
server_api::SubjektivMemoryApplyTarget::Revise {
|
||||
memory_id,
|
||||
expected_revision,
|
||||
} => crate::MemoryRevisionTarget::Revise {
|
||||
expected_change_id,
|
||||
} => crate::MemoryChangeTarget::Revise {
|
||||
memory_id,
|
||||
expected_revision,
|
||||
expected_change_id,
|
||||
},
|
||||
};
|
||||
crate::CandidateDecision::Apply {
|
||||
@@ -196,9 +190,9 @@ pub fn subjektiv_candidate_decision(
|
||||
derived_from: memory
|
||||
.derived_from
|
||||
.into_iter()
|
||||
.map(|reference| crate::MemoryRevisionRef {
|
||||
.map(|reference| crate::MemoryChangeRef {
|
||||
memory_id: reference.memory_id,
|
||||
revision: reference.revision,
|
||||
change_id: reference.change_id.clone(),
|
||||
})
|
||||
.collect(),
|
||||
change_reason: memory.change_reason,
|
||||
@@ -225,9 +219,9 @@ pub fn subjektiv_candidate_decision(
|
||||
},
|
||||
affected_memory: affected_memory
|
||||
.into_iter()
|
||||
.map(|reference| crate::MemoryRevisionRef {
|
||||
.map(|reference| crate::MemoryChangeRef {
|
||||
memory_id: reference.memory_id,
|
||||
revision: reference.revision,
|
||||
change_id: reference.change_id.clone(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
@@ -281,17 +275,17 @@ pub fn subjektiv_candidate_decision_response(
|
||||
.into_iter()
|
||||
.map(|reference| server_api::SubjektivMemoryAffectedRef {
|
||||
memory_id: reference.memory_id,
|
||||
revision: reference.revision,
|
||||
change_id: reference.change_id.clone(),
|
||||
operation,
|
||||
})
|
||||
.collect(),
|
||||
memory: receipt
|
||||
.memory
|
||||
.map(|memory| server_api::SubjektivMemoryRevisionRef {
|
||||
.map(|memory| server_api::SubjektivMemoryChangeRef {
|
||||
memory_id: memory.id,
|
||||
revision: memory.revision,
|
||||
change_id: memory.change_id.clone(),
|
||||
}),
|
||||
store_revision: receipt.store_revision,
|
||||
memory_fingerprint: receipt.memory_fingerprint,
|
||||
surface_dirty: receipt.surface_dirty,
|
||||
}
|
||||
}
|
||||
@@ -316,10 +310,19 @@ pub fn subjektiv_memory_query(
|
||||
.filter(|value| !value.is_empty());
|
||||
let kinds = canonical_subjektiv_kinds(input.kinds)?;
|
||||
let states = canonical_subjektiv_states(input.states)?;
|
||||
let subject = store
|
||||
let _subject = store
|
||||
.subject(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.ok_or_else(|| OperationError::SubjectNotFound(subject_id.to_string()))?;
|
||||
let all_records = store
|
||||
.list_memories(subject_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let memory_fingerprint = crate::memory_identity_fingerprint(
|
||||
&all_records
|
||||
.iter()
|
||||
.map(|r| (r.id.clone(), r.change_id.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let mut offset = 0;
|
||||
if let Some(cursor) = input.cursor {
|
||||
let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?;
|
||||
@@ -333,7 +336,7 @@ pub fn subjektiv_memory_query(
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if cursor.store_revision != subject.store_revision {
|
||||
if cursor.memory_fingerprint != memory_fingerprint {
|
||||
return Err(OperationError::Conflict(
|
||||
"stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(),
|
||||
)
|
||||
@@ -344,9 +347,7 @@ pub fn subjektiv_memory_query(
|
||||
|
||||
let kind_filter = kinds.iter().cloned().collect::<HashSet<_>>();
|
||||
let state_filter = states.iter().cloned().collect::<HashSet<_>>();
|
||||
let records = store
|
||||
.list_memories(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
let records = all_records
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
kind_filter.contains(record.kind.as_str())
|
||||
@@ -369,7 +370,7 @@ pub fn subjektiv_memory_query(
|
||||
.iter()
|
||||
.map(|record| server_api::SubjektivMemoryQueryItem {
|
||||
id: record.id.clone(),
|
||||
revision: record.revision,
|
||||
change_id: record.change_id.clone(),
|
||||
kind: record.kind.clone(),
|
||||
state: api_memory_state(record.state),
|
||||
claim: record.claim.clone(),
|
||||
@@ -383,7 +384,7 @@ pub fn subjektiv_memory_query(
|
||||
"query",
|
||||
&SubjektivQueryCursor {
|
||||
subject_id: subject_id.to_string(),
|
||||
store_revision: subject.store_revision,
|
||||
memory_fingerprint: memory_fingerprint,
|
||||
query,
|
||||
kinds,
|
||||
states,
|
||||
@@ -407,18 +408,18 @@ pub fn subjektiv_memory_read(
|
||||
let current = store
|
||||
.scoped_memory(subject_id, &input.memory_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let record = match input.revision {
|
||||
Some(0) => {
|
||||
return Err(
|
||||
OperationError::InvalidInput("revision must be a positive integer".into()).into(),
|
||||
);
|
||||
let record = match input.change_id.clone() {
|
||||
Some(id) if id.trim().is_empty() => {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"change_id must be nonempty".into(),
|
||||
));
|
||||
}
|
||||
Some(revision) => store
|
||||
.scoped_memory_revision(subject_id, &input.memory_id, revision)
|
||||
Some(change_id) => store
|
||||
.scoped_memory_change(subject_id, &input.memory_id, change_id.clone())
|
||||
.map_err(subjektiv_store_error)?
|
||||
.ok_or_else(|| {
|
||||
OperationError::InvalidInput(format!(
|
||||
"memory_revision_not_found: Memory `{}` has no revision {revision}",
|
||||
"memory_change_not_found: Memory `{}` has no change_id {change_id}",
|
||||
input.memory_id
|
||||
))
|
||||
})?,
|
||||
@@ -433,9 +434,9 @@ pub fn subjektiv_memory_read(
|
||||
}
|
||||
let body_offset = input.offset.unwrap_or(0);
|
||||
let body_byte_offset = input.byte_offset.unwrap_or(0);
|
||||
if (body_offset > 0 || body_byte_offset > 0) && input.revision.is_none() {
|
||||
if (body_offset > 0 || body_byte_offset > 0) && input.change_id.is_none() {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"body continuation requires the exact revision returned by the first read".into(),
|
||||
"body continuation requires the exact change_id returned by the first read".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -477,10 +478,10 @@ pub fn subjektiv_memory_read(
|
||||
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
|
||||
if cursor.subject_id != subject_id
|
||||
|| cursor.memory_id != record.id
|
||||
|| cursor.revision != record.revision
|
||||
|| cursor.change_id != record.change_id
|
||||
{
|
||||
return Err(OperationError::InvalidInput(
|
||||
"subjektiv evidence cursor does not match subject, Memory, or revision".into(),
|
||||
"subjektiv evidence cursor does not match subject, Memory, or change_id".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -570,9 +571,9 @@ pub fn subjektiv_memory_read(
|
||||
}
|
||||
let derived_index = evidence_offset - record.source_candidate_ids.len();
|
||||
let reference = &record.derived_from[derived_index];
|
||||
derived_from.push(server_api::SubjektivMemoryRevisionRef {
|
||||
derived_from.push(server_api::SubjektivMemoryChangeRef {
|
||||
memory_id: reference.memory_id.clone(),
|
||||
revision: reference.revision,
|
||||
change_id: reference.change_id.clone(),
|
||||
});
|
||||
next_evidence_offset += 1;
|
||||
} else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 {
|
||||
@@ -592,7 +593,7 @@ pub fn subjektiv_memory_read(
|
||||
&SubjektivEvidenceCursor {
|
||||
subject_id: subject_id.to_string(),
|
||||
memory_id: record.id.clone(),
|
||||
revision: record.revision,
|
||||
change_id: record.change_id.clone(),
|
||||
offset: next_evidence_offset,
|
||||
evidence_offset: next_nested_evidence_offset,
|
||||
source_ref_offset: next_nested_source_ref_offset,
|
||||
@@ -603,8 +604,8 @@ pub fn subjektiv_memory_read(
|
||||
|
||||
let mut response = server_api::SubjektivMemoryReadResponse {
|
||||
memory_id: record.id,
|
||||
revision: record.revision,
|
||||
current_revision: current.revision,
|
||||
change_id: record.change_id.clone(),
|
||||
current_change_id: current.change_id.clone(),
|
||||
kind: record.kind,
|
||||
state: api_memory_state(record.state),
|
||||
claim: record.claim,
|
||||
@@ -635,36 +636,41 @@ pub fn subjektiv_memory_read(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn subjektiv_memory_list_revisions(
|
||||
pub fn subjektiv_memory_list_changes(
|
||||
store: &crate::SubjektivStore,
|
||||
subject_id: &str,
|
||||
input: server_api::SubjektivMemoryListRevisionsRequest,
|
||||
) -> OperationResult<server_api::SubjektivMemoryListRevisionsResponse> {
|
||||
input: server_api::SubjektivMemoryListChangesRequest,
|
||||
) -> OperationResult<server_api::SubjektivMemoryListChangesResponse> {
|
||||
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
|
||||
let current = store
|
||||
.scoped_memory(subject_id, &input.memory_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let (max_revision, offset) = if let Some(cursor) = input.cursor {
|
||||
let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?;
|
||||
let (head_change_id, offset) = if let Some(cursor) = input.cursor {
|
||||
let cursor: SubjektivChangeCursor = decode_subjektiv_cursor("changes", &cursor)?;
|
||||
if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"subjektiv revision cursor does not match subject or Memory".into(),
|
||||
"subjektiv change_id cursor does not match subject or Memory".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
(cursor.max_revision, cursor.offset)
|
||||
(cursor.head_change_id, cursor.offset)
|
||||
} else {
|
||||
(current.revision, 0)
|
||||
(current.change_id.clone(), 0)
|
||||
};
|
||||
let records = store
|
||||
.list_memory_revisions(subject_id, &input.memory_id)
|
||||
.list_memory_changes(subject_id, &input.memory_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.into_iter()
|
||||
.filter(|record| record.revision <= max_revision)
|
||||
.skip_while(|record| record.change_id != head_change_id)
|
||||
.collect::<Vec<_>>();
|
||||
if records.is_empty() {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"subjektiv history cursor refers to an unknown change".into(),
|
||||
));
|
||||
}
|
||||
if offset > records.len() {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"subjektiv revision cursor offset is invalid".into(),
|
||||
"subjektiv change_id cursor offset is invalid".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -672,8 +678,8 @@ pub fn subjektiv_memory_list_revisions(
|
||||
let has_more = end < records.len();
|
||||
let items = records[offset..end]
|
||||
.iter()
|
||||
.map(|record| server_api::SubjektivMemoryRevisionItem {
|
||||
revision: record.revision,
|
||||
.map(|record| server_api::SubjektivMemoryChangeItem {
|
||||
change_id: record.change_id.clone(),
|
||||
kind: record.kind.clone(),
|
||||
state: api_memory_state(record.state),
|
||||
claim: record.claim.clone(),
|
||||
@@ -684,19 +690,19 @@ pub fn subjektiv_memory_list_revisions(
|
||||
let next_cursor = has_more
|
||||
.then(|| {
|
||||
encode_subjektiv_cursor(
|
||||
"revisions",
|
||||
&SubjektivRevisionCursor {
|
||||
"changes",
|
||||
&SubjektivChangeCursor {
|
||||
subject_id: subject_id.to_string(),
|
||||
memory_id: input.memory_id.clone(),
|
||||
max_revision,
|
||||
head_change_id,
|
||||
offset: end,
|
||||
},
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(server_api::SubjektivMemoryListRevisionsResponse {
|
||||
Ok(server_api::SubjektivMemoryListChangesResponse {
|
||||
memory_id: input.memory_id,
|
||||
current_revision: current.revision,
|
||||
current_change_id: current.change_id.clone(),
|
||||
items,
|
||||
next_cursor,
|
||||
has_more,
|
||||
@@ -708,25 +714,25 @@ pub fn subjektiv_memory_validate_proposal(
|
||||
subject_id: &str,
|
||||
input: server_api::SubjektivMemoryValidateProposalRequest,
|
||||
) -> OperationResult<server_api::SubjektivMemoryProposalValidationResponse> {
|
||||
if input.expected_revision == 0 {
|
||||
if input.expected_change_id.trim().is_empty() {
|
||||
return Err(
|
||||
OperationError::InvalidInput("expected_revision must be positive".into()).into(),
|
||||
OperationError::InvalidInput("expected_change_id must be nonempty".into()).into(),
|
||||
);
|
||||
}
|
||||
let current = store
|
||||
.scoped_memory(subject_id, &input.memory_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
if current.revision != input.expected_revision {
|
||||
if current.change_id != input.expected_change_id {
|
||||
return Err(OperationError::Conflict(format!(
|
||||
"revision_conflict: Memory `{}` expected {}, current {}",
|
||||
input.memory_id, input.expected_revision, current.revision
|
||||
"change_conflict: Memory `{}` expected {}, current {}",
|
||||
input.memory_id, input.expected_change_id, current.change_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
validate_subjektiv_proposal_transition(current.state, input.intent)?;
|
||||
Ok(server_api::SubjektivMemoryProposalValidationResponse {
|
||||
memory_id: input.memory_id,
|
||||
current_revision: current.revision,
|
||||
current_change_id: current.change_id.clone(),
|
||||
kind: current.kind,
|
||||
state: api_memory_state(current.state),
|
||||
intent: input.intent,
|
||||
@@ -821,10 +827,10 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
.proposal
|
||||
.as_ref()
|
||||
.map(|proposal| {
|
||||
crate::RevisionProposal::new(
|
||||
crate::ChangeProposal::new(
|
||||
domain_proposal_intent(proposal.intent),
|
||||
proposal.memory_id.clone(),
|
||||
proposal.expected_revision,
|
||||
proposal.expected_change_id.clone(),
|
||||
proposal.change_reason.clone(),
|
||||
)
|
||||
})
|
||||
@@ -836,7 +842,7 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
.map_err(subjektiv_store_error)?;
|
||||
if current.kind != input.kind {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"revision proposal kind must be derived from its target Memory".into(),
|
||||
"change_id proposal kind must be derived from its target Memory".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -859,7 +865,7 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging);
|
||||
if let Some(proposal) = proposal.clone() {
|
||||
staging
|
||||
.attach_revision_proposal(proposal)
|
||||
.attach_change_proposal(proposal)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
}
|
||||
let (staged, _) = store
|
||||
@@ -873,7 +879,7 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
.as_ref()
|
||||
.map(|proposal| server_api::SubjektivMemoryProposalTarget {
|
||||
memory_id: proposal.memory_id.clone(),
|
||||
expected_revision: proposal.expected_revision,
|
||||
expected_change_id: proposal.expected_change_id.clone(),
|
||||
}),
|
||||
intent: proposal
|
||||
.as_ref()
|
||||
@@ -883,10 +889,10 @@ pub fn subjektiv_memory_stage_explicit(
|
||||
|
||||
pub fn validate_subjektiv_proposal_transition(
|
||||
state: crate::MemoryState,
|
||||
intent: server_api::SubjektivMemoryRevisionIntent,
|
||||
intent: server_api::SubjektivMemoryChangeIntent,
|
||||
) -> OperationResult<()> {
|
||||
use crate::MemoryState::{Active, Resolved};
|
||||
use server_api::SubjektivMemoryRevisionIntent::{Reopen, Resolve, Retract, Revise};
|
||||
use server_api::SubjektivMemoryChangeIntent::{Reopen, Resolve, Retract, Revise};
|
||||
let valid = match intent {
|
||||
Revise => matches!(state, Active | Resolved),
|
||||
Resolve => state == Active,
|
||||
@@ -979,32 +985,24 @@ pub fn subjektiv_memory_state_name(state: crate::MemoryState) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn domain_proposal_intent(
|
||||
intent: server_api::SubjektivMemoryRevisionIntent,
|
||||
) -> crate::RevisionProposalIntent {
|
||||
intent: server_api::SubjektivMemoryChangeIntent,
|
||||
) -> crate::ChangeProposalIntent {
|
||||
match intent {
|
||||
server_api::SubjektivMemoryRevisionIntent::Revise => crate::RevisionProposalIntent::Revise,
|
||||
server_api::SubjektivMemoryRevisionIntent::Resolve => {
|
||||
crate::RevisionProposalIntent::Resolve
|
||||
}
|
||||
server_api::SubjektivMemoryRevisionIntent::Retract => {
|
||||
crate::RevisionProposalIntent::Retract
|
||||
}
|
||||
server_api::SubjektivMemoryRevisionIntent::Reopen => crate::RevisionProposalIntent::Reopen,
|
||||
server_api::SubjektivMemoryChangeIntent::Revise => crate::ChangeProposalIntent::Revise,
|
||||
server_api::SubjektivMemoryChangeIntent::Resolve => crate::ChangeProposalIntent::Resolve,
|
||||
server_api::SubjektivMemoryChangeIntent::Retract => crate::ChangeProposalIntent::Retract,
|
||||
server_api::SubjektivMemoryChangeIntent::Reopen => crate::ChangeProposalIntent::Reopen,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_proposal_intent(
|
||||
intent: crate::RevisionProposalIntent,
|
||||
) -> server_api::SubjektivMemoryRevisionIntent {
|
||||
intent: crate::ChangeProposalIntent,
|
||||
) -> server_api::SubjektivMemoryChangeIntent {
|
||||
match intent {
|
||||
crate::RevisionProposalIntent::Revise => server_api::SubjektivMemoryRevisionIntent::Revise,
|
||||
crate::RevisionProposalIntent::Resolve => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Resolve
|
||||
}
|
||||
crate::RevisionProposalIntent::Retract => {
|
||||
server_api::SubjektivMemoryRevisionIntent::Retract
|
||||
}
|
||||
crate::RevisionProposalIntent::Reopen => server_api::SubjektivMemoryRevisionIntent::Reopen,
|
||||
crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
|
||||
crate::ChangeProposalIntent::Resolve => server_api::SubjektivMemoryChangeIntent::Resolve,
|
||||
crate::ChangeProposalIntent::Retract => server_api::SubjektivMemoryChangeIntent::Retract,
|
||||
crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1189,6 +1187,7 @@ pub fn bounded_subjektiv_evidence_origin(
|
||||
&mut origin.worker_id,
|
||||
&mut origin.flow_selector,
|
||||
&mut origin.flow_definition_id,
|
||||
&mut origin.flow_definition_fingerprint,
|
||||
] {
|
||||
if let Some(value) = field.take() {
|
||||
*field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
|
||||
@@ -1294,10 +1293,10 @@ pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError {
|
||||
crate::SubjektivError::SubjectScopeMismatch { .. } => {
|
||||
OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}"))
|
||||
}
|
||||
crate::SubjektivError::RevisionConflict { .. }
|
||||
crate::SubjektivError::MemoryChangeConflict { .. }
|
||||
| crate::SubjektivError::SubjectBehaviorConflict { .. }
|
||||
| crate::SubjektivError::SurfaceGenerationConflict(_) => {
|
||||
OperationError::Conflict(format!("revision_conflict: {error}"))
|
||||
OperationError::Conflict(format!("change_conflict: {error}"))
|
||||
}
|
||||
crate::SubjektivError::CandidateResolved(_)
|
||||
| crate::SubjektivError::DecisionRequestConflict(_) => {
|
||||
|
||||
@@ -28,14 +28,13 @@ pub fn execute(
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Response::ResidentContext(server_api::SubjektivResidentContextOutput {
|
||||
behavior_md: resident.subject.behavior_md,
|
||||
behavior_revision: resident.subject.behavior_revision,
|
||||
memory_surface: resident_summary_output(resident.surface),
|
||||
})
|
||||
}
|
||||
Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?),
|
||||
Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?),
|
||||
Op::ListRevisions(input) => {
|
||||
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?)
|
||||
Op::ListChanges(input) => {
|
||||
Response::ListChanges(subjektiv_memory_list_changes(store, subject_id, input)?)
|
||||
}
|
||||
Op::ValidateProposal(input) => {
|
||||
context.body()?;
|
||||
@@ -169,7 +168,7 @@ pub fn execute(
|
||||
if resident.availability == crate::SurfaceAvailability::Ready {
|
||||
response.current_snapshot_id = resident
|
||||
.snapshot
|
||||
.filter(|s| s.built_from_store_revision == response.store_revision)
|
||||
.filter(|s| s.built_from_memory_fingerprint == response.memory_fingerprint)
|
||||
.map(|s| s.id);
|
||||
}
|
||||
Response::SurfacePrepared(response)
|
||||
@@ -194,9 +193,9 @@ pub fn execute(
|
||||
memory_refs: point
|
||||
.memory_refs
|
||||
.into_iter()
|
||||
.map(|r| crate::MemoryRevisionRef {
|
||||
.map(|r| crate::MemoryChangeRef {
|
||||
memory_id: r.memory_id,
|
||||
revision: r.revision,
|
||||
change_id: r.change_id,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -206,7 +205,7 @@ pub fn execute(
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
|
||||
snapshot_id: snapshot.id,
|
||||
built_from_store_revision: snapshot.built_from_store_revision,
|
||||
built_from_memory_fingerprint: snapshot.built_from_memory_fingerprint,
|
||||
empty: snapshot.body_md.is_empty(),
|
||||
})
|
||||
}
|
||||
@@ -222,7 +221,7 @@ pub fn execute(
|
||||
)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
}
|
||||
let revision = store
|
||||
let memory_fingerprint = store
|
||||
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let status = if let Some(job) = job {
|
||||
@@ -230,7 +229,7 @@ pub fn execute(
|
||||
.validate_job_surface_outcome(
|
||||
subject_id,
|
||||
&input.generation_id,
|
||||
revision,
|
||||
memory_fingerprint.clone(),
|
||||
"failed",
|
||||
None,
|
||||
Some(&input.reason_code),
|
||||
@@ -247,7 +246,7 @@ pub fn execute(
|
||||
"failed"
|
||||
};
|
||||
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
|
||||
store_revision: revision,
|
||||
memory_fingerprint,
|
||||
status: status.into(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,10 +23,15 @@ fn query(cursor: Option<String>) -> Op {
|
||||
})
|
||||
}
|
||||
|
||||
fn read(id: &str, revision: Option<u64>, offset: Option<usize>, byte_offset: Option<usize>) -> Op {
|
||||
fn read(
|
||||
id: &str,
|
||||
change_id: Option<String>,
|
||||
offset: Option<usize>,
|
||||
byte_offset: Option<usize>,
|
||||
) -> Op {
|
||||
Op::Read(server_api::SubjektivMemoryReadRequest {
|
||||
memory_id: id.into(),
|
||||
revision,
|
||||
change_id,
|
||||
offset,
|
||||
byte_offset,
|
||||
limit: Some(1),
|
||||
@@ -86,7 +91,7 @@ fn neutral_scope_reopen_and_context_attenuation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_query_cursor_and_utf8_fixed_revision_projection() {
|
||||
fn shared_query_cursor_and_utf8_fixed_change_projection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, store) = open(temp.path(), "local-random-scope");
|
||||
let subject = store
|
||||
@@ -148,7 +153,12 @@ fn shared_query_cursor_and_utf8_fixed_revision_projection() {
|
||||
let Response::Read(page) = execute(
|
||||
&store,
|
||||
&context,
|
||||
read(&first.id, Some(1), offset, byte_offset),
|
||||
read(
|
||||
&first.id,
|
||||
Some(first.change_id.clone()),
|
||||
offset,
|
||||
byte_offset,
|
||||
),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!()
|
||||
@@ -247,7 +257,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
|
||||
entry_range: Some([1, 1]),
|
||||
kind: memory::schema::EvidenceKind::new("model_output"),
|
||||
origin: None,
|
||||
excerpt: Some("Use immutable revisions".into()),
|
||||
excerpt: Some("Use immutable changes".into()),
|
||||
summary: None,
|
||||
}],
|
||||
source_refs: vec![memory::schema::SourceEvidenceRef {
|
||||
@@ -319,7 +329,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
|
||||
kind: CandidateKind::Lesson,
|
||||
state: server_api::SubjektivMemoryState::Active,
|
||||
claim: "Preserve correction history".into(),
|
||||
body_md: "Use immutable revisions".into(),
|
||||
body_md: "Use immutable changes".into(),
|
||||
why_useful: "Allows auditing".into(),
|
||||
staleness: None,
|
||||
derived_from: vec![],
|
||||
@@ -341,3 +351,104 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_pages_pin_ancestry_and_reject_unknown_heads() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, store) = open(temp.path(), "scope");
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let draft = |body: &str| {
|
||||
MemoryDraft::active(CandidateKind::Lesson, "claim", body, "useful", "evidence")
|
||||
};
|
||||
let first = store.create_memory(&subject.id, draft("first")).unwrap();
|
||||
let second = store
|
||||
.revise_memory(
|
||||
&subject.id,
|
||||
&first.id,
|
||||
first.change_id.clone(),
|
||||
draft("second"),
|
||||
)
|
||||
.unwrap();
|
||||
let input = |cursor| server_api::SubjektivMemoryListChangesRequest {
|
||||
memory_id: first.id.clone(),
|
||||
limit: Some(1),
|
||||
cursor,
|
||||
};
|
||||
let page = subjektiv_memory_list_changes(&store, &subject.id, input(None)).unwrap();
|
||||
assert_eq!(page.items[0].change_id, second.change_id);
|
||||
assert!(page.has_more);
|
||||
let third = store
|
||||
.revise_memory(
|
||||
&subject.id,
|
||||
&first.id,
|
||||
second.change_id.clone(),
|
||||
draft("third"),
|
||||
)
|
||||
.unwrap();
|
||||
let next = subjektiv_memory_list_changes(&store, &subject.id, input(page.next_cursor)).unwrap();
|
||||
assert_eq!(next.items[0].change_id, first.change_id);
|
||||
assert_eq!(next.current_change_id, third.change_id);
|
||||
assert!(!next.has_more);
|
||||
let invalid = encode_subjektiv_cursor(
|
||||
"changes",
|
||||
&SubjektivChangeCursor {
|
||||
subject_id: subject.id.clone(),
|
||||
memory_id: first.id.clone(),
|
||||
head_change_id: "not-a-history-entry".into(),
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(subjektiv_memory_list_changes(&store, &subject.id, input(Some(invalid))).is_err());
|
||||
let historic = subjektiv_memory_read(
|
||||
&store,
|
||||
&subject.id,
|
||||
server_api::SubjektivMemoryReadRequest {
|
||||
memory_id: first.id,
|
||||
change_id: Some(second.change_id.clone()),
|
||||
offset: None,
|
||||
byte_offset: None,
|
||||
limit: None,
|
||||
evidence_cursor: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(historic.body_md, "second");
|
||||
assert_eq!(historic.change_id, second.change_id);
|
||||
assert_eq!(historic.current_change_id, third.change_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_content_condition_ignores_behavior_and_detects_aba_memory_writes() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, store) = open(temp.path(), "scope");
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let draft = |body: &str| {
|
||||
MemoryDraft::active(CandidateKind::Lesson, "claim", body, "useful", "evidence")
|
||||
};
|
||||
let first = store.create_memory(&subject.id, draft("A")).unwrap();
|
||||
store.create_memory(&subject.id, draft("other")).unwrap();
|
||||
let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
|
||||
let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
let cursor = page.next_cursor.unwrap();
|
||||
store
|
||||
.update_subject_behavior(&subject.id, "", "New behavior".into())
|
||||
.unwrap();
|
||||
execute(&store, &context, query(Some(cursor.clone()))).unwrap();
|
||||
let second = store
|
||||
.revise_memory(&subject.id, &first.id, first.change_id.clone(), draft("B"))
|
||||
.unwrap();
|
||||
store
|
||||
.revise_memory(&subject.id, &first.id, second.change_id, draft("A"))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
execute(&store, &context, query(Some(cursor))),
|
||||
Err(OperationError::Conflict(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ struct ConsolidationResult {
|
||||
struct SurfaceResult {
|
||||
availability: String,
|
||||
generation_id: String,
|
||||
store_revision: u64,
|
||||
memory_fingerprint: String,
|
||||
#[serde(default)]
|
||||
snapshot_id: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -105,7 +105,7 @@ pub fn validate_result(
|
||||
.validate_job_surface_outcome(
|
||||
subject_id,
|
||||
&result.surface.generation_id,
|
||||
result.surface.store_revision,
|
||||
result.surface.memory_fingerprint,
|
||||
&result.surface.availability,
|
||||
result.surface.snapshot_id.as_deref(),
|
||||
result.surface.reason_code.as_deref(),
|
||||
@@ -118,7 +118,7 @@ pub fn validate_result(
|
||||
}
|
||||
|
||||
/// Enriches a previously verified result with compact durable dispositions. Full
|
||||
/// reasons, affected revisions and provenance remain in the receipt store. This
|
||||
/// reasons, affected changes and provenance remain in the receipt store. This
|
||||
/// deterministic projection neither applies candidates nor creates a new ledger.
|
||||
pub fn with_dispositions(
|
||||
store: &SubjektivStore,
|
||||
|
||||
@@ -22,7 +22,7 @@ fn escaped_ids_use_exact_t704_complete_result_budget() {
|
||||
.collect();
|
||||
let result = serde_json::json!({
|
||||
"subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions,
|
||||
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) }
|
||||
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "memory_fingerprint": "f".repeat(64), "reason_code": "\\\"".repeat(128) }
|
||||
});
|
||||
assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize);
|
||||
assert!(
|
||||
@@ -119,7 +119,7 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
|
||||
.publish_surface_generation(&subject.id, &generation.id, vec![])
|
||||
.unwrap();
|
||||
let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
|
||||
"surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}});
|
||||
"surface": {"availability": "ready", "generation_id": generation.id, "memory_fingerprint": snapshot.built_from_memory_fingerprint, "snapshot_id": snapshot.id}});
|
||||
assert!(
|
||||
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
|
||||
.unwrap_err()
|
||||
@@ -170,11 +170,11 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
|
||||
let failure = store
|
||||
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
|
||||
.unwrap();
|
||||
let revision = store
|
||||
let memory_fingerprint = store
|
||||
.fail_surface_generation(&subject.id, &failure.id, "model_failure")
|
||||
.unwrap();
|
||||
let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
|
||||
"surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}});
|
||||
"surface": {"availability": "failed", "generation_id": failure.id, "memory_fingerprint": memory_fingerprint, "reason_code": "model_failure"}});
|
||||
validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap();
|
||||
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
|
||||
let mut false_reason = failed;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+709
-1330
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
//! Current schema creation. Frozen data conversion lives with the old migrations.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
legacy_migrations::remove_state_numbers(tx)
|
||||
}
|
||||
|
||||
pub(super) fn create(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
tx.execute_batch(include_str!("schema.sql"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
|
||||
CREATE TABLE store_scope (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
workspace_id TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE subjects (
|
||||
subject_id TEXT PRIMARY KEY,
|
||||
role TEXT NOT NULL,
|
||||
behavior_md TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL CHECK (state IN ('active', 'retired')),
|
||||
record_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE staging_records (
|
||||
subject_id TEXT NOT NULL,
|
||||
candidate_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (
|
||||
kind IN (
|
||||
'preference', 'working_assumption', 'constraint',
|
||||
'decision', 'open_question', 'lesson'
|
||||
)
|
||||
),
|
||||
record_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, candidate_id),
|
||||
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE memory_records (
|
||||
subject_id TEXT NOT NULL,
|
||||
memory_id TEXT NOT NULL,
|
||||
current_change_id TEXT NOT NULL CHECK (length(current_change_id) > 0),
|
||||
kind TEXT NOT NULL CHECK (
|
||||
kind IN (
|
||||
'preference', 'working_assumption', 'constraint',
|
||||
'decision', 'open_question', 'lesson'
|
||||
)
|
||||
),
|
||||
state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')),
|
||||
record_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, memory_id),
|
||||
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (subject_id, memory_id, current_change_id)
|
||||
REFERENCES memory_changes(subject_id, memory_id, change_id)
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
|
||||
CREATE TABLE memory_changes (
|
||||
subject_id TEXT NOT NULL,
|
||||
memory_id TEXT NOT NULL,
|
||||
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
|
||||
previous_change_id TEXT,
|
||||
kind TEXT NOT NULL CHECK (
|
||||
kind IN (
|
||||
'preference', 'working_assumption', 'constraint',
|
||||
'decision', 'open_question', 'lesson'
|
||||
)
|
||||
),
|
||||
state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')),
|
||||
record_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, memory_id, change_id),
|
||||
FOREIGN KEY (subject_id, memory_id)
|
||||
REFERENCES memory_records(subject_id, memory_id)
|
||||
ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
|
||||
FOREIGN KEY (subject_id, memory_id, previous_change_id)
|
||||
REFERENCES memory_changes(subject_id, memory_id, change_id)
|
||||
ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
|
||||
CHECK (previous_change_id IS NULL OR previous_change_id <> change_id)
|
||||
);
|
||||
|
||||
CREATE TABLE memory_change_candidates (
|
||||
subject_id TEXT NOT NULL,
|
||||
memory_id TEXT NOT NULL,
|
||||
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
|
||||
candidate_id TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, memory_id, change_id, candidate_id),
|
||||
FOREIGN KEY (subject_id, memory_id, change_id)
|
||||
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (subject_id, candidate_id)
|
||||
REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE memory_change_derivations (
|
||||
subject_id TEXT NOT NULL,
|
||||
memory_id TEXT NOT NULL,
|
||||
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
|
||||
source_memory_id TEXT NOT NULL,
|
||||
source_change_id TEXT NOT NULL CHECK (length(source_change_id) > 0),
|
||||
PRIMARY KEY (
|
||||
subject_id, memory_id, change_id, source_memory_id, source_change_id
|
||||
),
|
||||
FOREIGN KEY (subject_id, memory_id, change_id)
|
||||
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (subject_id, source_memory_id, source_change_id)
|
||||
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE staging_resolutions (
|
||||
subject_id TEXT NOT NULL,
|
||||
candidate_id TEXT NOT NULL,
|
||||
resolution_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (
|
||||
action IN ('applied', 'discarded', 'invalid', 'duplicate', 'already_covered')
|
||||
),
|
||||
reason TEXT NOT NULL,
|
||||
affected_refs_json TEXT NOT NULL,
|
||||
staging_raw_json TEXT NOT NULL,
|
||||
resolution_json TEXT NOT NULL,
|
||||
resolved_at TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, candidate_id),
|
||||
UNIQUE (resolution_id),
|
||||
FOREIGN KEY (subject_id, candidate_id)
|
||||
REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE staging_resolution_targets (
|
||||
subject_id TEXT NOT NULL,
|
||||
candidate_id TEXT NOT NULL,
|
||||
memory_id TEXT NOT NULL,
|
||||
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
|
||||
PRIMARY KEY (subject_id, candidate_id, memory_id, change_id),
|
||||
FOREIGN KEY (subject_id, candidate_id)
|
||||
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (subject_id, memory_id, change_id)
|
||||
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE surface_snapshots (
|
||||
subject_id TEXT NOT NULL,
|
||||
snapshot_id TEXT NOT NULL,
|
||||
built_from_memory_fingerprint TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, snapshot_id),
|
||||
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE surface_snapshot_refs (
|
||||
subject_id TEXT NOT NULL,
|
||||
snapshot_id TEXT NOT NULL,
|
||||
memory_id TEXT NOT NULL,
|
||||
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
|
||||
PRIMARY KEY (subject_id, snapshot_id, memory_id),
|
||||
FOREIGN KEY (subject_id, snapshot_id)
|
||||
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (subject_id, memory_id, change_id)
|
||||
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE memory_change_seals (
|
||||
subject_id TEXT NOT NULL,
|
||||
memory_id TEXT NOT NULL,
|
||||
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
|
||||
PRIMARY KEY (subject_id, memory_id, change_id),
|
||||
FOREIGN KEY (subject_id, memory_id, change_id)
|
||||
REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE staging_resolution_seals (
|
||||
subject_id TEXT NOT NULL,
|
||||
candidate_id TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, candidate_id),
|
||||
FOREIGN KEY (subject_id, candidate_id)
|
||||
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE surface_snapshot_seals (
|
||||
subject_id TEXT NOT NULL,
|
||||
snapshot_id TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, snapshot_id),
|
||||
FOREIGN KEY (subject_id, snapshot_id)
|
||||
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER memory_change_candidates_no_late_insert
|
||||
BEFORE INSERT ON memory_change_candidates
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM memory_change_seals
|
||||
WHERE subject_id = NEW.subject_id
|
||||
AND memory_id = NEW.memory_id
|
||||
AND change_id = NEW.change_id
|
||||
) BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv change_id evidence is sealed');
|
||||
END;
|
||||
CREATE TRIGGER memory_change_derivations_no_late_insert
|
||||
BEFORE INSERT ON memory_change_derivations
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM memory_change_seals
|
||||
WHERE subject_id = NEW.subject_id
|
||||
AND memory_id = NEW.memory_id
|
||||
AND change_id = NEW.change_id
|
||||
) BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv derivations are sealed');
|
||||
END;
|
||||
CREATE TRIGGER staging_resolution_targets_no_late_insert
|
||||
BEFORE INSERT ON staging_resolution_targets
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM staging_resolution_seals
|
||||
WHERE subject_id = NEW.subject_id
|
||||
AND candidate_id = NEW.candidate_id
|
||||
) BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv resolution targets are sealed');
|
||||
END;
|
||||
CREATE TRIGGER surface_snapshot_refs_no_late_insert
|
||||
BEFORE INSERT ON surface_snapshot_refs
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM surface_snapshot_seals
|
||||
WHERE subject_id = NEW.subject_id
|
||||
AND snapshot_id = NEW.snapshot_id
|
||||
) BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv surface references are sealed');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER memory_change_seals_no_update
|
||||
BEFORE UPDATE ON memory_change_seals BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv change_id seals are immutable');
|
||||
END;
|
||||
CREATE TRIGGER memory_change_seals_no_delete
|
||||
BEFORE DELETE ON memory_change_seals BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv change_id seals are retained');
|
||||
END;
|
||||
CREATE TRIGGER staging_resolution_seals_no_update
|
||||
BEFORE UPDATE ON staging_resolution_seals BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv resolution seals are immutable');
|
||||
END;
|
||||
CREATE TRIGGER staging_resolution_seals_no_delete
|
||||
BEFORE DELETE ON staging_resolution_seals BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv resolution seals are retained');
|
||||
END;
|
||||
CREATE TRIGGER surface_snapshot_seals_no_update
|
||||
BEFORE UPDATE ON surface_snapshot_seals BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv snapshot seals are immutable');
|
||||
END;
|
||||
CREATE TRIGGER surface_snapshot_seals_no_delete
|
||||
BEFORE DELETE ON surface_snapshot_seals BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv snapshot seals are retained');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER memory_changes_no_update
|
||||
BEFORE UPDATE ON memory_changes BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv memory changes are immutable');
|
||||
END;
|
||||
CREATE TRIGGER memory_changes_no_delete
|
||||
BEFORE DELETE ON memory_changes BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv memory changes are retained');
|
||||
END;
|
||||
CREATE TRIGGER memory_change_candidates_no_update
|
||||
BEFORE UPDATE ON memory_change_candidates BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv change_id evidence is immutable');
|
||||
END;
|
||||
CREATE TRIGGER memory_change_candidates_no_delete
|
||||
BEFORE DELETE ON memory_change_candidates BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv change_id evidence is retained');
|
||||
END;
|
||||
CREATE TRIGGER memory_change_derivations_no_update
|
||||
BEFORE UPDATE ON memory_change_derivations BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv derivations are immutable');
|
||||
END;
|
||||
CREATE TRIGGER memory_change_derivations_no_delete
|
||||
BEFORE DELETE ON memory_change_derivations BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv derivations are retained');
|
||||
END;
|
||||
CREATE TRIGGER staging_records_no_update
|
||||
BEFORE UPDATE ON staging_records BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv staging records are immutable');
|
||||
END;
|
||||
CREATE TRIGGER staging_records_no_delete
|
||||
BEFORE DELETE ON staging_records BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv staging records are retained');
|
||||
END;
|
||||
CREATE TRIGGER staging_resolutions_no_update
|
||||
BEFORE UPDATE ON staging_resolutions BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv staging resolutions are immutable');
|
||||
END;
|
||||
CREATE TRIGGER staging_resolutions_no_delete
|
||||
BEFORE DELETE ON staging_resolutions BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv staging resolutions are retained');
|
||||
END;
|
||||
CREATE TRIGGER staging_resolution_targets_no_update
|
||||
BEFORE UPDATE ON staging_resolution_targets BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv resolution targets are immutable');
|
||||
END;
|
||||
CREATE TRIGGER staging_resolution_targets_no_delete
|
||||
BEFORE DELETE ON staging_resolution_targets BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv resolution targets are retained');
|
||||
END;
|
||||
CREATE TRIGGER surface_snapshots_no_update
|
||||
BEFORE UPDATE ON surface_snapshots BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv surface snapshots are immutable');
|
||||
END;
|
||||
CREATE TRIGGER surface_snapshots_no_delete
|
||||
BEFORE DELETE ON surface_snapshots BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv surface snapshots are retained');
|
||||
END;
|
||||
CREATE TRIGGER surface_snapshot_refs_no_update
|
||||
BEFORE UPDATE ON surface_snapshot_refs BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv surface references are immutable');
|
||||
END;
|
||||
CREATE TRIGGER surface_snapshot_refs_no_delete
|
||||
BEFORE DELETE ON surface_snapshot_refs BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv surface references are retained');
|
||||
END;
|
||||
|
||||
CREATE TABLE subject_session_attributions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
subject_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
attributed_at TEXT NOT NULL,
|
||||
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX subject_session_attributions_by_subject
|
||||
ON subject_session_attributions(subject_id, attributed_at, session_id);
|
||||
|
||||
CREATE TRIGGER subject_session_attributions_no_update
|
||||
BEFORE UPDATE ON subject_session_attributions BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv session attribution is immutable');
|
||||
END;
|
||||
CREATE TRIGGER subject_session_attributions_no_delete
|
||||
BEFORE DELETE ON subject_session_attributions BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv session attribution is retained');
|
||||
END;
|
||||
|
||||
CREATE TABLE candidate_decision_receipts (
|
||||
subject_id TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
candidate_id TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, request_id),
|
||||
UNIQUE (subject_id, candidate_id),
|
||||
FOREIGN KEY (subject_id, candidate_id)
|
||||
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER candidate_decision_receipts_no_update
|
||||
BEFORE UPDATE ON candidate_decision_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable');
|
||||
END;
|
||||
CREATE TRIGGER candidate_decision_receipts_no_delete
|
||||
BEFORE DELETE ON candidate_decision_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained');
|
||||
END;
|
||||
|
||||
CREATE TABLE surface_generation_state (
|
||||
subject_id TEXT PRIMARY KEY,
|
||||
memory_fingerprint TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')),
|
||||
snapshot_id TEXT,
|
||||
reason_code TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (subject_id, snapshot_id)
|
||||
REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE surface_generation_runs (
|
||||
subject_id TEXT NOT NULL,
|
||||
generation_id TEXT NOT NULL,
|
||||
memory_fingerprint TEXT NOT NULL,
|
||||
active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0),
|
||||
materials_json TEXT NOT NULL,
|
||||
job_id TEXT,
|
||||
attempt_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (subject_id, generation_id),
|
||||
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX surface_generation_runs_by_change
|
||||
ON surface_generation_runs(subject_id, memory_fingerprint, created_at, generation_id);
|
||||
|
||||
CREATE TRIGGER surface_generation_runs_no_update
|
||||
BEFORE UPDATE ON surface_generation_runs BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable');
|
||||
END;
|
||||
CREATE TRIGGER surface_generation_runs_no_delete
|
||||
BEFORE DELETE ON surface_generation_runs BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained');
|
||||
END;
|
||||
+175
-55
@@ -88,8 +88,8 @@ pub enum TicketError {
|
||||
InvalidReadWriteTargetCount(usize),
|
||||
#[error("ticket target authority is unavailable")]
|
||||
TargetAuthorityUnavailable,
|
||||
#[error("stale ticket item revision: expected `{expected}`, found `{actual}`")]
|
||||
StaleItemRevision { expected: String, actual: String },
|
||||
#[error("stale ticket content: expected `{expected}`, found `{actual}`")]
|
||||
StaleContent { expected: String, actual: String },
|
||||
#[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")]
|
||||
StaleWorkflowState { expected: String, actual: String },
|
||||
#[error("invalid ticket workflow transition `{from}` -> `{to}`")]
|
||||
@@ -473,7 +473,7 @@ impl TicketStateChange {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketCompletion {
|
||||
pub expected_item_revision: String,
|
||||
pub expected_content_digest: String,
|
||||
pub expected_state: TicketWorkflowState,
|
||||
pub operation_key: String,
|
||||
pub reason: String,
|
||||
@@ -483,7 +483,7 @@ pub struct TicketCompletion {
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
/// External state mutation with item/state CAS and an immutable operation receipt.
|
||||
/// External state mutation with content/state CAS and an immutable operation receipt.
|
||||
/// Exact replay returns the current authoritative Ticket without a new event or
|
||||
/// reapplying the recorded state, even after later edits or reopening.
|
||||
/// Setting `state` to Closed also stores `reason` as the resolution; reopening
|
||||
@@ -491,7 +491,7 @@ pub struct TicketCompletion {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketStateUpdate {
|
||||
pub expected_item_revision: String,
|
||||
pub expected_content_digest: String,
|
||||
pub expected_state: TicketWorkflowState,
|
||||
pub state: TicketWorkflowState,
|
||||
pub operation_key: String,
|
||||
@@ -505,7 +505,7 @@ pub struct TicketStateUpdate {
|
||||
impl From<TicketCompletion> for TicketStateUpdate {
|
||||
fn from(request: TicketCompletion) -> Self {
|
||||
Self {
|
||||
expected_item_revision: request.expected_item_revision,
|
||||
expected_content_digest: request.expected_content_digest,
|
||||
expected_state: request.expected_state,
|
||||
state: TicketWorkflowState::Done,
|
||||
operation_key: request.operation_key,
|
||||
@@ -516,17 +516,69 @@ impl From<TicketCompletion> for TicketStateUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical item revision shared by Ticket authority, item checkers, and Merge
|
||||
/// Request review records. Only persisted item_edit event indexes advance it.
|
||||
pub fn ticket_item_revision(ticket: &Ticket) -> String {
|
||||
let index = ticket
|
||||
.events
|
||||
/// SHA-256 of the actual editable Ticket requirements: identity, title, body,
|
||||
/// and ordered repository targets. Comments, timestamps, workflow state, and
|
||||
/// event order are not requirements. Restoring identical content restores its
|
||||
/// digest; state CAS and operation receipts are independent preconditions.
|
||||
pub fn ticket_content_digest(ticket: &Ticket) -> String {
|
||||
content_digest(
|
||||
&ticket.meta.id,
|
||||
&ticket.meta.title,
|
||||
ticket.document.body.as_str(),
|
||||
&ticket.meta.targets,
|
||||
)
|
||||
}
|
||||
|
||||
fn content_digest(id: &str, title: &str, body: &str, targets: &[TicketTarget]) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"ticket.content.sha256.v1\0");
|
||||
digest.update(
|
||||
serde_json::to_vec(&(id, title, body, targets)).expect("Ticket content serializes"),
|
||||
);
|
||||
digest
|
||||
.finalize()
|
||||
.iter()
|
||||
.filter(|event| event.kind == TicketEventKind::Other("item_edit".to_owned()))
|
||||
.filter_map(|event| event.attributes.get("event_sequence")?.parse::<i64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
format!("{}:{index}", ticket.meta.id)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the same content inside the caller's SQLite transaction. Review and
|
||||
/// Ticket mutation authorities must use this instead of event indexes or time.
|
||||
pub fn sqlite_ticket_content_digest(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let item: Option<(String, String)> = connection
|
||||
.query_row(
|
||||
"SELECT title,body FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, ticket_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(sqlite_err)?;
|
||||
let Some((title, body)) = item else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT repository_key,ref_selector,access FROM typed_ticket_targets WHERE workspace_id=?1 AND ticket_id=?2 ORDER BY ordinal",
|
||||
).map_err(sqlite_err)?;
|
||||
let targets = statement
|
||||
.query_map(params![workspace_id, ticket_id], |row| {
|
||||
let access: String = row.get(2)?;
|
||||
let access = TicketTargetAccess::parse(&access).ok_or_else(|| {
|
||||
rusqlite::Error::InvalidColumnType(2, "access".into(), rusqlite::types::Type::Text)
|
||||
})?;
|
||||
Ok(TicketTarget {
|
||||
repository_key: row.get(0)?,
|
||||
ref_selector: row.get(1)?,
|
||||
access,
|
||||
})
|
||||
})
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
Ok(Some(content_digest(ticket_id, &title, &body, &targets)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
@@ -2402,7 +2454,7 @@ impl SqliteTicketBackend {
|
||||
]
|
||||
.map(|key| self.event_attributes.get(key));
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"ticket.state-update.v1\0");
|
||||
digest.update(b"ticket.state-update.v2\0");
|
||||
digest.update(
|
||||
serde_json::to_vec(&(ticket_id, request, body, source))
|
||||
.map_err(|error| TicketError::Conflict(error.to_string()))?,
|
||||
@@ -2437,7 +2489,7 @@ impl SqliteTicketBackend {
|
||||
body: MarkdownText,
|
||||
) -> Result<Ticket> {
|
||||
validate_required_event_value("operation_key", &request.operation_key)?;
|
||||
validate_required_event_value("expected_item_revision", &request.expected_item_revision)?;
|
||||
validate_required_event_value("expected_content_digest", &request.expected_content_digest)?;
|
||||
validate_state_change(&TicketStateChange {
|
||||
from: request.expected_state.to_string(),
|
||||
to: request.state.to_string(),
|
||||
@@ -2457,7 +2509,7 @@ impl SqliteTicketBackend {
|
||||
.attributes
|
||||
.get("fingerprint_version")
|
||||
.map(String::as_str)
|
||||
!= Some("state-update-v1")
|
||||
!= Some("state-update-v2")
|
||||
|| event.attributes.get("request_fingerprint") != Some(&fingerprint)
|
||||
{
|
||||
return Err(TicketError::OperationFingerprintMismatch {
|
||||
@@ -2471,10 +2523,10 @@ impl SqliteTicketBackend {
|
||||
// Exact recorded replay is independent of today's live role/assignment.
|
||||
// Parent transport authenticates identity before restoring receipt context.
|
||||
self.require_active_source_assignment(conn, ticket_id)?;
|
||||
let actual = ticket_item_revision(&previous);
|
||||
if actual != request.expected_item_revision {
|
||||
return Err(TicketError::StaleItemRevision {
|
||||
expected: request.expected_item_revision.clone(),
|
||||
let actual = ticket_content_digest(&previous);
|
||||
if actual != request.expected_content_digest {
|
||||
return Err(TicketError::StaleContent {
|
||||
expected: request.expected_content_digest.clone(),
|
||||
actual,
|
||||
});
|
||||
}
|
||||
@@ -2526,11 +2578,11 @@ impl SqliteTicketBackend {
|
||||
("request_fingerprint".to_owned(), fingerprint),
|
||||
(
|
||||
"fingerprint_version".to_owned(),
|
||||
"state-update-v1".to_owned(),
|
||||
"state-update-v2".to_owned(),
|
||||
),
|
||||
(
|
||||
"expected_item_revision".to_owned(),
|
||||
request.expected_item_revision.clone(),
|
||||
"expected_content_digest".to_owned(),
|
||||
request.expected_content_digest.clone(),
|
||||
),
|
||||
]),
|
||||
},
|
||||
@@ -3449,7 +3501,7 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
let previous = self.load_ticket(conn, &ticket_id)?;
|
||||
let request = TicketStateUpdate {
|
||||
expected_item_revision: ticket_item_revision(&previous),
|
||||
expected_content_digest: ticket_content_digest(&previous),
|
||||
expected_state: from,
|
||||
state: to,
|
||||
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?,
|
||||
@@ -3634,7 +3686,7 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
let previous = self.load_ticket(conn, &ticket_id)?;
|
||||
let request = TicketStateUpdate {
|
||||
expected_item_revision: ticket_item_revision(&previous),
|
||||
expected_content_digest: ticket_content_digest(&previous),
|
||||
expected_state: previous.meta.workflow_state,
|
||||
state: TicketWorkflowState::Closed,
|
||||
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?,
|
||||
@@ -5626,13 +5678,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_revision_uses_canonical_max_item_edit_index_not_edit_count_or_content_hash() {
|
||||
fn content_digest_matches_sqlite_and_ignores_event_order() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let (reference, ticket) = backend
|
||||
.create_with_snapshot(NewTicket::new("Revision format"))
|
||||
.create_with_snapshot(NewTicket::new("Content digest"))
|
||||
.unwrap();
|
||||
assert_eq!(ticket_item_revision(&ticket), format!("{}:0", reference.id));
|
||||
assert_eq!(ticket_content_digest(&ticket).len(), 64);
|
||||
assert_eq!(
|
||||
sqlite_ticket_content_digest(
|
||||
&backend.open_connection().unwrap(),
|
||||
&backend.workspace_id,
|
||||
&reference.id
|
||||
)
|
||||
.unwrap(),
|
||||
Some(ticket_content_digest(&ticket))
|
||||
);
|
||||
backend
|
||||
.add_event(
|
||||
reference.id.clone().into(),
|
||||
@@ -5648,7 +5709,10 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_item_revision(&first), format!("{}:2", reference.id));
|
||||
assert_ne!(
|
||||
ticket_content_digest(&first),
|
||||
ticket_content_digest(&ticket)
|
||||
);
|
||||
backend
|
||||
.add_event(
|
||||
reference.id.clone().into(),
|
||||
@@ -5664,13 +5728,23 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_item_revision(&second), format!("{}:4", reference.id));
|
||||
assert_ne!(
|
||||
ticket_content_digest(&second),
|
||||
ticket_content_digest(&first)
|
||||
);
|
||||
let digest = ticket_content_digest(&second);
|
||||
assert_eq!(
|
||||
sqlite_ticket_content_digest(
|
||||
&backend.open_connection().unwrap(),
|
||||
&backend.workspace_id,
|
||||
&reference.id
|
||||
)
|
||||
.unwrap(),
|
||||
Some(digest.clone())
|
||||
);
|
||||
let mut reordered = second;
|
||||
reordered.events.reverse();
|
||||
assert_eq!(
|
||||
ticket_item_revision(&reordered),
|
||||
format!("{}:4", reference.id)
|
||||
);
|
||||
assert_eq!(ticket_content_digest(&reordered), digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5983,18 +6057,18 @@ mod tests {
|
||||
assert_eq!(event.from.as_deref(), Some("planning"));
|
||||
assert_eq!(event.reason.as_deref(), Some("finished"));
|
||||
assert_eq!(
|
||||
event.attributes.get("expected_item_revision"),
|
||||
Some(&ticket_item_revision(&ticket))
|
||||
event.attributes.get("expected_content_digest"),
|
||||
Some(&ticket_content_digest(&ticket))
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.attributes
|
||||
.get("fingerprint_version")
|
||||
.map(String::as_str),
|
||||
Some("state-update-v1")
|
||||
Some("state-update-v2")
|
||||
);
|
||||
let request = TicketStateUpdate {
|
||||
expected_item_revision: ticket_item_revision(&ticket),
|
||||
expected_content_digest: ticket_content_digest(&ticket),
|
||||
expected_state: TicketWorkflowState::Planning,
|
||||
state: TicketWorkflowState::Done,
|
||||
operation_key: event.attributes["operation_key"].clone(),
|
||||
@@ -6023,8 +6097,8 @@ mod tests {
|
||||
assert_eq!(close.body, resolution);
|
||||
assert_eq!(closed.resolution, Some(resolution));
|
||||
assert_eq!(
|
||||
close.attributes.get("expected_item_revision"),
|
||||
Some(&ticket_item_revision(&done))
|
||||
close.attributes.get("expected_content_digest"),
|
||||
Some(&ticket_content_digest(&done))
|
||||
);
|
||||
assert_ne!(
|
||||
close.attributes["operation_key"],
|
||||
@@ -6175,7 +6249,7 @@ mod tests {
|
||||
|
||||
fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion {
|
||||
TicketCompletion {
|
||||
expected_item_revision: ticket_item_revision(ticket),
|
||||
expected_content_digest: ticket_content_digest(ticket),
|
||||
expected_state: ticket.meta.workflow_state,
|
||||
operation_key: key.to_owned(),
|
||||
reason: "Acceptance criteria met".to_owned(),
|
||||
@@ -6184,6 +6258,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requests() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let (reference, ticket) = backend
|
||||
.create_with_snapshot(NewTicket::new("Old receipt"))
|
||||
.unwrap();
|
||||
let request = completion_request(&ticket, "old-operation");
|
||||
backend.complete(&reference.id, request.clone()).unwrap();
|
||||
let connection = backend.open_connection().unwrap();
|
||||
// Frozen pre-content fixture. Its old fingerprint cannot prove a new
|
||||
// content-based request, and migration must not rewrite immutable audit.
|
||||
connection.execute(
|
||||
"UPDATE typed_ticket_event_attributes SET value='state-update-v1' WHERE ticket_id=?1 AND key='fingerprint_version'",
|
||||
[&reference.id],
|
||||
).unwrap();
|
||||
connection.execute(
|
||||
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
|
||||
[&reference.id],
|
||||
).unwrap();
|
||||
let before = backend.show(reference.id.clone().into()).unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
|
||||
assert!(matches!(
|
||||
backend.complete(&reference.id, request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. }),
|
||||
));
|
||||
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
|
||||
let fresh = backend
|
||||
.complete(
|
||||
&reference.id,
|
||||
completion_request(&before, "fresh-operation"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fresh.events.len(), before.events.len() + 1);
|
||||
assert_eq!(
|
||||
fresh
|
||||
.events
|
||||
.last()
|
||||
.unwrap()
|
||||
.attributes
|
||||
.get("expected_content_digest"),
|
||||
Some(&ticket_content_digest(&before))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_from_all_states_needs_no_git_targets_or_merge_request() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -6316,7 +6436,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
backend.complete(&reference.id, request),
|
||||
Err(TicketError::StaleItemRevision { .. })
|
||||
Err(TicketError::StaleContent { .. })
|
||||
));
|
||||
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited);
|
||||
let mut request = completion_request(&edited, "complete");
|
||||
@@ -6440,7 +6560,7 @@ mod tests {
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
expected_item_revision: "other-revision".to_owned(),
|
||||
expected_content_digest: "other-content".to_owned(),
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
@@ -6562,13 +6682,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_revision_tracks_target_edits_and_aba_but_not_comments_or_state() {
|
||||
fn content_digest_tracks_targets_and_restored_content_not_comments_or_state() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let (reference, ticket) = backend
|
||||
.create_with_snapshot(NewTicket::new("Revision"))
|
||||
.create_with_snapshot(NewTicket::new("Content"))
|
||||
.unwrap();
|
||||
let revision = ticket_item_revision(&ticket);
|
||||
let digest = ticket_content_digest(&ticket);
|
||||
backend
|
||||
.add_event(
|
||||
reference.id.clone().into(),
|
||||
@@ -6576,11 +6696,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let commented = backend.show(reference.id.clone().into()).unwrap();
|
||||
assert_eq!(ticket_item_revision(&commented), revision);
|
||||
assert_eq!(ticket_content_digest(&commented), digest);
|
||||
let completed = backend
|
||||
.complete(&reference.id, completion_request(&ticket, "complete"))
|
||||
.unwrap();
|
||||
assert_eq!(ticket_item_revision(&completed), revision);
|
||||
assert_eq!(ticket_content_digest(&completed), digest);
|
||||
backend
|
||||
.edit_item(
|
||||
reference.id.clone().into(),
|
||||
@@ -6601,7 +6721,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(ticket_item_revision(&restored), revision);
|
||||
assert_eq!(ticket_content_digest(&restored), digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6618,7 +6738,7 @@ mod tests {
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
expected_item_revision: "".to_owned(),
|
||||
expected_content_digest: "".to_owned(),
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
@@ -7081,8 +7201,8 @@ mod tests {
|
||||
assert_eq!(close.from.as_deref(), Some("planning"));
|
||||
assert_eq!(close.to.as_deref(), Some("closed"));
|
||||
assert_eq!(
|
||||
close.attributes.get("expected_item_revision"),
|
||||
Some(&format!("{}:0", record.meta.id))
|
||||
close.attributes.get("expected_content_digest"),
|
||||
Some(&ticket_content_digest(&record))
|
||||
);
|
||||
assert!(close.attributes.contains_key("operation_key"));
|
||||
}
|
||||
|
||||
@@ -40,11 +40,12 @@ arguments; Backspace/Delete removes a chip atomically. Typed chips survive local
|
||||
history, draft restoration, transport retry, and rewind.
|
||||
|
||||
Every completion and chip-re-edit query carries a fresh `request_id`. Replies
|
||||
must echo that nonce and match the active query context, composer edit/cursor
|
||||
revision, authority snapshot generation, and Worker target. Identical-prefix ABA
|
||||
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches
|
||||
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even
|
||||
for File queries where the wire field remains optional for legacy callers.
|
||||
must echo that ID and match the active query context and Worker target. The first
|
||||
input/cursor edit cancels the pending input watch; returning to identical text
|
||||
cannot revive it. Authority snapshots and Worker/view switches discard pending
|
||||
queries and visible candidates, rather than advancing another counter.
|
||||
Uncorrelated replies are ignored, even for File queries where the wire field
|
||||
remains optional for legacy callers.
|
||||
|
||||
The declared attachment adapter stages a client-local file in place, replacing
|
||||
its staging chip with an `UploadedFile` reference when ready. Local file argument
|
||||
|
||||
+107
-138
@@ -35,14 +35,22 @@ pub enum CommandCompletionApply {
|
||||
/// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger
|
||||
/// is invalidated (cursor moved out, whitespace landed inside the
|
||||
/// token, the sigil was deleted, or the candidate was confirmed).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct CompletionSnapshot {
|
||||
input_revision: u64,
|
||||
generation: u64,
|
||||
input: crate::input::CompletionInputWatch,
|
||||
target: String,
|
||||
worker_view: Option<String>,
|
||||
}
|
||||
|
||||
impl CompletionSnapshot {
|
||||
fn same_context(&self, other: &Self) -> bool {
|
||||
!self.input.is_cancelled()
|
||||
&& !other.input.is_cancelled()
|
||||
&& self.target == other.target
|
||||
&& self.worker_view == other.worker_view
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingFeatureEdit {
|
||||
invocation: protocol::FeatureInvocation,
|
||||
request_id: String,
|
||||
@@ -229,7 +237,6 @@ impl ActionbarNotice {
|
||||
|
||||
pub struct InternalWorkerView {
|
||||
pub worker: InternalWorkerRef,
|
||||
pub revision: u64,
|
||||
pub app: Box<App>,
|
||||
}
|
||||
|
||||
@@ -242,7 +249,7 @@ pub struct WorkerViewTab {
|
||||
pub struct App {
|
||||
pub worker_name: String,
|
||||
pub connected: bool,
|
||||
/// Latest authoritative revisioned live execution state.
|
||||
/// Latest authoritative live execution state.
|
||||
pub worker_state: WorkerStateSnapshot,
|
||||
next_command_id: u64,
|
||||
/// Derived Runtime-catalog compatibility projection used by existing UI.
|
||||
@@ -297,8 +304,9 @@ pub struct App {
|
||||
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
||||
/// replayable conversation rows during segment rotation.
|
||||
run_error_messages: Vec<String>,
|
||||
/// Current compaction identity/revision used to fence snapshot/live updates.
|
||||
active_compaction: Option<(String, u64)>,
|
||||
/// Current compaction operation; terminal IDs cannot reopen on delayed events.
|
||||
active_compaction: Option<String>,
|
||||
terminal_compactions: std::collections::HashSet<String>,
|
||||
pub compaction_progress: Option<protocol::InFlightCompaction>,
|
||||
/// Presentation-only Internal Worker projections keyed by session identity.
|
||||
/// They are rendered in separate selectable views and never mixed into `blocks`.
|
||||
@@ -307,7 +315,7 @@ pub struct App {
|
||||
/// view; the stable session identity survives projection reordering.
|
||||
selected_internal_worker_session_id: Option<String>,
|
||||
/// Terminal child-session fences, reset only by an authoritative snapshot.
|
||||
removed_internal_workers: HashMap<String, u64>,
|
||||
removed_internal_workers: std::collections::HashSet<String>,
|
||||
pub scroll: Scroll,
|
||||
pub mode: Mode,
|
||||
pub cache: FileCache,
|
||||
@@ -323,7 +331,6 @@ pub struct App {
|
||||
/// typed FeatureInvoke chip.
|
||||
selected_feature_invocations: HashMap<String, protocol::FeatureInvocationDescriptor>,
|
||||
pending_feature_edit: Option<PendingFeatureEdit>,
|
||||
completion_generation: u64,
|
||||
completion_target: String,
|
||||
/// Dedicated main-view rewind picker state.
|
||||
pub rewind_picker: Option<RewindPickerState>,
|
||||
@@ -397,11 +404,12 @@ impl App {
|
||||
shutdown_confirm: None,
|
||||
blocks: Vec::new(),
|
||||
active_compaction: None,
|
||||
terminal_compactions: std::collections::HashSet::new(),
|
||||
compaction_progress: None,
|
||||
run_error_messages: Vec::new(),
|
||||
internal_workers: Vec::new(),
|
||||
selected_internal_worker_session_id: None,
|
||||
removed_internal_workers: HashMap::new(),
|
||||
removed_internal_workers: std::collections::HashSet::new(),
|
||||
scroll: Scroll::default(),
|
||||
mode: Mode::Normal,
|
||||
cache: FileCache::new(),
|
||||
@@ -409,7 +417,6 @@ impl App {
|
||||
completion: None,
|
||||
selected_feature_invocations: HashMap::new(),
|
||||
pending_feature_edit: None,
|
||||
completion_generation: 0,
|
||||
completion_target: protocol::new_submission_request_id(),
|
||||
rewind_picker: None,
|
||||
rewind_request_pending: false,
|
||||
@@ -542,7 +549,7 @@ impl App {
|
||||
/// Cycle the presentation-only transcript/task view. Input and control
|
||||
/// methods continue to target the parent Worker regardless of selection.
|
||||
pub fn cycle_worker_view(&mut self) -> bool {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
if self.internal_workers.is_empty() {
|
||||
self.selected_internal_worker_session_id = None;
|
||||
return false;
|
||||
@@ -604,8 +611,7 @@ impl App {
|
||||
|
||||
fn completion_snapshot(&self) -> CompletionSnapshot {
|
||||
CompletionSnapshot {
|
||||
input_revision: self.input.revision(),
|
||||
generation: self.completion_generation,
|
||||
input: self.input.watch_completion_input(),
|
||||
target: self.completion_target.clone(),
|
||||
worker_view: self.selected_internal_worker_session_id.clone(),
|
||||
}
|
||||
@@ -614,14 +620,13 @@ impl App {
|
||||
pub(crate) fn set_completion_target(&mut self, target: String) {
|
||||
if self.completion_target != target {
|
||||
self.completion_target = target;
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.selected_feature_invocations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Authority/snapshot/target changes revoke in-flight queries and visible candidates.
|
||||
fn invalidate_completion_generation(&mut self) {
|
||||
self.completion_generation = self.completion_generation.wrapping_add(1);
|
||||
fn cancel_completion_requests(&mut self) {
|
||||
self.completion = None;
|
||||
self.pending_feature_edit = None;
|
||||
}
|
||||
@@ -672,7 +677,7 @@ impl App {
|
||||
&& state.prefix_start == start
|
||||
&& state.prefix == prefix
|
||||
&& state.context == context
|
||||
&& state.snapshot == snapshot
|
||||
&& state.snapshot.same_context(&snapshot)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
@@ -722,7 +727,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn cancel_completion(&mut self) {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
}
|
||||
|
||||
/// Tab path: insert the popup-selected entry's value (with a
|
||||
@@ -736,7 +741,7 @@ impl App {
|
||||
if self
|
||||
.completion
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.snapshot != self.completion_snapshot())
|
||||
.is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
|
||||
{
|
||||
self.cancel_completion();
|
||||
return None;
|
||||
@@ -885,7 +890,7 @@ impl App {
|
||||
if self
|
||||
.completion
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.snapshot != self.completion_snapshot())
|
||||
.is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
|
||||
{
|
||||
self.cancel_completion();
|
||||
return false;
|
||||
@@ -942,7 +947,7 @@ impl App {
|
||||
if self
|
||||
.completion
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.snapshot != self.completion_snapshot())
|
||||
.is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
|
||||
{
|
||||
self.cancel_completion();
|
||||
return false;
|
||||
@@ -1248,22 +1253,23 @@ impl App {
|
||||
|
||||
pub fn continue_pending_method(&self) -> Option<Method> {
|
||||
Some(Method::ContinuePending {
|
||||
expected_revision: self.pending_submissions.revision,
|
||||
expected_head_id: self.pending_submissions.head_id.clone()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_pending_method(&self) -> Method {
|
||||
Method::ClearPendingSubmissions {
|
||||
expected_revision: self.pending_submissions.revision,
|
||||
expected_submission_ids: self
|
||||
.pending_submissions
|
||||
.submissions
|
||||
.iter()
|
||||
.map(|submission| submission.submission_id.clone())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_pending_method(&self, submission_id: String) -> Method {
|
||||
Method::CancelPendingSubmission {
|
||||
submission_id,
|
||||
expected_revision: self.pending_submissions.revision,
|
||||
}
|
||||
Method::CancelPendingSubmission { submission_id }
|
||||
}
|
||||
|
||||
pub fn next_queued_input_preview(&self) -> Option<&str> {
|
||||
@@ -1802,14 +1808,13 @@ impl App {
|
||||
});
|
||||
}
|
||||
Event::CompactStart { lifecycle } => {
|
||||
let should_apply = match &self.active_compaction {
|
||||
None => true,
|
||||
Some((id, revision)) => {
|
||||
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||
}
|
||||
};
|
||||
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
|
||||
&& self
|
||||
.active_compaction
|
||||
.as_ref()
|
||||
.is_none_or(|id| id == &lifecycle.compaction_id);
|
||||
if should_apply {
|
||||
self.active_compaction = Some((lifecycle.compaction_id, lifecycle.revision));
|
||||
self.active_compaction = Some(lifecycle.compaction_id);
|
||||
if self.last_streaming_compact_mut().is_none() {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
@@ -1818,15 +1823,16 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::CompactDone { lifecycle } => {
|
||||
let should_apply = match &self.active_compaction {
|
||||
None => true,
|
||||
Some((id, revision)) => {
|
||||
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||
}
|
||||
};
|
||||
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
|
||||
&& self
|
||||
.active_compaction
|
||||
.as_ref()
|
||||
.is_none_or(|id| id == &lifecycle.compaction_id);
|
||||
if !should_apply {
|
||||
return None;
|
||||
}
|
||||
self.terminal_compactions
|
||||
.insert(lifecycle.compaction_id.clone());
|
||||
self.active_compaction = None;
|
||||
let new_segment_id = lifecycle
|
||||
.new_segment_id
|
||||
@@ -1852,15 +1858,16 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::CompactFailed { lifecycle } => {
|
||||
let should_apply = match &self.active_compaction {
|
||||
None => true,
|
||||
Some((id, revision)) => {
|
||||
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||
}
|
||||
};
|
||||
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
|
||||
&& self
|
||||
.active_compaction
|
||||
.as_ref()
|
||||
.is_none_or(|id| id == &lifecycle.compaction_id);
|
||||
if !should_apply {
|
||||
return None;
|
||||
}
|
||||
self.terminal_compactions
|
||||
.insert(lifecycle.compaction_id.clone());
|
||||
self.active_compaction = None;
|
||||
let error = lifecycle
|
||||
.error
|
||||
@@ -1915,14 +1922,10 @@ impl App {
|
||||
self.replace_internal_worker_snapshots(internal_workers);
|
||||
return self.refresh_completion();
|
||||
}
|
||||
Event::InternalWorker {
|
||||
worker,
|
||||
revision,
|
||||
event,
|
||||
} => self.apply_internal_worker_event(worker, revision, *event),
|
||||
Event::InternalWorkerRemoved { worker, revision } => {
|
||||
self.remove_internal_worker(worker, revision)
|
||||
Event::InternalWorker { worker, event } => {
|
||||
self.apply_internal_worker_event(worker, *event)
|
||||
}
|
||||
Event::InternalWorkerRemoved { worker } => self.remove_internal_worker(worker),
|
||||
Event::WorkerState { snapshot } => {
|
||||
self.rewind_refresh_fence = false;
|
||||
self.apply_worker_state_snapshot(&snapshot);
|
||||
@@ -1956,7 +1959,7 @@ impl App {
|
||||
&& self.pending_feature_edit.as_ref().is_some_and(|edit| {
|
||||
edit.invocation.name == prefix
|
||||
&& request_id.as_ref() == Some(&edit.request_id)
|
||||
&& edit.snapshot == snapshot
|
||||
&& edit.snapshot.same_context(&snapshot)
|
||||
})
|
||||
{
|
||||
let invocation = self.pending_feature_edit.take().unwrap().invocation;
|
||||
@@ -1981,7 +1984,7 @@ impl App {
|
||||
// stale argument/provider replies cannot cross invocation scope.
|
||||
if let Some(state) = self.completion.as_mut()
|
||||
&& request_id.as_ref() == Some(&state.request_id)
|
||||
&& state.snapshot == snapshot
|
||||
&& state.snapshot.same_context(&snapshot)
|
||||
&& state.kind == kind
|
||||
&& state.prefix == prefix
|
||||
&& state.context == context
|
||||
@@ -2100,7 +2103,7 @@ impl App {
|
||||
);
|
||||
}
|
||||
Event::Shutdown => {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.mark_orphan_compacts_incomplete();
|
||||
self.quit = true;
|
||||
}
|
||||
@@ -2309,7 +2312,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn enter_command_mode(&mut self) {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.input_mode = CommandInputMode::Command;
|
||||
self.completion = None;
|
||||
self.command_completion_selected = None;
|
||||
@@ -2317,7 +2320,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn exit_command_mode(&mut self) {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.input_mode = CommandInputMode::Composer;
|
||||
self.command_input.clear();
|
||||
self.command_completion_selected = None;
|
||||
@@ -2445,7 +2448,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
// Rewind is a parent Worker control surface. Bring the parent transcript
|
||||
// back into view before presenting diagnostics or the picker.
|
||||
self.selected_internal_worker_session_id = None;
|
||||
@@ -2791,21 +2794,12 @@ impl App {
|
||||
app.replace_internal_worker_snapshots(snapshot.internal_workers);
|
||||
InternalWorkerView {
|
||||
worker: snapshot.worker,
|
||||
revision: snapshot.revision,
|
||||
app: Box::new(app),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_internal_worker_event(
|
||||
&mut self,
|
||||
worker: InternalWorkerRef,
|
||||
revision: u64,
|
||||
event: Event,
|
||||
) {
|
||||
if self
|
||||
.removed_internal_workers
|
||||
.contains_key(&worker.session_id)
|
||||
{
|
||||
fn apply_internal_worker_event(&mut self, worker: InternalWorkerRef, event: Event) {
|
||||
if self.removed_internal_workers.contains(&worker.session_id) {
|
||||
return;
|
||||
}
|
||||
let index = self
|
||||
@@ -2819,21 +2813,16 @@ impl App {
|
||||
app.mode = self.mode;
|
||||
self.internal_workers.push(InternalWorkerView {
|
||||
worker: worker.clone(),
|
||||
revision: 0,
|
||||
app: Box::new(app),
|
||||
});
|
||||
self.internal_workers.last_mut().unwrap()
|
||||
};
|
||||
if revision <= target.revision {
|
||||
return;
|
||||
}
|
||||
target.worker = worker;
|
||||
target.revision = revision;
|
||||
let _ = target.app.handle_worker_event(event);
|
||||
}
|
||||
|
||||
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) {
|
||||
self.invalidate_completion_generation();
|
||||
fn remove_internal_worker(&mut self, worker: InternalWorkerRef) {
|
||||
self.cancel_completion_requests();
|
||||
let session_id = worker.session_id;
|
||||
let Some(index) = self
|
||||
.internal_workers
|
||||
@@ -2843,20 +2832,14 @@ impl App {
|
||||
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
|
||||
self.selected_internal_worker_session_id = None;
|
||||
}
|
||||
self.removed_internal_workers
|
||||
.entry(session_id)
|
||||
.and_modify(|current| *current = (*current).max(revision))
|
||||
.or_insert(revision);
|
||||
self.removed_internal_workers.insert(session_id);
|
||||
return;
|
||||
};
|
||||
if revision <= self.internal_workers[index].revision {
|
||||
return;
|
||||
}
|
||||
self.internal_workers.remove(index);
|
||||
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
|
||||
self.selected_internal_worker_session_id = None;
|
||||
}
|
||||
self.removed_internal_workers.insert(session_id, revision);
|
||||
self.removed_internal_workers.insert(session_id);
|
||||
}
|
||||
|
||||
fn restore_snapshot(
|
||||
@@ -2918,7 +2901,7 @@ impl App {
|
||||
session: &protocol::SessionSnapshot,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
) {
|
||||
self.invalidate_completion_generation();
|
||||
self.cancel_completion_requests();
|
||||
self.run_error_messages.clear();
|
||||
self.turn_index = 0;
|
||||
self.blocks.clear();
|
||||
@@ -4102,7 +4085,6 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 3,
|
||||
notification_count: 0,
|
||||
notification_previews: vec![],
|
||||
head_id: Some("submission-1".into()),
|
||||
@@ -4497,34 +4479,25 @@ mod completion_flow_tests {
|
||||
};
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 2,
|
||||
event: Box::new(Event::TextDelta {
|
||||
text: "child output".into(),
|
||||
}),
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker,
|
||||
revision: 1,
|
||||
event: Box::new(Event::TextDelta {
|
||||
text: "stale".into(),
|
||||
text: " next".into(),
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(app.blocks.is_empty());
|
||||
assert_eq!(app.internal_workers.len(), 1);
|
||||
assert_eq!(app.internal_workers[0].revision, 2);
|
||||
assert!(
|
||||
app.internal_workers[0].app.blocks.iter().any(
|
||||
|block| matches!(block, Block::AssistantText { text } if text == "child output")
|
||||
)
|
||||
);
|
||||
assert!(app.internal_workers[0].app.blocks.iter().any(
|
||||
|block| matches!(block, Block::AssistantText { text } if text == "child output next")
|
||||
));
|
||||
}
|
||||
|
||||
fn test_internal_worker_snapshot(
|
||||
session_id: &str,
|
||||
name: &str,
|
||||
revision: u64,
|
||||
) -> InternalWorkerSnapshot {
|
||||
fn test_internal_worker_snapshot(session_id: &str, name: &str) -> InternalWorkerSnapshot {
|
||||
InternalWorkerSnapshot {
|
||||
worker: InternalWorkerRef {
|
||||
session_id: session_id.into(),
|
||||
@@ -4532,7 +4505,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: Some("parent".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision,
|
||||
status: WorkerStatus::Idle,
|
||||
greeting: None,
|
||||
session: protocol::SessionSnapshot {
|
||||
@@ -4548,7 +4520,7 @@ mod completion_flow_tests {
|
||||
#[test]
|
||||
fn internal_worker_snapshot_uses_child_greeting_metadata() {
|
||||
let mut app = App::new("parent".into());
|
||||
let mut child = test_internal_worker_snapshot("child", "research", 1);
|
||||
let mut child = test_internal_worker_snapshot("child", "research");
|
||||
let mut greeting = test_greeting();
|
||||
greeting.worker_name = "research".into();
|
||||
greeting.model = "child-model".into();
|
||||
@@ -4570,7 +4542,7 @@ mod completion_flow_tests {
|
||||
#[test]
|
||||
fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() {
|
||||
let mut app = App::new("parent".into());
|
||||
let worker = test_internal_worker_snapshot("child", "research", 1).worker;
|
||||
let worker = test_internal_worker_snapshot("child", "research").worker;
|
||||
let mut greeting = test_greeting();
|
||||
greeting.worker_name = "research".into();
|
||||
greeting.model = "child-model".into();
|
||||
@@ -4582,7 +4554,6 @@ mod completion_flow_tests {
|
||||
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 1,
|
||||
event: Box::new(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
@@ -4596,7 +4567,6 @@ mod completion_flow_tests {
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker,
|
||||
revision: 2,
|
||||
event: Box::new(Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
tokens: 14_000,
|
||||
@@ -4626,7 +4596,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: Some("parent".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
app: Box::new(App::new(name.into())),
|
||||
});
|
||||
}
|
||||
@@ -4664,7 +4633,7 @@ mod completion_flow_tests {
|
||||
|
||||
let mut app = App::new("parent".into());
|
||||
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
|
||||
"child", "child", 1,
|
||||
"child", "child",
|
||||
)]);
|
||||
select_first_row(&mut app, "parent selection");
|
||||
select_first_row(app.internal_workers[0].app.as_mut(), "child selection");
|
||||
@@ -4687,7 +4656,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: Some("parent".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
app: Box::new(App::new("old".into())),
|
||||
});
|
||||
app.cycle_worker_view();
|
||||
@@ -4711,7 +4679,7 @@ mod completion_flow_tests {
|
||||
|
||||
let mut app = App::new("parent".into());
|
||||
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
|
||||
"child", "child", 1,
|
||||
"child", "child",
|
||||
)]);
|
||||
let child = app.internal_workers[0].app.as_mut();
|
||||
child.scroll.follow_tail = false;
|
||||
@@ -4733,11 +4701,9 @@ mod completion_flow_tests {
|
||||
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
|
||||
"child",
|
||||
"renamed-child",
|
||||
2,
|
||||
)]);
|
||||
|
||||
let view = &app.internal_workers[0];
|
||||
assert_eq!(view.revision, 2);
|
||||
assert_eq!(view.app.worker_name, "renamed-child");
|
||||
assert!(!view.app.scroll.follow_tail);
|
||||
assert_eq!(view.app.scroll.top_offset, 7);
|
||||
@@ -4749,8 +4715,8 @@ mod completion_flow_tests {
|
||||
fn task_pane_scroll_is_local_to_selected_worker_view() {
|
||||
let mut app = App::new("parent".into());
|
||||
app.replace_internal_worker_snapshots(vec![
|
||||
test_internal_worker_snapshot("child-a", "alpha", 1),
|
||||
test_internal_worker_snapshot("child-b", "beta", 1),
|
||||
test_internal_worker_snapshot("child-a", "alpha"),
|
||||
test_internal_worker_snapshot("child-b", "beta"),
|
||||
]);
|
||||
app.task_pane_scroll = 3;
|
||||
|
||||
@@ -4793,10 +4759,8 @@ mod completion_flow_tests {
|
||||
};
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 2,
|
||||
event: Box::new(Event::InternalWorker {
|
||||
worker: nested,
|
||||
revision: 1,
|
||||
event: Box::new(Event::TextDone {
|
||||
text: "nested".into(),
|
||||
}),
|
||||
@@ -4809,11 +4773,9 @@ mod completion_flow_tests {
|
||||
|
||||
app.handle_worker_event(Event::InternalWorkerRemoved {
|
||||
worker: worker.clone(),
|
||||
revision: 3,
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker,
|
||||
revision: 4,
|
||||
event: Box::new(Event::TextDone {
|
||||
text: "late".into(),
|
||||
}),
|
||||
@@ -4836,7 +4798,7 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_internal_worker_removal_keeps_newer_projection() {
|
||||
fn internal_worker_removal_applies_in_stream_order() {
|
||||
let mut app = App::new("parent".into());
|
||||
let worker = InternalWorkerRef {
|
||||
session_id: "child-session".into(),
|
||||
@@ -4846,18 +4808,13 @@ mod completion_flow_tests {
|
||||
};
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 4,
|
||||
event: Box::new(Event::TextDone {
|
||||
text: "current".into(),
|
||||
}),
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorkerRemoved {
|
||||
worker,
|
||||
revision: 3,
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorkerRemoved { worker });
|
||||
|
||||
assert_eq!(app.internal_workers.len(), 1);
|
||||
assert_eq!(app.internal_workers[0].revision, 4);
|
||||
assert!(app.internal_workers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4870,7 +4827,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: None,
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
app: Box::new(App::new("old".into())),
|
||||
});
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
@@ -4888,7 +4844,6 @@ mod completion_flow_tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 4,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
@@ -4903,7 +4858,6 @@ mod completion_flow_tests {
|
||||
|
||||
assert_eq!(app.internal_workers.len(), 1);
|
||||
assert_eq!(app.internal_workers[0].worker.session_id, "replacement");
|
||||
assert_eq!(app.internal_workers[0].revision, 4);
|
||||
assert_eq!(
|
||||
app.internal_workers[0].app.worker_status,
|
||||
WorkerStatus::Running
|
||||
@@ -4955,7 +4909,6 @@ mod completion_flow_tests {
|
||||
protocol::CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-test".into(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state,
|
||||
started_at_ms: 1,
|
||||
@@ -4975,7 +4928,6 @@ mod completion_flow_tests {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||
lifecycle.revision = 2;
|
||||
lifecycle.new_segment_id = Some(id.to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
@@ -4989,6 +4941,25 @@ mod completion_flow_tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_compaction_identity_rejects_duplicate_and_delayed_start() {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
app.handle_worker_event(Event::CompactDone {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Done),
|
||||
});
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
app.handle_worker_event(Event::CompactDone {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Done),
|
||||
});
|
||||
assert_eq!(compact_block_count(&app), 1);
|
||||
assert!(app.active_compaction.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_failed_replaces_live_block() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -4997,7 +4968,6 @@ mod completion_flow_tests {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
|
||||
lifecycle.revision = 2;
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
app.handle_worker_event(Event::CompactFailed { lifecycle });
|
||||
|
||||
@@ -5662,7 +5632,7 @@ mod completion_correlation_tests {
|
||||
let first = app.refresh_completion().unwrap();
|
||||
app.input.insert_char('x');
|
||||
app.input.delete_before();
|
||||
// Prefix, context, token location, and cursor are identical; semantic revision is not.
|
||||
// Returning to the same text cannot revive a request cancelled by the edit.
|
||||
app.handle_worker_event(reply(&first, "old"));
|
||||
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
||||
let second = app.refresh_completion().unwrap();
|
||||
@@ -5734,7 +5704,7 @@ mod completion_correlation_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_snapshot_generation_aba_revokes_queries_even_when_scope_returns_to_original() {
|
||||
fn permission_snapshot_aba_revokes_queries_even_when_scope_returns_to_original() {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(authority_snapshot("Writable: /tmp"));
|
||||
crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run");
|
||||
@@ -5748,8 +5718,8 @@ mod completion_correlation_tests {
|
||||
.unwrap();
|
||||
assert_ne!(request_id(&first), request_id(&denied));
|
||||
assert_ne!(request_id(&first), request_id(¤t));
|
||||
app.handle_worker_event(reply(&first, "old permission generation"));
|
||||
app.handle_worker_event(reply(&denied, "intermediate generation"));
|
||||
app.handle_worker_event(reply(&first, "old permission context"));
|
||||
app.handle_worker_event(reply(&denied, "intermediate context"));
|
||||
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
||||
app.handle_worker_event(reply(¤t, "資料/current"));
|
||||
assert_eq!(
|
||||
@@ -5789,7 +5759,6 @@ mod completion_correlation_tests {
|
||||
parent_session_id: Some("parent".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle),
|
||||
}),
|
||||
|
||||
@@ -674,7 +674,7 @@ mod tests {
|
||||
|
||||
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
|
||||
BackendWorkerSummary {
|
||||
restore_observation_token: Some("observed-worker-generation".into()),
|
||||
restore_observation_token: Some("observed-worker-state".into()),
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
resource_key: "W-1".to_string(),
|
||||
@@ -953,7 +953,6 @@ mod tests {
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".into(),
|
||||
settings_revision: 1,
|
||||
language: "English".into(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
@@ -1107,7 +1106,7 @@ mod tests {
|
||||
target,
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(client::BackendWorkerRestoreRequest {
|
||||
expected_observation_token: "observed-worker-generation".into(),
|
||||
expected_observation_token: "observed-worker-state".into(),
|
||||
request_id: "resume-operation".into(),
|
||||
}),
|
||||
|_| Ok(false),
|
||||
@@ -1142,7 +1141,7 @@ mod tests {
|
||||
target,
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(client::BackendWorkerRestoreRequest {
|
||||
expected_observation_token: "observed-worker-generation".into(),
|
||||
expected_observation_token: "observed-worker-state".into(),
|
||||
request_id: "resume-operation".into(),
|
||||
}),
|
||||
|_| Ok(false),
|
||||
|
||||
@@ -2625,7 +2625,6 @@ mod tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 2,
|
||||
notification_count: 0,
|
||||
notification_previews: vec![],
|
||||
head_id: Some("submission-1".into()),
|
||||
@@ -2646,7 +2645,6 @@ mod tests {
|
||||
assert!(matches!(
|
||||
continue_next,
|
||||
Some(Method::ContinuePending {
|
||||
expected_revision: 2,
|
||||
ref expected_head_id,
|
||||
}) if expected_head_id == "submission-1"
|
||||
));
|
||||
@@ -2659,7 +2657,6 @@ mod tests {
|
||||
assert!(matches!(
|
||||
cancel,
|
||||
Some(Method::CancelPendingSubmission {
|
||||
expected_revision: 2,
|
||||
ref submission_id,
|
||||
}) if submission_id == "submission-1"
|
||||
));
|
||||
@@ -2671,8 +2668,8 @@ mod tests {
|
||||
assert!(matches!(
|
||||
clear,
|
||||
Some(Method::ClearPendingSubmissions {
|
||||
expected_revision: 2
|
||||
})
|
||||
ref expected_submission_ids
|
||||
}) if expected_submission_ids == &["submission-1"]
|
||||
));
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
}
|
||||
@@ -2682,7 +2679,6 @@ mod tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 2,
|
||||
notification_count: 0,
|
||||
notification_previews: vec![],
|
||||
head_id: Some("submission-1".into()),
|
||||
@@ -3355,7 +3351,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
@@ -3471,7 +3466,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
@@ -3497,7 +3491,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
@@ -3537,7 +3530,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
|
||||
+55
-25
@@ -11,6 +11,14 @@
|
||||
//! back to their original captured content so the Worker sees the full
|
||||
//! pasted text (without the placeholder label).
|
||||
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
@@ -281,9 +289,19 @@ struct SelectedInvocation {
|
||||
invocation_id: String,
|
||||
}
|
||||
|
||||
/// A pending completion is cancelled by the first subsequent input/cursor edit.
|
||||
/// Returning to identical text does not revive that request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CompletionInputWatch(Arc<AtomicBool>);
|
||||
|
||||
impl CompletionInputWatch {
|
||||
pub(crate) fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InputBuffer {
|
||||
/// Monotonic semantic edit/cursor revision, including ABA changes.
|
||||
revision: u64,
|
||||
pending_completion: RefCell<Option<Arc<AtomicBool>>>,
|
||||
atoms: Vec<Atom>,
|
||||
selected_invocations: Vec<SelectedInvocation>,
|
||||
removed_attachment_stages: Vec<String>,
|
||||
@@ -300,7 +318,7 @@ pub struct InputBuffer {
|
||||
impl Default for InputBuffer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
revision: 0,
|
||||
pending_completion: RefCell::new(None),
|
||||
atoms: Vec::new(),
|
||||
selected_invocations: Vec::new(),
|
||||
removed_attachment_stages: Vec::new(),
|
||||
@@ -312,13 +330,25 @@ impl Default for InputBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputBuffer {
|
||||
fn drop(&mut self) {
|
||||
self.cancel_completion_on_edit();
|
||||
}
|
||||
}
|
||||
|
||||
impl InputBuffer {
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
pub(crate) fn watch_completion_input(&self) -> CompletionInputWatch {
|
||||
CompletionInputWatch(Arc::clone(
|
||||
self.pending_completion
|
||||
.borrow_mut()
|
||||
.get_or_insert_with(|| Arc::new(AtomicBool::new(false))),
|
||||
))
|
||||
}
|
||||
|
||||
fn bump_revision(&mut self) {
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
fn cancel_completion_on_edit(&mut self) {
|
||||
if let Some(pending) = self.pending_completion.get_mut().take() {
|
||||
pending.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
@@ -326,7 +356,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.selected_invocations.clear();
|
||||
self.atoms.clear();
|
||||
self.cursor = 0;
|
||||
@@ -349,7 +379,7 @@ impl InputBuffer {
|
||||
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
|
||||
/// and placing the cursor at the end of the restored input.
|
||||
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.selected_invocations.clear();
|
||||
self.atoms.clear();
|
||||
for segment in segments {
|
||||
@@ -400,7 +430,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.adjust_selection(self.cursor, self.cursor, 1);
|
||||
self.atoms.insert(self.cursor, Atom::Char(c));
|
||||
self.cursor += 1;
|
||||
@@ -445,7 +475,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn insert_paste(&mut self, content: String) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let measurement = measure_paste(&content);
|
||||
if measurement.presentation() == PastePresentation::Text {
|
||||
self.insert_str(&content);
|
||||
@@ -469,7 +499,7 @@ impl InputBuffer {
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.adjust_selection(self.cursor, self.cursor, 1);
|
||||
self.atoms.insert(self.cursor, Atom::UploadedFile(file));
|
||||
self.cursor += 1;
|
||||
@@ -493,7 +523,7 @@ impl InputBuffer {
|
||||
start: usize,
|
||||
descriptor: protocol::FeatureInvocationDescriptor,
|
||||
) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.selected_invocations
|
||||
.retain(|selection| selection.start != start);
|
||||
self.selected_invocations.push(SelectedInvocation {
|
||||
@@ -508,7 +538,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn retry_attachment_stage(&mut self, id: &str) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
for atom in &mut self.atoms {
|
||||
if let Atom::AttachmentStage {
|
||||
id: stage_id,
|
||||
@@ -563,7 +593,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let inserted = atoms.len();
|
||||
self.adjust_selection(start, end, inserted);
|
||||
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
|
||||
@@ -669,7 +699,7 @@ impl InputBuffer {
|
||||
id: &str,
|
||||
file: Option<protocol::UploadedFileRef>,
|
||||
) -> bool {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let Some(index) = self.atoms.iter().position(
|
||||
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
|
||||
) else {
|
||||
@@ -965,12 +995,12 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.cursor = (self.cursor + 1).min(self.atoms.len());
|
||||
}
|
||||
|
||||
@@ -978,7 +1008,7 @@ impl InputBuffer {
|
||||
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
|
||||
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
|
||||
pub fn move_word_left(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
@@ -993,7 +1023,7 @@ impl InputBuffer {
|
||||
|
||||
/// Move forward by one word. Mirror of [`move_word_left`].
|
||||
pub fn move_word_right(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
while self.cursor < self.atoms.len()
|
||||
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
|
||||
{
|
||||
@@ -1009,7 +1039,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn move_start(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
@@ -1058,13 +1088,13 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let (ranges, line, _) = self.logical_line_and_col();
|
||||
self.cursor = ranges[line].0;
|
||||
}
|
||||
|
||||
pub fn move_end(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let (ranges, line, _) = self.logical_line_and_col();
|
||||
self.cursor = ranges[line].1;
|
||||
}
|
||||
@@ -1072,7 +1102,7 @@ impl InputBuffer {
|
||||
/// Move one logical line up, preserving column (atom count from
|
||||
/// current line start). No-op if already on the first line.
|
||||
pub fn move_up(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let (ranges, line, col) = self.logical_line_and_col();
|
||||
if line == 0 {
|
||||
return;
|
||||
@@ -1083,7 +1113,7 @@ impl InputBuffer {
|
||||
|
||||
/// Move one logical line down, preserving column.
|
||||
pub fn move_down(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cancel_completion_on_edit();
|
||||
let (ranges, line, col) = self.logical_line_and_col();
|
||||
let Some(&(start, end)) = ranges.get(line + 1) else {
|
||||
return;
|
||||
|
||||
@@ -2187,7 +2187,6 @@ mod tests {
|
||||
fn set_pending_submission(app: &mut App, id: &str) {
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 1,
|
||||
notification_count: 0,
|
||||
notification_previews: vec![],
|
||||
head_id: Some(id.into()),
|
||||
@@ -2320,7 +2319,6 @@ mod tests {
|
||||
parent_session_id: Some("parent-session".into()),
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(protocol::Event::TextDelta {
|
||||
text: "child transcript".into(),
|
||||
}),
|
||||
|
||||
@@ -75,7 +75,6 @@ pub struct WorkingDirectoryRepository {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub source: server_api::RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<RepositorySelector>,
|
||||
@@ -121,7 +120,7 @@ impl std::fmt::Debug for SensitiveString {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySshCredentialCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub public_key_fingerprint: String,
|
||||
#[serde(skip, default)]
|
||||
pub private_key: SensitiveString,
|
||||
}
|
||||
@@ -130,7 +129,7 @@ pub struct RepositorySshCredentialCandidate {
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||
pub host_trust_id: String,
|
||||
pub host_trust_revision: u64,
|
||||
pub host_key_fingerprint: String,
|
||||
pub access: server_api::RepositoryAccessMode,
|
||||
pub expires_at_epoch_seconds: u64,
|
||||
pub repository_id: String,
|
||||
@@ -146,7 +145,6 @@ pub struct RepositoryMaterializationContext {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub operation_id: String,
|
||||
pub config_revision: u64,
|
||||
pub config_projection_digest: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||
@@ -191,10 +189,9 @@ pub struct RepositoryRefObservationRequest {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositoryRefObservation {
|
||||
pub repository_id: String,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub selector: String,
|
||||
pub revision_ref: String,
|
||||
pub resolved_ref: String,
|
||||
pub observed_at_epoch_seconds: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ impl ConfigBundle {
|
||||
pub fn computed_digest(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("id\0{}", self.metadata.id));
|
||||
lines.push(format!("revision\0{}", self.metadata.revision));
|
||||
lines.push(format!("workspace_id\0{}", self.metadata.workspace_id));
|
||||
lines.push(format!("created_at\0{}", self.metadata.created_at));
|
||||
lines.push(format!(
|
||||
@@ -82,7 +81,7 @@ impl ConfigBundle {
|
||||
if let Some(prompt_catalog) = &self.prompt_catalog {
|
||||
lines.push(format!(
|
||||
"prompt_catalog\0{}\0{}\0{}\0{}",
|
||||
prompt_catalog.config_revision,
|
||||
prompt_catalog.source_digest,
|
||||
prompt_catalog.schema_fingerprint,
|
||||
prompt_catalog.toolchain_fingerprint,
|
||||
prompt_catalog.catalog_digest
|
||||
@@ -100,12 +99,8 @@ impl ConfigBundle {
|
||||
}
|
||||
if let Some(handle) = &self.profile_source_archive_handle {
|
||||
lines.push(format!(
|
||||
"profile_archive_handle\0{}\0{}\0{}\0{}\0{}",
|
||||
handle.workspace_id,
|
||||
handle.resource_id,
|
||||
handle.digest,
|
||||
handle.revision,
|
||||
handle.max_bytes
|
||||
"profile_archive_handle\0{}\0{}\0{}\0{}",
|
||||
handle.workspace_id, handle.resource_id, handle.digest, handle.max_bytes
|
||||
));
|
||||
for (selector, path) in handle
|
||||
.profile_source_graph
|
||||
@@ -140,7 +135,6 @@ impl ConfigBundle {
|
||||
id: self.metadata.id.clone(),
|
||||
digest: self.metadata.digest.clone(),
|
||||
digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(),
|
||||
revision: self.metadata.revision.clone(),
|
||||
workspace_id: self.metadata.workspace_id.clone(),
|
||||
created_at: self.metadata.created_at.clone(),
|
||||
provenance: self.metadata.provenance.clone(),
|
||||
@@ -182,7 +176,6 @@ impl From<ConfigBundle> for server_api::WorkspaceRuntimeConfigResponse {
|
||||
prompt_catalog: prompt_catalog.map(|catalog| {
|
||||
server_api::WorkspaceRuntimePromptCatalog {
|
||||
templates: catalog.templates,
|
||||
config_revision: catalog.config_revision,
|
||||
source_digest: catalog.source_digest,
|
||||
schema_fingerprint: catalog.schema_fingerprint,
|
||||
toolchain_fingerprint: catalog.toolchain_fingerprint,
|
||||
@@ -200,7 +193,6 @@ impl From<ConfigBundleMetadata> for server_api::WorkspaceRuntimeConfigMetadata {
|
||||
Self {
|
||||
id: metadata.id,
|
||||
digest: metadata.digest,
|
||||
revision: metadata.revision,
|
||||
workspace_id: metadata.workspace_id,
|
||||
created_at: metadata.created_at,
|
||||
provenance: metadata.provenance.into(),
|
||||
@@ -303,8 +295,6 @@ impl From<BackendResourceHandle> for server_api::WorkspaceRuntimeResourceHandle
|
||||
operation: handle.operation.into(),
|
||||
expires_at_unix_seconds: handle.expires_at_unix_seconds,
|
||||
nonce: handle.nonce,
|
||||
revision: handle.revision,
|
||||
generation: handle.generation,
|
||||
max_bytes: handle.max_bytes,
|
||||
content_type: handle.content_type,
|
||||
redaction: handle.redaction.into(),
|
||||
@@ -344,7 +334,6 @@ impl From<ResourceRedactionPolicy> for server_api::WorkspaceRuntimeResourceRedac
|
||||
pub struct ConfigBundleMetadata {
|
||||
pub id: String,
|
||||
pub digest: String,
|
||||
pub revision: String,
|
||||
pub workspace_id: String,
|
||||
pub created_at: String,
|
||||
pub provenance: ConfigBundleProvenance,
|
||||
@@ -402,7 +391,6 @@ pub struct ConfigBundleSummary {
|
||||
pub id: String,
|
||||
pub digest: String,
|
||||
pub digest_algorithm: String,
|
||||
pub revision: String,
|
||||
pub workspace_id: String,
|
||||
pub created_at: String,
|
||||
pub provenance: ConfigBundleProvenance,
|
||||
@@ -422,7 +410,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
|
||||
validate_config_bundle_id(&bundle.metadata.id)?;
|
||||
validate_non_empty("config bundle digest", &bundle.metadata.digest)?;
|
||||
validate_digest("config bundle digest", &bundle.metadata.digest)?;
|
||||
validate_non_empty("config bundle revision", &bundle.metadata.revision)?;
|
||||
validate_non_empty("config bundle workspace id", &bundle.metadata.workspace_id)?;
|
||||
validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?;
|
||||
validate_non_empty(
|
||||
@@ -430,7 +417,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
|
||||
&bundle.metadata.provenance.source,
|
||||
)?;
|
||||
validate_boundary_text("config bundle id", &bundle.metadata.id)?;
|
||||
validate_boundary_text("config bundle revision", &bundle.metadata.revision)?;
|
||||
validate_boundary_text("config bundle workspace id", &bundle.metadata.workspace_id)?;
|
||||
validate_boundary_text(
|
||||
"config bundle provenance source",
|
||||
@@ -489,7 +475,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
|
||||
("resource handle resource id", handle.resource_id.as_str()),
|
||||
("resource handle digest", handle.digest.as_str()),
|
||||
("resource handle nonce", handle.nonce.as_str()),
|
||||
("resource handle revision", handle.revision.as_str()),
|
||||
("resource handle content type", handle.content_type.as_str()),
|
||||
(
|
||||
"resource handle audit correlation id",
|
||||
@@ -766,7 +751,6 @@ mod tests {
|
||||
metadata: ConfigBundleMetadata {
|
||||
id: "bundle-1".to_string(),
|
||||
digest: String::new(),
|
||||
revision: "rev-1".to_string(),
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
created_at: "2026-06-26T00:00:00Z".to_string(),
|
||||
provenance: ConfigBundleProvenance {
|
||||
@@ -823,7 +807,6 @@ mod tests {
|
||||
bundle.prompt_catalog = Some(
|
||||
worker::EffectivePromptCatalog::new(
|
||||
std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]),
|
||||
7,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -866,8 +849,6 @@ mod tests {
|
||||
operation: crate::resource::BackendResourceOperation::FetchArchive,
|
||||
expires_at_unix_seconds: 4_102_444_800,
|
||||
nonce: "nonce-not-for-browser".to_string(),
|
||||
revision: "rev-1".to_string(),
|
||||
generation: Some(1),
|
||||
max_bytes: 128,
|
||||
content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
|
||||
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
|
||||
@@ -693,7 +693,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
}
|
||||
|
||||
/// Observe a newer immutable Workspace Prompt projection. Profile-backed
|
||||
/// execution uses this as a revision notification; other backends may
|
||||
/// execution uses this as a content projection notification; other backends may
|
||||
/// safely ignore it.
|
||||
fn observe_workspace_prompt_projection(
|
||||
&self,
|
||||
|
||||
@@ -21,7 +21,9 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
|
||||
const SCHEMA_VERSION: u32 = 9;
|
||||
const SCHEMA_VERSION: u32 = 10;
|
||||
// Frozen source schemas: do not move their meaning when adding a migration.
|
||||
const AGGREGATE_SCHEMA_VERSION: u32 = 9;
|
||||
const PREVIOUS_SCHEMA_VERSION: u32 = 8;
|
||||
const LEGACY_SCHEMA_VERSION: u32 = 7;
|
||||
const OLDEST_SCHEMA_VERSION: u32 = 6;
|
||||
@@ -609,8 +611,8 @@ fn plan_runtime_store_migration(
|
||||
format!("Runtime store schema version {schema_version} is out of range"),
|
||||
)
|
||||
})?;
|
||||
let staging = migration_sibling(root, "schema-v9-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
|
||||
let staging = migration_sibling(root, "schema-v10-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
|
||||
if staging.exists() || backup.exists() {
|
||||
return Err(runtime_store_corrupt(
|
||||
root,
|
||||
@@ -639,12 +641,15 @@ fn plan_runtime_store_migration(
|
||||
}
|
||||
if !matches!(
|
||||
current_schema_version,
|
||||
OLDEST_SCHEMA_VERSION | LEGACY_SCHEMA_VERSION | PREVIOUS_SCHEMA_VERSION
|
||||
OLDEST_SCHEMA_VERSION
|
||||
| LEGACY_SCHEMA_VERSION
|
||||
| PREVIOUS_SCHEMA_VERSION
|
||||
| AGGREGATE_SCHEMA_VERSION
|
||||
) {
|
||||
return Err(runtime_store_corrupt(
|
||||
&runtime_path,
|
||||
format!(
|
||||
"unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
|
||||
"unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, {AGGREGATE_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -969,11 +974,30 @@ fn validate_migrated_worker_documents(
|
||||
}
|
||||
|
||||
fn migrate_worker_record(
|
||||
identity_document: serde_json::Value,
|
||||
mut identity_document: serde_json::Value,
|
||||
source_schema_version: u32,
|
||||
mapping: Option<&LegacyWorkerIdentityMapping>,
|
||||
identity_path: &Path,
|
||||
) -> Result<WorkerAggregateRecord, RuntimeError> {
|
||||
if source_schema_version == AGGREGATE_SCHEMA_VERSION {
|
||||
migrate_v9_restore_guard(&mut identity_document, identity_path)?;
|
||||
remove_legacy_job_input_counter(&mut identity_document);
|
||||
migrate_legacy_request_evidence(&mut identity_document)
|
||||
.map_err(|message| runtime_store_corrupt(identity_path, message.into()))?;
|
||||
identity_document["schema_version"] = serde_json::Value::from(SCHEMA_VERSION);
|
||||
let aggregate: WorkerAggregateRecord =
|
||||
serde_json::from_value(identity_document).map_err(|error| {
|
||||
runtime_store_corrupt(
|
||||
identity_path,
|
||||
format!("decode migrated schema-v9 Worker aggregate: {error}"),
|
||||
)
|
||||
})?;
|
||||
aggregate.clone().validate(identity_path)?;
|
||||
return Ok(aggregate);
|
||||
}
|
||||
remove_legacy_job_input_counter(&mut identity_document);
|
||||
migrate_legacy_request_evidence(&mut identity_document)
|
||||
.map_err(|message| runtime_store_corrupt(identity_path, message.into()))?;
|
||||
if source_schema_version == PREVIOUS_SCHEMA_VERSION {
|
||||
let identity: WorkerIdentityRecord =
|
||||
serde_json::from_value(identity_document).map_err(|error| {
|
||||
@@ -984,8 +1008,18 @@ fn migrate_worker_record(
|
||||
})?;
|
||||
identity.validate_for_schema(identity_path, PREVIOUS_SCHEMA_VERSION)?;
|
||||
let execution_path = identity_path.with_file_name(WORKER_EXECUTION_FILE);
|
||||
let execution: WorkerExecutionRecord =
|
||||
let mut execution_document: serde_json::Value =
|
||||
read_bounded_json(&execution_path, "read schema-v8 Worker execution record")?;
|
||||
remove_legacy_job_input_counter(&mut execution_document);
|
||||
migrate_legacy_request_evidence(&mut execution_document)
|
||||
.map_err(|message| runtime_store_corrupt(&execution_path, message.into()))?;
|
||||
let execution: WorkerExecutionRecord =
|
||||
serde_json::from_value(execution_document).map_err(|error| {
|
||||
runtime_store_corrupt(
|
||||
&execution_path,
|
||||
format!("decode migrated schema-v8 execution: {error}"),
|
||||
)
|
||||
})?;
|
||||
execution.validate_for_schema(&identity, &execution_path, PREVIOUS_SCHEMA_VERSION)?;
|
||||
return Ok(WorkerAggregateRecord::from_v8(identity, execution));
|
||||
}
|
||||
@@ -1016,6 +1050,114 @@ fn migrate_worker_record(
|
||||
Ok(WorkerAggregateRecord::from_v8(identity, execution))
|
||||
}
|
||||
|
||||
// Frozen schema-v9 conversion. Preserve the exact token and all Restore owners
|
||||
// and receipts; generating a fresh token here would lose retry/admission authority.
|
||||
fn migrate_v9_restore_guard(
|
||||
document: &mut serde_json::Value,
|
||||
path: &Path,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let Some(guard) = document.get_mut("restore_guard") else {
|
||||
return Ok(());
|
||||
};
|
||||
let guard = guard.as_object_mut().ok_or_else(|| {
|
||||
runtime_store_corrupt(path, "schema-v9 restore_guard must be an object".into())
|
||||
})?;
|
||||
let token = guard.remove("generation").ok_or_else(|| {
|
||||
runtime_store_corrupt(path, "schema-v9 restore_guard is missing generation".into())
|
||||
})?;
|
||||
if !token.as_str().is_some_and(|token| !token.is_empty())
|
||||
|| guard.contains_key("observation_token")
|
||||
{
|
||||
return Err(runtime_store_corrupt(
|
||||
path,
|
||||
"schema-v9 Restore observation token is invalid or ambiguous".into(),
|
||||
));
|
||||
}
|
||||
guard.insert("observation_token".into(), token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Legacy Runtime Job bindings never held input JSON, so the old caller-selected
|
||||
// counter cannot become a content digest. Retain Job/attempt identity and leave
|
||||
// content identity unknown until the host supplies its immutable intent.
|
||||
fn remove_legacy_job_input_counter(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
if let Some(job) = object
|
||||
.get_mut("backend_job")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
job.remove("input_revision");
|
||||
}
|
||||
for value in object.values_mut() {
|
||||
remove_legacy_job_input_counter(value);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for value in values {
|
||||
remove_legacy_job_input_counter(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Frozen schema 6..9 request migration. Preserve concrete source/config digests,
|
||||
// contents, operation ownership and fingerprints. Unknown legacy SSH key identity
|
||||
// stays empty and cannot pass the materializer's live-authority validation.
|
||||
fn migrate_legacy_request_evidence(value: &mut serde_json::Value) -> Result<(), &'static str> {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
if object.contains_key("credential_id") && object.contains_key("credential_revision") {
|
||||
let counter = object.remove("credential_revision").expect("checked field");
|
||||
if !counter.as_u64().is_some_and(|value| value > 0) {
|
||||
return Err("invalid legacy SSH credential evidence");
|
||||
}
|
||||
object
|
||||
.entry("public_key_fingerprint")
|
||||
.or_insert_with(|| serde_json::json!(""));
|
||||
}
|
||||
if object.contains_key("host_trust_id") && object.contains_key("host_trust_revision") {
|
||||
let counter = object.remove("host_trust_revision").expect("checked field");
|
||||
if !counter.as_u64().is_some_and(|value| value > 0) {
|
||||
return Err("invalid legacy SSH host trust evidence");
|
||||
}
|
||||
object
|
||||
.entry("host_key_fingerprint")
|
||||
.or_insert_with(|| serde_json::json!(""));
|
||||
}
|
||||
if let Some(settings) = object
|
||||
.get_mut("memory_settings")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
&& let Some(counter) = settings.remove("settings_revision")
|
||||
&& !counter.as_u64().is_some_and(|value| value > 0)
|
||||
{
|
||||
return Err("invalid legacy Memory settings evidence");
|
||||
}
|
||||
if object.contains_key("source_fingerprint") {
|
||||
object.remove("source_revision");
|
||||
}
|
||||
if object.contains_key("config_projection_digest") {
|
||||
object.remove("config_revision");
|
||||
}
|
||||
if object.contains_key("resource_id") && object.contains_key("nonce") {
|
||||
object.remove("revision");
|
||||
object.remove("generation");
|
||||
}
|
||||
for value in object.values_mut() {
|
||||
migrate_legacy_request_evidence(value)?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for value in values {
|
||||
migrate_legacy_request_evidence(value)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn runtime_worker_name(worker_id: WorkerId) -> String {
|
||||
format!("worker-runtime-{worker_id}")
|
||||
}
|
||||
@@ -1216,6 +1358,9 @@ fn migrate_runtime_document(
|
||||
serde_json::Value::from(SCHEMA_VERSION),
|
||||
);
|
||||
object.remove("workers");
|
||||
// This legacy cache is not Runtime lifecycle or restore authority. Bundles
|
||||
// are reacquired from Workspace content authority, never renumbered/rehash-pinned here.
|
||||
object.remove("config_bundles");
|
||||
object.remove("next_worker_sequence");
|
||||
let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
|
||||
runtime_store_corrupt(
|
||||
@@ -1325,8 +1470,14 @@ fn remove_runtime_migration_directory(path: &Path) -> Result<(), RuntimeError> {
|
||||
}
|
||||
|
||||
fn recover_runtime_store_migration(root: &Path) -> Result<(), RuntimeError> {
|
||||
let staging = migration_sibling(root, "schema-v9-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
|
||||
for suffix in ["schema-v9-staging", "pre-schema-v9-backup"] {
|
||||
let earlier = migration_sibling(root, suffix)?;
|
||||
if earlier.exists() {
|
||||
return Err(runtime_store_corrupt(root, "unfinished schema-v9 migration requires explicit recovery before schema-v10 startup".into()));
|
||||
}
|
||||
}
|
||||
let staging = migration_sibling(root, "schema-v10-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
|
||||
let root_exists = migration_directory_exists(root)?;
|
||||
let staging_exists = migration_directory_exists(&staging)?;
|
||||
let backup_exists = migration_directory_exists(&backup)?;
|
||||
@@ -1528,8 +1679,8 @@ fn migrate_runtime_store(
|
||||
if !plan.migration_required {
|
||||
return Ok(plan);
|
||||
}
|
||||
let staging = migration_sibling(root, "schema-v9-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
|
||||
let staging = migration_sibling(root, "schema-v10-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
|
||||
if staging.exists() || backup.exists() {
|
||||
return Err(runtime_store_corrupt(
|
||||
root,
|
||||
@@ -2875,7 +3026,7 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
write_empty_schema_v8_store(&root);
|
||||
let staging = migration_sibling(&root, "schema-v9-staging").unwrap();
|
||||
let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
|
||||
fs::create_dir_all(&staging).unwrap();
|
||||
fs::write(staging.join("partial"), b"partial").unwrap();
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
@@ -2890,7 +3041,7 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
write_empty_schema_v8_store(&root);
|
||||
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
|
||||
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
|
||||
fs::rename(&root, &backup).unwrap();
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
|
||||
@@ -2904,10 +3055,10 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
write_empty_schema_v8_store(&root);
|
||||
let staging = migration_sibling(&root, "schema-v9-staging").unwrap();
|
||||
let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
|
||||
copy_runtime_tree(&root, &root, &staging).unwrap();
|
||||
migrate_runtime_store_in_place(&staging, "test-runtime").unwrap();
|
||||
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
|
||||
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
|
||||
fs::rename(&root, &backup).unwrap();
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
|
||||
@@ -2922,10 +3073,10 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
write_empty_schema_v8_store(&root);
|
||||
let staging = migration_sibling(&root, "schema-v9-staging").unwrap();
|
||||
let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
|
||||
fs::create_dir_all(&staging).unwrap();
|
||||
fs::write(staging.join(RUNTIME_FILE), b"{\"schema_version\":8}").unwrap();
|
||||
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
|
||||
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
|
||||
fs::rename(&root, &backup).unwrap();
|
||||
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
@@ -2942,7 +3093,7 @@ mod tests {
|
||||
let root = temp.path().join("runtime-store");
|
||||
write_empty_schema_v8_store(&root);
|
||||
migrate_runtime_store_in_place(&root, "test-runtime").unwrap();
|
||||
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
|
||||
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
|
||||
write_empty_schema_v8_store(&backup);
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
|
||||
@@ -2950,6 +3101,120 @@ mod tests {
|
||||
assert!(!backup.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_migration_backup_cannot_be_mistaken_for_a_fresh_runtime() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
|
||||
write_empty_schema_v8_store(&backup);
|
||||
let error = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap_err();
|
||||
assert!(error.to_string().contains("explicit recovery"));
|
||||
assert!(!root.exists());
|
||||
assert!(backup.join(RUNTIME_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_request_migration_preserves_content_and_never_invents_key_identity() {
|
||||
let mut document = serde_json::json!({"request": {
|
||||
"memory_settings": {"workspace_id":"workspace-a", "language":"English", "settings_revision":2},
|
||||
"repository": {"source_revision":1, "source_fingerprint":"source-content"},
|
||||
"materialization": {"operation_id":"op-a", "config_revision":2, "config_projection_digest":"projection-content", "ssh": {
|
||||
"host_trust_id":"host-a", "host_trust_revision":3,
|
||||
"credential_candidates":[{"credential_id":"credential-a", "credential_revision":4}]
|
||||
}}
|
||||
}});
|
||||
migrate_legacy_request_evidence(&mut document).unwrap();
|
||||
let request = &document["request"];
|
||||
assert_eq!(request["memory_settings"]["language"], "English");
|
||||
assert!(
|
||||
request["memory_settings"]
|
||||
.get("settings_revision")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
request["repository"]["source_fingerprint"],
|
||||
"source-content"
|
||||
);
|
||||
assert_eq!(
|
||||
request["materialization"]["config_projection_digest"],
|
||||
"projection-content"
|
||||
);
|
||||
assert_eq!(
|
||||
request["materialization"]["ssh"]["host_key_fingerprint"],
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
request["materialization"]["ssh"]["credential_candidates"][0]["public_key_fingerprint"],
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v9_restore_token_migrates_durably_without_rotating_admission_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime-store");
|
||||
write_empty_schema_v8_store(&root);
|
||||
let runtime_path = root.join(RUNTIME_FILE);
|
||||
let mut runtime: serde_json::Value = read_json(&runtime_path, "read test Runtime").unwrap();
|
||||
runtime["schema_version"] = serde_json::json!(AGGREGATE_SCHEMA_VERSION);
|
||||
fs::write(&runtime_path, serde_json::to_vec(&runtime).unwrap()).unwrap();
|
||||
let worker_id = WorkerId::now_v7();
|
||||
let path = root
|
||||
.join(WORKERS_DIR)
|
||||
.join(worker_id.to_string())
|
||||
.join(WORKER_FILE);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let aggregate = migrate_worker_record(
|
||||
schema_v7_worker_document(worker_id),
|
||||
LEGACY_SCHEMA_VERSION,
|
||||
None,
|
||||
&path,
|
||||
)
|
||||
.unwrap();
|
||||
let mut legacy = serde_json::to_value(&aggregate).unwrap();
|
||||
let token = legacy["restore_guard"]["observation_token"].take();
|
||||
legacy["restore_guard"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("observation_token");
|
||||
legacy["restore_guard"]["generation"] = token.clone();
|
||||
legacy["schema_version"] = serde_json::json!(AGGREGATE_SCHEMA_VERSION);
|
||||
fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
|
||||
store.store.load_runtime_state().unwrap();
|
||||
drop(store);
|
||||
let migrated: serde_json::Value = read_json(&path, "read migrated Worker").unwrap();
|
||||
assert_eq!(migrated["schema_version"], SCHEMA_VERSION);
|
||||
assert_eq!(migrated["restore_guard"]["observation_token"], token);
|
||||
assert!(migrated["restore_guard"].get("generation").is_none());
|
||||
let store = FsRuntimeStore::open_or_create(root, "test-runtime").unwrap();
|
||||
store.store.load_runtime_state().unwrap();
|
||||
assert_eq!(
|
||||
read_json::<serde_json::Value>(&path, "read restarted Worker").unwrap(),
|
||||
migrated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_restore_guard_conversion_preserves_receipts_and_rejects_ambiguous_identity() {
|
||||
let path = Path::new("worker.json");
|
||||
let mut document = serde_json::json!({"restore_guard": {
|
||||
"generation": "opaque-admission-token", "active_request_id": "restore-a",
|
||||
"owners": {"restore-a": {"fingerprint": "intent", "receipt": {"accepted": true}}}
|
||||
}});
|
||||
let owners = document["restore_guard"]["owners"].clone();
|
||||
migrate_v9_restore_guard(&mut document, path).unwrap();
|
||||
assert_eq!(document["restore_guard"]["owners"], owners);
|
||||
assert_eq!(document["restore_guard"]["active_request_id"], "restore-a");
|
||||
assert_eq!(
|
||||
document["restore_guard"]["observation_token"],
|
||||
"opaque-admission-token"
|
||||
);
|
||||
let mut ambiguous =
|
||||
serde_json::json!({"restore_guard": {"generation": "old", "observation_token": "new"}});
|
||||
assert!(migrate_v9_restore_guard(&mut ambiguous, path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_worker_record_read_rejects_oversize_before_parse() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -3047,7 +3312,7 @@ mod tests {
|
||||
));
|
||||
assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists());
|
||||
let aggregate: WorkerAggregateRecord =
|
||||
read_bounded_json(&identity_path, "read schema-v9 aggregate").unwrap();
|
||||
read_bounded_json(&identity_path, "read schema-v10 aggregate").unwrap();
|
||||
assert_eq!(aggregate.schema_version, SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
|
||||
@@ -413,7 +413,8 @@ pub struct RuntimeHttpWorkspacePromptProjectionRequest {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpWorkspacePromptProjectionResponse {
|
||||
pub workspace_id: String,
|
||||
pub config_revision: u64,
|
||||
pub source_digest: String,
|
||||
pub projection_digest: String,
|
||||
}
|
||||
|
||||
/// Config bundle availability response used by sync/check endpoints.
|
||||
@@ -576,10 +577,10 @@ async fn post_workspace_verification_challenge(
|
||||
})?;
|
||||
if challenge.workspace_id != verified.workspace_id
|
||||
|| challenge.runtime_id != verified.runtime_id
|
||||
|| challenge.binding_revision != verified.binding_revision
|
||||
|| challenge.binding_id != verified.binding_id
|
||||
|| challenge.workspace_key_id != verified.issuer_key_id
|
||||
|| challenge.workspace_identity_revision != verified.issuer_identity_revision
|
||||
|| challenge.workspace_trust_generation != verified.trust_generation
|
||||
|| challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint
|
||||
|| challenge.workspace_trust_id != verified.trust_id
|
||||
|| challenge.runtime_id != auth.signer.runtime_id()
|
||||
|| challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|
||||
{
|
||||
@@ -599,6 +600,13 @@ async fn post_workspace_verification_challenge(
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
auth.verifications.begin(challenge).map_err(|error| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
@@ -616,17 +624,17 @@ async fn post_workspace_verification_acknowledgement(
|
||||
})?;
|
||||
if acknowledgement.workspace_id != verified.workspace_id
|
||||
|| acknowledgement.runtime_id != verified.runtime_id
|
||||
|| acknowledgement.binding_revision != verified.binding_revision
|
||||
|| acknowledgement.binding_id != verified.binding_id
|
||||
|| acknowledgement.workspace_key_id != verified.issuer_key_id
|
||||
|| acknowledgement.workspace_identity_revision != verified.issuer_identity_revision
|
||||
|| acknowledgement.workspace_trust_generation != verified.trust_generation
|
||||
|| acknowledgement.workspace_public_key_fingerprint
|
||||
!= verified.issuer_public_key_fingerprint
|
||||
|| acknowledgement.workspace_trust_id != verified.trust_id
|
||||
|| acknowledgement.runtime_id != auth.signer.runtime_id()
|
||||
|| acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|
||||
|| acknowledgement.expires_at <= unix_now_i64()
|
||||
|| acknowledgement.binding_revision == 0
|
||||
|| acknowledgement.runtime_identity_revision == 0
|
||||
|| acknowledgement.workspace_identity_revision == 0
|
||||
|| acknowledgement.workspace_trust_generation == 0
|
||||
|| acknowledgement.binding_id.is_empty()
|
||||
|| acknowledgement.workspace_public_key_fingerprint.is_empty()
|
||||
|| acknowledgement.workspace_trust_id.is_empty()
|
||||
|| acknowledgement.workspace_nonce.is_empty()
|
||||
|| acknowledgement.runtime_nonce.is_empty()
|
||||
|| acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len()
|
||||
@@ -641,12 +649,11 @@ async fn post_workspace_verification_acknowledgement(
|
||||
challenge_id: acknowledgement.challenge_id.clone(),
|
||||
workspace_id: acknowledgement.workspace_id.clone(),
|
||||
runtime_id: acknowledgement.runtime_id.clone(),
|
||||
binding_revision: acknowledgement.binding_revision,
|
||||
binding_id: acknowledgement.binding_id.clone(),
|
||||
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
||||
workspace_identity_revision: acknowledgement.workspace_identity_revision,
|
||||
workspace_trust_generation: acknowledgement.workspace_trust_generation,
|
||||
workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(),
|
||||
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
|
||||
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
|
||||
runtime_identity_revision: acknowledgement.runtime_identity_revision,
|
||||
workspace_nonce: acknowledgement.workspace_nonce.clone(),
|
||||
expires_at: acknowledgement.expires_at,
|
||||
};
|
||||
@@ -689,17 +696,23 @@ async fn post_workspace_verification_acknowledgement(
|
||||
)
|
||||
})?;
|
||||
auth.verifications
|
||||
.record(WorkspaceRuntimeVerificationRecord {
|
||||
.acknowledge(
|
||||
&acknowledgement.challenge_id,
|
||||
WorkspaceRuntimeVerificationRecord {
|
||||
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,
|
||||
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
|
||||
runtime_identity_revision: acknowledgement.runtime_identity_revision,
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2108,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,
|
||||
@@ -2145,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,
|
||||
@@ -2782,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,
|
||||
@@ -2805,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,
|
||||
};
|
||||
@@ -2819,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(),
|
||||
@@ -2870,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),
|
||||
@@ -2974,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,
|
||||
};
|
||||
@@ -3018,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() {
|
||||
@@ -3107,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 {
|
||||
@@ -3164,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,
|
||||
@@ -4477,8 +4622,7 @@ mod tests {
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: workspace_identity.public_key,
|
||||
public_key_fingerprint: workspace_fingerprint,
|
||||
identity_revision: 1,
|
||||
trust_generation: 1,
|
||||
trust_id: "trust-cleanup-test".to_string(),
|
||||
state: WorkspaceIssuerTrustState::Active,
|
||||
registered_at_unix: 1,
|
||||
updated_at_unix: 1,
|
||||
@@ -5041,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 {
|
||||
@@ -5097,7 +5240,6 @@ mod ws_tests {
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
|
||||
@@ -905,12 +905,11 @@ fn print_workspace_trust_mutation(
|
||||
WorkspaceIssuerTrustMutation::Unchanged => "unchanged",
|
||||
};
|
||||
println!(
|
||||
"Workspace issuer trust {action}: workspace={} key={} fingerprint={} identity_revision={} trust_generation={} state={:?}",
|
||||
"Workspace issuer trust {action}: workspace={} key={} fingerprint={} trust_id={} state={:?}",
|
||||
record.workspace_id,
|
||||
record.key_id,
|
||||
record.public_key_fingerprint,
|
||||
record.identity_revision,
|
||||
record.trust_generation,
|
||||
record.trust_id,
|
||||
record.state,
|
||||
);
|
||||
}
|
||||
@@ -936,10 +935,38 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
|
||||
}
|
||||
let contents = String::from_utf8(contents)
|
||||
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
|
||||
let auth: RuntimeAuthFile = toml::from_str(&contents)
|
||||
let mut document: toml::Value = toml::from_str(&contents)
|
||||
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
|
||||
let mut migrated = false;
|
||||
if let Some(records) = document
|
||||
.get_mut("workspace_issuers")
|
||||
.and_then(toml::Value::as_array_mut)
|
||||
{
|
||||
for value in records {
|
||||
if value.get("identity_revision").is_some() || value.get("trust_generation").is_some() {
|
||||
let legacy = serde_json::to_value(&*value)
|
||||
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
|
||||
let record =
|
||||
worker_runtime::workspace_issuer::migrate_legacy_workspace_issuer_trust_record(
|
||||
legacy,
|
||||
)
|
||||
.map_err(|_| ProcessError::auth("runtime legacy trust store is corrupt"))?;
|
||||
*value = toml::Value::try_from(record).map_err(|_| {
|
||||
ProcessError::auth("runtime auth migration serialization failed")
|
||||
})?;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let auth: RuntimeAuthFile = document
|
||||
.try_into()
|
||||
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
|
||||
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
|
||||
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
|
||||
if migrated {
|
||||
// Commit the revoked migration before exposing any issuer authority.
|
||||
write_runtime_auth_file(path, &auth)?;
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
@@ -1398,7 +1425,6 @@ mod tests {
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: identity.public_key,
|
||||
public_key_fingerprint: fingerprint,
|
||||
revision: 1,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -1419,7 +1445,38 @@ mod tests {
|
||||
let path = runtime_auth_path(&config);
|
||||
let first = read_runtime_auth_file(&path).unwrap();
|
||||
assert_eq!(first.workspace_issuers.len(), 1);
|
||||
assert_eq!(first.workspace_issuers[0].trust_generation, 1);
|
||||
assert!(!first.workspace_issuers[0].trust_id.is_empty());
|
||||
// `trust-workspace show` serializes this persisted record. Owner
|
||||
// registration must forward that exact enrollment ID, not derive one
|
||||
// from the key fingerprint or an initial counter.
|
||||
let shown = serde_json::to_value(&first.workspace_issuers[0]).unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("remote-runtime").unwrap();
|
||||
let mut registration_wire = serde_json::json!({
|
||||
"public_bundle": {
|
||||
"identity_id": runtime_identity.identity_id,
|
||||
"public_key": runtime_identity.public_key,
|
||||
},
|
||||
"endpoint": "https://runtime.example.test",
|
||||
"workspace_trust_id": shown["trust_id"],
|
||||
});
|
||||
let registration: server_api::CreateRemoteRuntimeRequest =
|
||||
serde_json::from_value(registration_wire.clone()).unwrap();
|
||||
assert_eq!(
|
||||
registration.workspace_trust_id,
|
||||
first.workspace_issuers[0].trust_id
|
||||
);
|
||||
assert_ne!(
|
||||
registration.workspace_trust_id,
|
||||
first.workspace_issuers[0].public_key_fingerprint
|
||||
);
|
||||
registration_wire
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("workspace_trust_id");
|
||||
assert!(
|
||||
serde_json::from_value::<server_api::CreateRemoteRuntimeRequest>(registration_wire)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
run_trust_workspace_command(VecDeque::from([
|
||||
"add".to_string(),
|
||||
@@ -1430,7 +1487,10 @@ mod tests {
|
||||
]))
|
||||
.unwrap();
|
||||
let replay = read_runtime_auth_file(&path).unwrap();
|
||||
assert_eq!(replay.workspace_issuers[0].trust_generation, 1);
|
||||
assert_eq!(
|
||||
replay.workspace_issuers[0].trust_id,
|
||||
first.workspace_issuers[0].trust_id
|
||||
);
|
||||
|
||||
let replacement = RuntimeIdentityMaterial::generate("WK-2").unwrap();
|
||||
let public_key = decode_public_key(&replacement.public_key).unwrap();
|
||||
@@ -1451,7 +1511,6 @@ mod tests {
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: replacement.public_key,
|
||||
public_key_fingerprint: fingerprint,
|
||||
revision: 2,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -1483,7 +1542,10 @@ mod tests {
|
||||
]))
|
||||
.unwrap();
|
||||
let revoked = read_runtime_auth_file(&path).unwrap();
|
||||
assert_eq!(revoked.workspace_issuers[0].trust_generation, 3);
|
||||
assert_ne!(
|
||||
revoked.workspace_issuers[0].trust_id,
|
||||
first.workspace_issuers[0].trust_id
|
||||
);
|
||||
assert_eq!(
|
||||
revoked.workspace_issuers[0].state,
|
||||
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
|
||||
|
||||
@@ -17,7 +17,7 @@ pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RepositorySshAccessSecretCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub public_key_fingerprint: String,
|
||||
pub private_key: String,
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
|
||||
formatter
|
||||
.debug_struct("RepositorySshAccessSecretCandidate")
|
||||
.field("credential_id", &self.credential_id)
|
||||
.field("credential_revision", &self.credential_revision)
|
||||
.field("public_key_fingerprint", &self.public_key_fingerprint)
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
@@ -89,9 +89,6 @@ pub struct BackendResourceHandle {
|
||||
pub operation: BackendResourceOperation,
|
||||
pub expires_at_unix_seconds: i64,
|
||||
pub nonce: String,
|
||||
pub revision: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation: Option<u64>,
|
||||
pub max_bytes: u64,
|
||||
pub content_type: String,
|
||||
pub redaction: ResourceRedactionPolicy,
|
||||
@@ -425,8 +422,6 @@ mod tests {
|
||||
operation: BackendResourceOperation::FetchArchive,
|
||||
expires_at_unix_seconds: 4_102_444_800,
|
||||
nonce: "nonce-test".to_string(),
|
||||
revision: sha256_hex(bytes),
|
||||
generation: Some(1),
|
||||
max_bytes: 1024,
|
||||
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
|
||||
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
@@ -477,7 +472,7 @@ mod tests {
|
||||
let secret = RepositorySshAccessSecret {
|
||||
credential_candidates: vec![RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 2,
|
||||
public_key_fingerprint: "key-2".into(),
|
||||
private_key: "PRIVATE KEY secret bytes".to_string(),
|
||||
}],
|
||||
known_hosts_entry: "host key secret bytes".to_string(),
|
||||
|
||||
@@ -116,13 +116,11 @@ pub struct WorkerRetentionExecutionRequest {
|
||||
pub workspace_id: String,
|
||||
pub source_runtime_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub expected_worker_revision: String,
|
||||
pub source_created_at: String,
|
||||
pub removed_at: String,
|
||||
pub effective_profile: Option<String>,
|
||||
pub retention_class: Option<String>,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||
}
|
||||
@@ -145,7 +143,6 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub content_bytes: u64,
|
||||
pub content_file_count: u64,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
}
|
||||
@@ -154,7 +151,6 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub struct WorkerRetentionExecutionResult {
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
pub expected_worker_revision: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub session_disposition: SessionDisposition,
|
||||
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||
@@ -524,7 +520,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
||||
let mut result = WorkerRetentionExecutionResult {
|
||||
operation_id: request.operation_id.clone(),
|
||||
input_fingerprint: request.input_fingerprint.clone(),
|
||||
expected_worker_revision: request.expected_worker_revision.clone(),
|
||||
worker_id: request.worker_id,
|
||||
session_disposition: request.session_disposition,
|
||||
diagnostics_disposition: request.diagnostics_disposition,
|
||||
@@ -749,7 +744,6 @@ fn commit_session_archive(
|
||||
content_bytes: bytes,
|
||||
content_file_count: count,
|
||||
policy_id: request.policy_id.clone(),
|
||||
policy_revision: request.policy_revision,
|
||||
operation_id: request.operation_id.clone(),
|
||||
input_fingerprint: request.input_fingerprint.clone(),
|
||||
};
|
||||
@@ -1290,7 +1284,6 @@ mod tests {
|
||||
WorkerRetentionExecutionRequest {
|
||||
operation_id: "operation-a".to_string(),
|
||||
input_fingerprint: "fingerprint-a".to_string(),
|
||||
expected_worker_revision: "revision-a".to_string(),
|
||||
archive_id: (disposition == SessionDisposition::Archive)
|
||||
.then(|| "archive-a".to_string()),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
@@ -1301,7 +1294,6 @@ mod tests {
|
||||
effective_profile: Some("builtin:coder".to_string()),
|
||||
retention_class: None,
|
||||
policy_id: "policy-a".to_string(),
|
||||
policy_revision: 3,
|
||||
session_disposition: disposition,
|
||||
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
||||
}
|
||||
|
||||
@@ -457,7 +457,7 @@ impl Runtime {
|
||||
state
|
||||
};
|
||||
state.execution_backend = execution_backend;
|
||||
// Initialize legacy generations durably before exposing an observation.
|
||||
// Initialize legacy observation tokens durably before exposing an observation.
|
||||
state.persist_workers()?;
|
||||
let runtime = Self {
|
||||
inner: Arc::new(Mutex::new(state)),
|
||||
@@ -761,7 +761,7 @@ impl Runtime {
|
||||
.zip(&ssh.credential_candidates)
|
||||
.any(|(secret, metadata)| {
|
||||
secret.credential_id != metadata.credential_id
|
||||
|| secret.credential_revision != metadata.credential_revision
|
||||
|| secret.public_key_fingerprint != metadata.public_key_fingerprint
|
||||
})
|
||||
{
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
@@ -2157,7 +2157,8 @@ impl Runtime {
|
||||
&& candidate.pending_stop.is_none()
|
||||
&& candidate.restore_guard.active_request_id.is_none();
|
||||
if coordinate && request.preparation.is_none() && !recovering_pending {
|
||||
if candidate.restore_guard.generation != request.expected_observation_token
|
||||
if candidate.restore_guard.observation_token
|
||||
!= request.expected_observation_token
|
||||
|| candidate.restore_guard.active_request_id.is_some()
|
||||
|| candidate.has_pending_lifecycle_operation()
|
||||
{
|
||||
@@ -2176,7 +2177,7 @@ impl Runtime {
|
||||
));
|
||||
}
|
||||
state.ensure_running()?;
|
||||
if candidate.restore_guard.generation != request.expected_observation_token
|
||||
if candidate.restore_guard.observation_token != request.expected_observation_token
|
||||
|| candidate.restore_guard.active_request_id.is_some()
|
||||
|| (candidate.has_pending_lifecycle_operation() && !recovering_pending)
|
||||
{
|
||||
@@ -2283,7 +2284,7 @@ impl Runtime {
|
||||
return worker.restore_guard.owners[id].request.clone();
|
||||
}
|
||||
runtime_api::WorkerRestoreRequest {
|
||||
expected_observation_token: worker.restore_guard.generation.clone(),
|
||||
expected_observation_token: worker.restore_guard.observation_token.clone(),
|
||||
request_id: uuid::Uuid::now_v7().to_string(),
|
||||
preparation: worker
|
||||
.workspace_id
|
||||
@@ -5531,7 +5532,7 @@ impl RuntimeState {
|
||||
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => Some(name.clone()),
|
||||
};
|
||||
Ok(SubscriptionWorker {
|
||||
restore_observation_token: Some(worker.restore_guard.generation.clone()),
|
||||
restore_observation_token: Some(worker.restore_guard.observation_token.clone()),
|
||||
worker_id,
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
@@ -5806,7 +5807,6 @@ impl RuntimeState {
|
||||
InternalWorkerActivity {
|
||||
status: Some(snapshot.status),
|
||||
parent_session_id: snapshot.worker.parent_session_id.clone(),
|
||||
revision: snapshot.revision,
|
||||
},
|
||||
);
|
||||
for child in &snapshot.internal_workers {
|
||||
@@ -5854,42 +5854,22 @@ impl RuntimeState {
|
||||
fn remove_internal_worker_subtree(
|
||||
activity: &mut InternalWorkerActivityProjection,
|
||||
worker: &protocol::InternalWorkerRef,
|
||||
revision: u64,
|
||||
) {
|
||||
let known_revision = activity
|
||||
.workers
|
||||
.get(&worker.session_id)
|
||||
.map(|worker| worker.revision)
|
||||
.into_iter()
|
||||
.chain(
|
||||
activity
|
||||
.removed_workers
|
||||
.get(&worker.session_id)
|
||||
.map(|worker| worker.revision),
|
||||
)
|
||||
.max();
|
||||
if known_revision.is_some_and(|known_revision| revision <= known_revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
for session_id in Self::internal_worker_subtree_ids(activity, &worker.session_id) {
|
||||
let active = activity.workers.remove(&session_id);
|
||||
let removed = activity.removed_workers.remove(&session_id);
|
||||
let (worker_revision, parent_session_id) = if session_id == worker.session_id {
|
||||
(revision, worker.parent_session_id.clone())
|
||||
let parent_session_id = if session_id == worker.session_id {
|
||||
worker.parent_session_id.clone()
|
||||
} else if let Some(active) = active {
|
||||
(active.revision, active.parent_session_id)
|
||||
active.parent_session_id
|
||||
} else if let Some(removed) = removed {
|
||||
(removed.revision, removed.parent_session_id)
|
||||
removed.parent_session_id
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
activity.removed_workers.insert(
|
||||
session_id,
|
||||
RemovedInternalWorkerActivity {
|
||||
revision: worker_revision,
|
||||
parent_session_id,
|
||||
},
|
||||
RemovedInternalWorkerActivity { parent_session_id },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5897,37 +5877,25 @@ impl RuntimeState {
|
||||
fn touch_internal_worker_activity(
|
||||
activity: &mut InternalWorkerActivityProjection,
|
||||
worker: &protocol::InternalWorkerRef,
|
||||
revision: u64,
|
||||
) {
|
||||
activity
|
||||
.workers
|
||||
.entry(worker.session_id.clone())
|
||||
.and_modify(|activity| {
|
||||
activity.parent_session_id = worker.parent_session_id.clone();
|
||||
activity.revision = revision;
|
||||
})
|
||||
.and_modify(|activity| activity.parent_session_id = worker.parent_session_id.clone())
|
||||
.or_insert_with(|| InternalWorkerActivity {
|
||||
status: None,
|
||||
parent_session_id: worker.parent_session_id.clone(),
|
||||
revision,
|
||||
});
|
||||
}
|
||||
|
||||
fn project_internal_worker_event(
|
||||
activity: &mut InternalWorkerActivityProjection,
|
||||
worker: &protocol::InternalWorkerRef,
|
||||
revision: u64,
|
||||
event: &protocol::Event,
|
||||
) {
|
||||
if activity.removed_workers.contains_key(&worker.session_id)
|
||||
|| activity
|
||||
.workers
|
||||
.get(&worker.session_id)
|
||||
.is_some_and(|current| revision <= current.revision)
|
||||
{
|
||||
if activity.removed_workers.contains_key(&worker.session_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match event {
|
||||
protocol::Event::Snapshot {
|
||||
state,
|
||||
@@ -5940,7 +5908,6 @@ impl RuntimeState {
|
||||
InternalWorkerActivity {
|
||||
status: Some(state.catalog_status()),
|
||||
parent_session_id: worker.parent_session_id.clone(),
|
||||
revision,
|
||||
},
|
||||
);
|
||||
for child in internal_workers {
|
||||
@@ -5949,23 +5916,16 @@ impl RuntimeState {
|
||||
}
|
||||
protocol::Event::InternalWorker {
|
||||
worker: nested_worker,
|
||||
revision: nested_revision,
|
||||
event,
|
||||
} => {
|
||||
Self::touch_internal_worker_activity(activity, worker, revision);
|
||||
Self::project_internal_worker_event(
|
||||
activity,
|
||||
nested_worker,
|
||||
*nested_revision,
|
||||
event,
|
||||
);
|
||||
Self::touch_internal_worker_activity(activity, worker);
|
||||
Self::project_internal_worker_event(activity, nested_worker, event);
|
||||
}
|
||||
protocol::Event::InternalWorkerRemoved {
|
||||
worker: removed_worker,
|
||||
revision: removed_revision,
|
||||
} => {
|
||||
Self::touch_internal_worker_activity(activity, worker, revision);
|
||||
Self::remove_internal_worker_subtree(activity, removed_worker, *removed_revision);
|
||||
Self::touch_internal_worker_activity(activity, worker);
|
||||
Self::remove_internal_worker_subtree(activity, removed_worker);
|
||||
}
|
||||
protocol::Event::WorkerState { snapshot }
|
||||
| protocol::Event::CommandAcknowledged {
|
||||
@@ -5979,11 +5939,10 @@ impl RuntimeState {
|
||||
InternalWorkerActivity {
|
||||
status: Some(snapshot.catalog_status()),
|
||||
parent_session_id: worker.parent_session_id.clone(),
|
||||
revision,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => Self::touch_internal_worker_activity(activity, worker, revision),
|
||||
_ => Self::touch_internal_worker_activity(activity, worker),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6001,15 +5960,11 @@ impl RuntimeState {
|
||||
Self::internal_worker_snapshot_statuses(activity, child);
|
||||
}
|
||||
}
|
||||
protocol::Event::InternalWorker {
|
||||
worker,
|
||||
revision,
|
||||
event,
|
||||
} => {
|
||||
Self::project_internal_worker_event(activity, worker, *revision, event);
|
||||
protocol::Event::InternalWorker { worker, event } => {
|
||||
Self::project_internal_worker_event(activity, worker, event);
|
||||
}
|
||||
protocol::Event::InternalWorkerRemoved { worker, revision } => {
|
||||
Self::remove_internal_worker_subtree(activity, worker, *revision);
|
||||
protocol::Event::InternalWorkerRemoved { worker } => {
|
||||
Self::remove_internal_worker_subtree(activity, worker);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -6057,12 +6012,10 @@ impl RuntimeState {
|
||||
struct InternalWorkerActivity {
|
||||
status: Option<protocol::WorkerStatus>,
|
||||
parent_session_id: Option<String>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RemovedInternalWorkerActivity {
|
||||
revision: u64,
|
||||
parent_session_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -6111,8 +6064,9 @@ pub(crate) struct RestoreRequestOwner {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct RestoreGuard {
|
||||
generation: String,
|
||||
observation_token: String,
|
||||
active_request_id: Option<String>,
|
||||
owners: BTreeMap<String, RestoreRequestOwner>,
|
||||
}
|
||||
@@ -6120,7 +6074,7 @@ pub(crate) struct RestoreGuard {
|
||||
impl Default for RestoreGuard {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
generation: uuid::Uuid::now_v7().to_string(),
|
||||
observation_token: uuid::Uuid::now_v7().to_string(),
|
||||
active_request_id: None,
|
||||
owners: BTreeMap::new(),
|
||||
}
|
||||
@@ -6129,7 +6083,7 @@ impl Default for RestoreGuard {
|
||||
|
||||
impl RestoreGuard {
|
||||
fn rotate(&mut self) {
|
||||
self.generation = uuid::Uuid::now_v7().to_string();
|
||||
self.observation_token = uuid::Uuid::now_v7().to_string();
|
||||
}
|
||||
|
||||
fn finish(&mut self, result: RuntimeWorkerRestoreResult) {
|
||||
@@ -6199,7 +6153,7 @@ impl WorkerRecord {
|
||||
|
||||
fn summary(&self) -> WorkerSummary {
|
||||
WorkerSummary {
|
||||
restore_observation_token: Some(self.restore_guard.generation.clone()),
|
||||
restore_observation_token: Some(self.restore_guard.observation_token.clone()),
|
||||
worker_ref: self.worker_ref.clone(),
|
||||
worker_id: self.worker_id,
|
||||
status: self.status,
|
||||
@@ -6217,7 +6171,7 @@ impl WorkerRecord {
|
||||
|
||||
fn detail(&self) -> WorkerDetail {
|
||||
WorkerDetail {
|
||||
restore_observation_token: Some(self.restore_guard.generation.clone()),
|
||||
restore_observation_token: Some(self.restore_guard.observation_token.clone()),
|
||||
worker_ref: self.worker_ref.clone(),
|
||||
worker_id: self.worker_id,
|
||||
status: self.status,
|
||||
@@ -6740,11 +6694,6 @@ fn validate_create_workspace_scope(
|
||||
snapshot.workspace_id
|
||||
)));
|
||||
}
|
||||
if snapshot.settings_revision == 0 {
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Memory settings revision must be at least 1".to_string(),
|
||||
));
|
||||
}
|
||||
if !manifest::is_normalized_workspace_memory_language(&snapshot.language) {
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Memory settings language must be a normalized bounded UTF-8 value".to_string(),
|
||||
@@ -6953,12 +6902,10 @@ mod tests {
|
||||
|
||||
fn internal_worker_status_event(
|
||||
worker: protocol::InternalWorkerRef,
|
||||
revision: u64,
|
||||
status: protocol::WorkerStatus,
|
||||
) -> protocol::Event {
|
||||
protocol::Event::InternalWorker {
|
||||
worker,
|
||||
revision,
|
||||
event: Box::new(protocol::Event::WorkerState {
|
||||
snapshot: status.into(),
|
||||
}),
|
||||
@@ -6967,13 +6914,11 @@ mod tests {
|
||||
|
||||
fn internal_worker_snapshot(
|
||||
worker: protocol::InternalWorkerRef,
|
||||
revision: u64,
|
||||
status: protocol::WorkerStatus,
|
||||
internal_workers: Vec<protocol::InternalWorkerSnapshot>,
|
||||
) -> protocol::InternalWorkerSnapshot {
|
||||
protocol::InternalWorkerSnapshot {
|
||||
worker,
|
||||
revision,
|
||||
status,
|
||||
greeting: None,
|
||||
session: protocol::SessionSnapshot {
|
||||
@@ -7017,7 +6962,6 @@ mod tests {
|
||||
&mut activity,
|
||||
&internal_worker_status_event(
|
||||
internal_worker_ref("child-a", None),
|
||||
1,
|
||||
protocol::WorkerStatus::Running,
|
||||
),
|
||||
));
|
||||
@@ -7025,7 +6969,6 @@ mod tests {
|
||||
&mut activity,
|
||||
&internal_worker_status_event(
|
||||
internal_worker_ref("child-b", None),
|
||||
1,
|
||||
protocol::WorkerStatus::Running,
|
||||
),
|
||||
));
|
||||
@@ -7033,7 +6976,6 @@ mod tests {
|
||||
&mut activity,
|
||||
&internal_worker_status_event(
|
||||
internal_worker_ref("child-a", None),
|
||||
2,
|
||||
protocol::WorkerStatus::Idle,
|
||||
),
|
||||
));
|
||||
@@ -7041,7 +6983,6 @@ mod tests {
|
||||
&mut activity,
|
||||
&internal_worker_status_event(
|
||||
internal_worker_ref("child-b", None),
|
||||
2,
|
||||
protocol::WorkerStatus::Stopped,
|
||||
),
|
||||
));
|
||||
@@ -7053,10 +6994,8 @@ mod tests {
|
||||
let direct_child = internal_worker_ref("child", None);
|
||||
let nested_running = protocol::Event::InternalWorker {
|
||||
worker: direct_child.clone(),
|
||||
revision: 1,
|
||||
event: Box::new(internal_worker_status_event(
|
||||
internal_worker_ref("grandchild", Some("child")),
|
||||
1,
|
||||
protocol::WorkerStatus::Running,
|
||||
)),
|
||||
};
|
||||
@@ -7067,10 +7006,8 @@ mod tests {
|
||||
|
||||
let nested_idle = protocol::Event::InternalWorker {
|
||||
worker: direct_child,
|
||||
revision: 2,
|
||||
event: Box::new(internal_worker_status_event(
|
||||
internal_worker_ref("grandchild", Some("child")),
|
||||
2,
|
||||
protocol::WorkerStatus::Idle,
|
||||
)),
|
||||
};
|
||||
@@ -7087,7 +7024,6 @@ mod tests {
|
||||
&mut activity,
|
||||
&internal_worker_status_event(
|
||||
internal_worker_ref("child-a", None),
|
||||
1,
|
||||
protocol::WorkerStatus::Running,
|
||||
),
|
||||
);
|
||||
@@ -7101,44 +7037,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_worker_removal_is_revision_fenced_until_parent_snapshot() {
|
||||
fn internal_worker_removal_is_session_fenced_until_parent_snapshot() {
|
||||
let mut activity = InternalWorkerActivityProjection::default();
|
||||
let child_a = internal_worker_ref("child-a", None);
|
||||
let child_b = internal_worker_ref("child-b", None);
|
||||
|
||||
assert!(RuntimeState::update_internal_worker_activity(
|
||||
&mut activity,
|
||||
&internal_worker_status_event(child_a.clone(), 2, protocol::WorkerStatus::Running),
|
||||
&internal_worker_status_event(child_a.clone(), protocol::WorkerStatus::Running),
|
||||
));
|
||||
assert!(!RuntimeState::update_internal_worker_activity(
|
||||
&mut activity,
|
||||
&internal_worker_status_event(child_b.clone(), 1, protocol::WorkerStatus::Running),
|
||||
&internal_worker_status_event(child_b.clone(), protocol::WorkerStatus::Running),
|
||||
));
|
||||
|
||||
assert!(!RuntimeState::update_internal_worker_activity(
|
||||
&mut activity,
|
||||
&protocol::Event::InternalWorkerRemoved {
|
||||
worker: child_a.clone(),
|
||||
revision: 1,
|
||||
},
|
||||
));
|
||||
assert!(activity.workers.contains_key("child-a"));
|
||||
|
||||
assert!(!RuntimeState::update_internal_worker_activity(
|
||||
&mut activity,
|
||||
&protocol::Event::InternalWorkerRemoved {
|
||||
worker: child_a.clone(),
|
||||
revision: 2,
|
||||
},
|
||||
));
|
||||
assert!(activity.workers.contains_key("child-a"));
|
||||
|
||||
assert!(!RuntimeState::update_internal_worker_activity(
|
||||
&mut activity,
|
||||
&protocol::Event::InternalWorkerRemoved {
|
||||
worker: child_a,
|
||||
revision: 3,
|
||||
},
|
||||
&protocol::Event::InternalWorkerRemoved { worker: child_a },
|
||||
));
|
||||
assert!(!activity.workers.contains_key("child-a"));
|
||||
assert!(activity.removed_workers.contains_key("child-a"));
|
||||
@@ -7148,14 +7063,13 @@ mod tests {
|
||||
&mut activity,
|
||||
&protocol::Event::InternalWorkerRemoved {
|
||||
worker: child_b.clone(),
|
||||
revision: 2,
|
||||
},
|
||||
));
|
||||
assert!(!activity.has_running_worker());
|
||||
|
||||
assert!(!RuntimeState::update_internal_worker_activity(
|
||||
&mut activity,
|
||||
&internal_worker_status_event(child_b.clone(), 3, protocol::WorkerStatus::Running),
|
||||
&internal_worker_status_event(child_b.clone(), protocol::WorkerStatus::Running),
|
||||
));
|
||||
assert!(!activity.workers.contains_key("child-b"));
|
||||
assert!(!activity.has_running_worker());
|
||||
@@ -7164,7 +7078,6 @@ mod tests {
|
||||
&mut activity,
|
||||
&parent_snapshot(vec![internal_worker_snapshot(
|
||||
child_b,
|
||||
4,
|
||||
protocol::WorkerStatus::Running,
|
||||
Vec::new(),
|
||||
)]),
|
||||
@@ -7182,16 +7095,14 @@ mod tests {
|
||||
|
||||
assert!(!RuntimeState::update_internal_worker_activity(
|
||||
&mut activity,
|
||||
&internal_worker_status_event(child.clone(), 1, protocol::WorkerStatus::Idle),
|
||||
&internal_worker_status_event(child.clone(), protocol::WorkerStatus::Idle),
|
||||
));
|
||||
assert!(RuntimeState::update_internal_worker_activity(
|
||||
&mut activity,
|
||||
&protocol::Event::InternalWorker {
|
||||
worker: child.clone(),
|
||||
revision: 2,
|
||||
event: Box::new(internal_worker_status_event(
|
||||
grandchild.clone(),
|
||||
1,
|
||||
protocol::WorkerStatus::Running,
|
||||
)),
|
||||
},
|
||||
@@ -7200,13 +7111,10 @@ mod tests {
|
||||
&mut activity,
|
||||
&protocol::Event::InternalWorker {
|
||||
worker: child.clone(),
|
||||
revision: 3,
|
||||
event: Box::new(protocol::Event::InternalWorker {
|
||||
worker: grandchild.clone(),
|
||||
revision: 2,
|
||||
event: Box::new(internal_worker_status_event(
|
||||
great_grandchild,
|
||||
1,
|
||||
protocol::WorkerStatus::Running,
|
||||
)),
|
||||
}),
|
||||
@@ -7217,10 +7125,8 @@ mod tests {
|
||||
&mut activity,
|
||||
&protocol::Event::InternalWorker {
|
||||
worker: child.clone(),
|
||||
revision: 4,
|
||||
event: Box::new(protocol::Event::InternalWorkerRemoved {
|
||||
worker: grandchild.clone(),
|
||||
revision: 3,
|
||||
}),
|
||||
},
|
||||
));
|
||||
@@ -7235,10 +7141,8 @@ mod tests {
|
||||
&mut activity,
|
||||
&protocol::Event::InternalWorker {
|
||||
worker: child,
|
||||
revision: 5,
|
||||
event: Box::new(internal_worker_status_event(
|
||||
grandchild,
|
||||
4,
|
||||
protocol::WorkerStatus::Running,
|
||||
)),
|
||||
},
|
||||
@@ -7338,7 +7242,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,
|
||||
@@ -7367,7 +7270,7 @@ mod tests {
|
||||
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
|
||||
job_id: "job-1".into(),
|
||||
attempt_id: "attempt-1".into(),
|
||||
input_revision: Some("revision-1".into()),
|
||||
input_digest: Some(format!("sha256:{}", "a".repeat(64))),
|
||||
subjektiv_consolidation: false,
|
||||
});
|
||||
let owner = scope("local", "server-job-test");
|
||||
@@ -7416,7 +7319,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: true,
|
||||
});
|
||||
assert!(validate_create_worker_request(&request).is_err());
|
||||
@@ -7444,7 +7347,6 @@ mod tests {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:source".to_string(),
|
||||
selector: None,
|
||||
},
|
||||
@@ -7455,16 +7357,15 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_candidates: vec![RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "key-1".into(),
|
||||
private_key: SensitiveString::new("private-key-bytes"),
|
||||
}],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
host_key_fingerprint: "key-1".into(),
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
@@ -7512,8 +7413,6 @@ mod tests {
|
||||
operation: crate::resource::BackendResourceOperation::FetchOnce,
|
||||
expires_at_unix_seconds: i64::MAX,
|
||||
nonce: "repository-access-1".to_string(),
|
||||
revision: "1".to_string(),
|
||||
generation: None,
|
||||
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
@@ -7544,12 +7443,12 @@ mod tests {
|
||||
credential_candidates: vec![
|
||||
crate::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "key-1".into(),
|
||||
private_key: "private-key-bytes-1".to_string(),
|
||||
},
|
||||
crate::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
public_key_fingerprint: "key-3".into(),
|
||||
private_key: "private-key-bytes-2".to_string(),
|
||||
},
|
||||
],
|
||||
@@ -7567,23 +7466,22 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_candidates: vec![
|
||||
RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "key-1".into(),
|
||||
private_key: SensitiveString::default(),
|
||||
},
|
||||
RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
public_key_fingerprint: "key-3".into(),
|
||||
private_key: SensitiveString::default(),
|
||||
},
|
||||
],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
host_key_fingerprint: "key-1".into(),
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
@@ -7652,12 +7550,12 @@ mod tests {
|
||||
credential_candidates: vec![
|
||||
crate::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "key-1".into(),
|
||||
private_key: "create-private-key-bytes-1".to_string(),
|
||||
},
|
||||
crate::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
public_key_fingerprint: "key-3".into(),
|
||||
private_key: "create-private-key-bytes-2".to_string(),
|
||||
},
|
||||
],
|
||||
@@ -7677,7 +7575,6 @@ mod tests {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:source".to_string(),
|
||||
selector: None,
|
||||
},
|
||||
@@ -7688,23 +7585,22 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-create".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_candidates: vec![
|
||||
RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "key-1".into(),
|
||||
private_key: SensitiveString::default(),
|
||||
},
|
||||
RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
public_key_fingerprint: "key-3".into(),
|
||||
private_key: SensitiveString::default(),
|
||||
},
|
||||
],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
host_key_fingerprint: "key-1".into(),
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
@@ -7751,7 +7647,6 @@ mod tests {
|
||||
});
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
request
|
||||
@@ -7769,14 +7664,12 @@ mod tests {
|
||||
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-b".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
|
||||
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
settings_revision: 2,
|
||||
language: " english ".to_string(),
|
||||
});
|
||||
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
|
||||
@@ -7791,7 +7684,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 {
|
||||
@@ -8350,7 +8242,6 @@ mod tests {
|
||||
kind: server_api::RepositorySourceKind::LocalPath,
|
||||
uri: "/unused-test-repository".into(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test-source".into(),
|
||||
selector: None,
|
||||
},
|
||||
@@ -8368,7 +8259,6 @@ mod tests {
|
||||
workspace_id: "workspace-a".into(),
|
||||
runtime_id: "runtime-1".into(),
|
||||
operation_id: "operation-1".into(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:test-projection".into(),
|
||||
ssh: None,
|
||||
},
|
||||
@@ -8842,7 +8732,6 @@ mod tests {
|
||||
&created.worker_ref,
|
||||
internal_worker_status_event(
|
||||
internal_worker_ref("child-live", None),
|
||||
1,
|
||||
protocol::WorkerStatus::Running,
|
||||
),
|
||||
)
|
||||
@@ -8861,7 +8750,6 @@ mod tests {
|
||||
&created.worker_ref,
|
||||
protocol::Event::InternalWorkerRemoved {
|
||||
worker: internal_worker_ref("child-live", None),
|
||||
revision: 2,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -8879,7 +8767,6 @@ mod tests {
|
||||
&created.worker_ref,
|
||||
internal_worker_status_event(
|
||||
internal_worker_ref("child-live", None),
|
||||
3,
|
||||
protocol::WorkerStatus::Running,
|
||||
),
|
||||
)
|
||||
@@ -8900,7 +8787,6 @@ mod tests {
|
||||
&created.worker_ref,
|
||||
parent_snapshot(vec![internal_worker_snapshot(
|
||||
internal_worker_ref("child-live", None),
|
||||
4,
|
||||
protocol::WorkerStatus::Running,
|
||||
Vec::new(),
|
||||
)]),
|
||||
@@ -9427,7 +9313,6 @@ mod tests {
|
||||
bundle.prompt_catalog = Some(
|
||||
worker::EffectivePromptCatalog::new(
|
||||
BTreeMap::from([("default".to_string(), "workspace prompt".to_string())]),
|
||||
7,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -10287,7 +10172,6 @@ mod tests {
|
||||
workspace_id: "workspace-a".into(),
|
||||
runtime_id: "peer-runtime".into(),
|
||||
operation_id: format!("worker-restore:{}", request.request_id),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "snapshot".into(),
|
||||
ssh: None,
|
||||
},
|
||||
@@ -10405,7 +10289,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_guard_protocol_state_aba_rotates_generation() {
|
||||
fn restore_guard_protocol_state_aba_rotates_observation_token() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
let worker = runtime.create_worker(task_request("state ABA")).unwrap();
|
||||
let request = runtime.test_restore_request(&worker.worker_ref);
|
||||
@@ -11160,7 +11044,7 @@ mod tests {
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
#[test]
|
||||
fn restore_guard_generation_and_terminal_receipt_survive_restart() {
|
||||
fn restore_guard_observation_token_and_terminal_receipt_survive_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let options =
|
||||
FsRuntimeStoreOptions::new(dir.path().join("runtime")).with_runtime_id("guard-test");
|
||||
@@ -11177,7 +11061,7 @@ mod tests {
|
||||
.restore_worker_operation(&worker.worker_ref, request.clone())
|
||||
.unwrap();
|
||||
runtime.stop_worker(&worker.worker_ref, None).unwrap();
|
||||
let generation = runtime
|
||||
let observation_token = runtime
|
||||
.worker_detail(&worker.worker_ref)
|
||||
.unwrap()
|
||||
.restore_observation_token;
|
||||
@@ -11188,7 +11072,7 @@ mod tests {
|
||||
.worker_detail(&worker.worker_ref)
|
||||
.unwrap()
|
||||
.restore_observation_token,
|
||||
generation
|
||||
observation_token
|
||||
);
|
||||
assert_eq!(
|
||||
restarted
|
||||
@@ -12680,7 +12564,7 @@ mod tests {
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported Runtime store schema version 2; expected 6, 7, 8, or 9")
|
||||
.contains("unsupported Runtime store schema version 2; expected 6, 7, 8, 9, or 10")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
@@ -12722,7 +12606,7 @@ mod tests {
|
||||
let worker_aggregate: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(worker_aggregate["schema_version"], serde_json::json!(9));
|
||||
assert_eq!(worker_aggregate["schema_version"], serde_json::json!(10));
|
||||
assert_eq!(worker_aggregate["status"], serde_json::json!("stopped"));
|
||||
assert_eq!(
|
||||
worker_aggregate["execution_state"]["state"],
|
||||
@@ -13072,7 +12956,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.remove("restore_guard")
|
||||
.unwrap();
|
||||
assert_ne!(guard["generation"], old_guard["generation"]);
|
||||
assert_ne!(guard["observation_token"], old_guard["observation_token"]);
|
||||
assert!(guard["active_request_id"].is_null());
|
||||
assert_eq!(
|
||||
guard["owners"]
|
||||
@@ -13669,7 +13553,7 @@ mod tests {
|
||||
);
|
||||
let migrated_aggregate: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap();
|
||||
assert_eq!(migrated_aggregate["schema_version"], serde_json::json!(9));
|
||||
assert_eq!(migrated_aggregate["schema_version"], serde_json::json!(10));
|
||||
assert!(migrated_aggregate.get("request").is_none());
|
||||
assert!(migrated_aggregate.get("execution").is_none());
|
||||
assert_eq!(
|
||||
|
||||
@@ -374,7 +374,6 @@ fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiv
|
||||
content_bytes: manifest.content_bytes,
|
||||
content_file_count: manifest.content_file_count,
|
||||
policy_id: manifest.policy_id.clone(),
|
||||
policy_revision: manifest.policy_revision,
|
||||
operation_id: manifest.operation_id.clone(),
|
||||
input_fingerprint: manifest.input_fingerprint.clone(),
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ use worker::feature::builtin::{
|
||||
WorkspaceClientWorkerObservationProvider,
|
||||
};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
|
||||
use worker::ipc::protocol_session::{WorkerProtocolSessionStreams, live_log_entry_event};
|
||||
use worker::{
|
||||
PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle,
|
||||
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
||||
@@ -142,6 +142,15 @@ use worker::{
|
||||
bash_output_dir_for_worker_id,
|
||||
};
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtocolSessionStreams {
|
||||
// Child state changes and their live events share this publication gate.
|
||||
// Acquire it before subscribing, and retain it through the parent snapshot
|
||||
// capture, so a pre-snapshot child event cannot replay after newer state.
|
||||
let _publish_guard = handle.protocol_snapshot_publish_guard();
|
||||
worker::ipc::protocol_session::subscribe_worker_protocol_session(handle)
|
||||
}
|
||||
|
||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SPAWN_RESTORE_TASK_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
@@ -403,26 +412,12 @@ impl WorkspacePromptProjectionCache {
|
||||
.active
|
||||
.lock()
|
||||
.map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?;
|
||||
if let Some(current) = active.get(&workspace_id) {
|
||||
if current.projection.config_revision > resolution.projection.config_revision {
|
||||
return Ok(current.clone());
|
||||
}
|
||||
if current.projection.config_revision == resolution.projection.config_revision
|
||||
&& (current.projection.source_digest != resolution.projection.source_digest
|
||||
|| current.projection.projection_digest
|
||||
!= resolution.projection.projection_digest
|
||||
|| current.projection.catalog.catalog_digest
|
||||
!= resolution.projection.catalog.catalog_digest
|
||||
|| current.projection.catalog.schema_fingerprint
|
||||
!= resolution.projection.catalog.schema_fingerprint
|
||||
|| current.projection.catalog.toolchain_fingerprint
|
||||
!= resolution.projection.catalog.toolchain_fingerprint)
|
||||
// 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 Err(format!(
|
||||
"Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}",
|
||||
resolution.projection.config_revision
|
||||
));
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -1195,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();
|
||||
@@ -1216,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() {
|
||||
@@ -4789,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();
|
||||
@@ -5982,7 +5970,6 @@ mod tests {
|
||||
);
|
||||
let mut catalog = worker::EffectivePromptCatalog::new(
|
||||
templates,
|
||||
17,
|
||||
projection.schema_fingerprint.clone(),
|
||||
projection.toolchain_fingerprint.clone(),
|
||||
)
|
||||
@@ -6005,7 +5992,6 @@ mod tests {
|
||||
missing_instruction_bundle.prompt_catalog = Some(
|
||||
worker::EffectivePromptCatalog::new(
|
||||
templates,
|
||||
17,
|
||||
projection.schema_fingerprint.clone(),
|
||||
projection.toolchain_fingerprint.clone(),
|
||||
)
|
||||
@@ -6115,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"),
|
||||
] {
|
||||
@@ -6660,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",
|
||||
)
|
||||
@@ -6674,7 +6659,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let catalog_v2 = worker::EffectivePromptCatalog::new(
|
||||
BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]),
|
||||
9,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
@@ -6691,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
|
||||
@@ -6699,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",
|
||||
)
|
||||
@@ -6724,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",
|
||||
@@ -6757,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]
|
||||
@@ -7165,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 {
|
||||
@@ -7273,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] {
|
||||
@@ -7329,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");
|
||||
@@ -7987,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")),
|
||||
},
|
||||
@@ -8197,7 +8181,6 @@ mod tests {
|
||||
);
|
||||
let mut effective = worker::EffectivePromptCatalog::new(
|
||||
templates,
|
||||
7,
|
||||
projection.schema_fingerprint.clone(),
|
||||
projection.toolchain_fingerprint.clone(),
|
||||
)
|
||||
@@ -8213,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"
|
||||
@@ -8250,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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -40,6 +40,7 @@ const REPOSITORY_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||
static NEXT_WORKING_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "FrozenEvidenceRead")]
|
||||
pub struct WorkingDirectoryEvidence {
|
||||
pub repository_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -48,16 +49,61 @@ pub struct WorkingDirectoryEvidence {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_tree: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repository_source_revision: Option<u64>,
|
||||
/// SSH authority must be reacquired before opening a session, even if an old
|
||||
/// record has no concrete key fingerprint. Never inferred as false from that omission.
|
||||
pub repository_access_required: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repository_source_fingerprint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub operation_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub credential_revision: Option<u64>,
|
||||
pub public_key_fingerprint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_trust_revision: Option<u64>,
|
||||
pub host_key_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
// Frozen persisted evidence reader. Legacy counters only mark that SSH
|
||||
// access is required; they are never converted to key identity or grants.
|
||||
#[derive(Deserialize)]
|
||||
struct FrozenEvidenceRead {
|
||||
repository_id: String,
|
||||
requested_selector: Option<String>,
|
||||
resolved_commit: String,
|
||||
resolved_tree: Option<String>,
|
||||
materializer_kind: MaterializerKind,
|
||||
repository_source_fingerprint: Option<String>,
|
||||
operation_id: Option<String>,
|
||||
public_key_fingerprint: Option<String>,
|
||||
host_key_fingerprint: Option<String>,
|
||||
repository_access_required: Option<bool>,
|
||||
credential_revision: Option<u64>,
|
||||
host_trust_revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl TryFrom<FrozenEvidenceRead> for WorkingDirectoryEvidence {
|
||||
type Error = &'static str;
|
||||
fn try_from(wire: FrozenEvidenceRead) -> Result<Self, Self::Error> {
|
||||
if wire.credential_revision == Some(0) || wire.host_trust_revision == Some(0) {
|
||||
return Err("legacy SSH evidence contains an invalid authority marker");
|
||||
}
|
||||
let repository_access_required = wire.repository_access_required.unwrap_or(false)
|
||||
|| wire.credential_revision.is_some()
|
||||
|| wire.host_trust_revision.is_some()
|
||||
|| wire.public_key_fingerprint.is_some()
|
||||
|| wire.host_key_fingerprint.is_some();
|
||||
Ok(Self {
|
||||
repository_id: wire.repository_id,
|
||||
requested_selector: wire.requested_selector,
|
||||
resolved_commit: wire.resolved_commit,
|
||||
resolved_tree: wire.resolved_tree,
|
||||
materializer_kind: wire.materializer_kind,
|
||||
repository_access_required,
|
||||
repository_source_fingerprint: wire.repository_source_fingerprint,
|
||||
operation_id: wire.operation_id,
|
||||
public_key_fingerprint: wire.public_key_fingerprint,
|
||||
host_key_fingerprint: wire.host_key_fingerprint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -156,7 +202,7 @@ impl WorkingDirectoryBinding {
|
||||
WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending
|
||||
) && binding_paths_are_available(self)
|
||||
{
|
||||
let (current_selector, current_ref, current_tree) = binding_current_revision(self);
|
||||
let (current_selector, current_ref, current_tree) = binding_current_commit(self);
|
||||
summary.current_selector = current_selector;
|
||||
summary.current_ref = current_ref;
|
||||
summary.current_tree = current_tree;
|
||||
@@ -274,7 +320,7 @@ fn binding_paths_are_available(binding: &WorkingDirectoryBinding) -> bool {
|
||||
root.join(".git").is_dir()
|
||||
}
|
||||
|
||||
fn binding_current_revision(
|
||||
fn binding_current_commit(
|
||||
binding: &WorkingDirectoryBinding,
|
||||
) -> (Option<String>, Option<String>, Option<String>) {
|
||||
let current_ref = git_stdout(binding.root(), ["rev-parse", "HEAD"])
|
||||
@@ -305,13 +351,12 @@ fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingRepositoryAccessLease {
|
||||
generation: u64,
|
||||
lease_id: uuid::Uuid,
|
||||
access: RepositorySshMaterializationAccess,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RepositoryAccessExpiryState {
|
||||
next_generation: u64,
|
||||
pending: HashMap<String, PendingRepositoryAccessLease>,
|
||||
shutdown: bool,
|
||||
}
|
||||
@@ -360,23 +405,11 @@ impl RepositoryAccessExpiryScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
fn next_generation(
|
||||
state: &mut RepositoryAccessExpiryState,
|
||||
) -> Result<u64, WorkingDirectoryDiagnostic> {
|
||||
state.next_generation = state.next_generation.checked_add(1).ok_or_else(|| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_repository_access_generation_exhausted",
|
||||
"Runtime Repository access generation is exhausted",
|
||||
)
|
||||
})?;
|
||||
Ok(state.next_generation)
|
||||
}
|
||||
|
||||
fn store_pending(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
access: RepositorySshMaterializationAccess,
|
||||
) -> Result<u64, WorkingDirectoryDiagnostic> {
|
||||
) -> Result<uuid::Uuid, WorkingDirectoryDiagnostic> {
|
||||
let mut state = self.inner.state.lock().map_err(|_| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_repository_access_unavailable",
|
||||
@@ -389,14 +422,14 @@ impl RepositoryAccessExpiryScheduler {
|
||||
"Runtime Repository access state is shutting down",
|
||||
));
|
||||
}
|
||||
let generation = Self::next_generation(&mut state)?;
|
||||
let lease_id = uuid::Uuid::now_v7();
|
||||
state.pending.insert(
|
||||
working_directory_id.to_string(),
|
||||
PendingRepositoryAccessLease { generation, access },
|
||||
PendingRepositoryAccessLease { lease_id, access },
|
||||
);
|
||||
drop(state);
|
||||
self.inner.wake.notify_one();
|
||||
Ok(generation)
|
||||
Ok(lease_id)
|
||||
}
|
||||
|
||||
fn pending(
|
||||
@@ -452,14 +485,14 @@ impl RepositoryAccessExpiryScheduler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_expiry(state: &RepositoryAccessExpiryState) -> Option<(String, u64, u64)> {
|
||||
fn next_expiry(state: &RepositoryAccessExpiryState) -> Option<(String, uuid::Uuid, u64)> {
|
||||
state
|
||||
.pending
|
||||
.iter()
|
||||
.map(|(working_directory_id, lease)| {
|
||||
(
|
||||
working_directory_id.clone(),
|
||||
lease.generation,
|
||||
lease.lease_id,
|
||||
lease.access.expires_at_epoch_seconds,
|
||||
)
|
||||
})
|
||||
@@ -469,14 +502,14 @@ impl RepositoryAccessExpiryScheduler {
|
||||
fn expire_if_current(
|
||||
state: &mut RepositoryAccessExpiryState,
|
||||
working_directory_id: &str,
|
||||
generation: u64,
|
||||
lease_id: uuid::Uuid,
|
||||
now_epoch_seconds: u64,
|
||||
) {
|
||||
let should_remove = state
|
||||
.pending
|
||||
.get(working_directory_id)
|
||||
.is_some_and(|lease| {
|
||||
lease.generation == generation
|
||||
lease.lease_id == lease_id
|
||||
&& lease.access.expires_at_epoch_seconds <= now_epoch_seconds
|
||||
});
|
||||
if should_remove {
|
||||
@@ -494,7 +527,7 @@ impl RepositoryAccessExpiryScheduler {
|
||||
if state.shutdown {
|
||||
return;
|
||||
}
|
||||
let Some((working_directory_id, generation, expires_at_epoch_seconds)) =
|
||||
let Some((working_directory_id, lease_id, expires_at_epoch_seconds)) =
|
||||
Self::next_expiry(&state)
|
||||
else {
|
||||
state = match inner.wake.wait(state) {
|
||||
@@ -518,7 +551,7 @@ impl RepositoryAccessExpiryScheduler {
|
||||
Self::expire_if_current(
|
||||
&mut state,
|
||||
&working_directory_id,
|
||||
generation,
|
||||
lease_id,
|
||||
now_epoch_seconds,
|
||||
);
|
||||
break;
|
||||
@@ -527,21 +560,21 @@ impl RepositoryAccessExpiryScheduler {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn pending_generation(&self, working_directory_id: &str) -> Option<u64> {
|
||||
fn pending_lease_id(&self, working_directory_id: &str) -> Option<uuid::Uuid> {
|
||||
self.inner
|
||||
.state
|
||||
.lock()
|
||||
.ok()?
|
||||
.pending
|
||||
.get(working_directory_id)
|
||||
.map(|lease| lease.generation)
|
||||
.map(|lease| lease.lease_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn expire_pending_for_test(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
generation: u64,
|
||||
lease_id: uuid::Uuid,
|
||||
now_epoch_seconds: u64,
|
||||
) {
|
||||
let Ok(mut state) = self.inner.state.lock() else {
|
||||
@@ -550,7 +583,7 @@ impl RepositoryAccessExpiryScheduler {
|
||||
Self::expire_if_current(
|
||||
&mut state,
|
||||
working_directory_id,
|
||||
generation,
|
||||
lease_id,
|
||||
now_epoch_seconds,
|
||||
);
|
||||
}
|
||||
@@ -701,6 +734,26 @@ impl RuntimeGitMaterializer {
|
||||
fn read_binding(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||
// Frozen evidence normalization can write the record. Keep it inside
|
||||
// use admission so observational reads cannot race physical cleanup.
|
||||
let _use_lease = self.occupancy.acquire_use(working_directory_id)?;
|
||||
self.read_binding_record(working_directory_id, true)
|
||||
}
|
||||
|
||||
fn read_binding_for_cleanup(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||
// Cleanup already owns exclusive admission and validates the original
|
||||
// bytes. Its witnessed status write performs any canonicalization.
|
||||
self.read_binding_record(working_directory_id, false)
|
||||
}
|
||||
|
||||
fn read_binding_record(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
migrate: bool,
|
||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||
let working_directory_root = self.working_directory_root(working_directory_id);
|
||||
let path = working_directory_root.join(MATERIALIZATION_RECORD);
|
||||
@@ -716,7 +769,7 @@ impl RuntimeGitMaterializer {
|
||||
"working directory working_directory record is invalid; backend-private path details were omitted",
|
||||
)
|
||||
})?;
|
||||
Ok(WorkingDirectoryBinding {
|
||||
let binding = WorkingDirectoryBinding {
|
||||
working_directory: record.working_directory,
|
||||
root: record.root.clone(),
|
||||
cwd: record.root,
|
||||
@@ -726,7 +779,39 @@ impl RuntimeGitMaterializer {
|
||||
occupancy: self.occupancy.clone(),
|
||||
occupancy_id: working_directory_id.to_string(),
|
||||
generation: self.occupancy.generation(&working_directory_id),
|
||||
};
|
||||
let canonical = serde_json::to_vec_pretty(&WorkingDirectoryMaterializationRecord {
|
||||
working_directory: binding.working_directory.clone(),
|
||||
root: binding.root.clone(),
|
||||
})
|
||||
.map_err(|_| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_record_invalid",
|
||||
"materialization evidence is invalid",
|
||||
)
|
||||
})?;
|
||||
if migrate && canonical != raw {
|
||||
let temporary = path.with_extension(format!("migration-{}.tmp", uuid::Uuid::now_v7()));
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)?;
|
||||
file.write_all(&canonical)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temporary, &path)?;
|
||||
fs::File::open(&binding.working_directory_root)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_record_write_failed",
|
||||
"materialization evidence migration could not be committed",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(binding)
|
||||
}
|
||||
|
||||
fn store_repository_access(
|
||||
@@ -750,8 +835,7 @@ impl RuntimeGitMaterializer {
|
||||
if binding
|
||||
.working_directory
|
||||
.evidence
|
||||
.credential_revision
|
||||
.is_some()
|
||||
.repository_access_required
|
||||
{
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_remote_repository_access_required",
|
||||
@@ -832,7 +916,6 @@ impl RuntimeGitMaterializer {
|
||||
if materialization.workspace_id.trim().is_empty()
|
||||
|| materialization.runtime_id.trim().is_empty()
|
||||
|| materialization.operation_id.trim().is_empty()
|
||||
|| materialization.config_revision == 0
|
||||
|| materialization.config_projection_digest.trim().is_empty()
|
||||
{
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
@@ -1032,16 +1115,17 @@ impl RuntimeGitMaterializer {
|
||||
resolved_commit,
|
||||
resolved_tree,
|
||||
materializer_kind: MaterializerKind::RuntimeGitClone,
|
||||
repository_source_revision: Some(request.repository.source_revision),
|
||||
repository_access_required: request.repository.source.kind
|
||||
== server_api::RepositorySourceKind::Ssh,
|
||||
repository_source_fingerprint: Some(request.repository.source_fingerprint.clone()),
|
||||
operation_id: context.map(|value| value.operation_id.clone()),
|
||||
credential_revision: context
|
||||
public_key_fingerprint: context
|
||||
.and_then(|value| value.ssh.as_ref())
|
||||
.and_then(|value| value.credential_candidates.first())
|
||||
.map(|candidate| candidate.credential_revision),
|
||||
host_trust_revision: context
|
||||
.map(|candidate| candidate.public_key_fingerprint.clone()),
|
||||
host_key_fingerprint: context
|
||||
.and_then(|value| value.ssh.as_ref())
|
||||
.map(|value| value.host_trust_revision),
|
||||
.map(|value| value.host_key_fingerprint.clone()),
|
||||
},
|
||||
cleanup_target: WorkingDirectoryCleanupTarget {
|
||||
kind: "runtime_git_clone".to_string(),
|
||||
@@ -1122,11 +1206,10 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if binding
|
||||
if !binding
|
||||
.working_directory
|
||||
.evidence
|
||||
.credential_revision
|
||||
.is_none()
|
||||
.repository_access_required
|
||||
{
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_repository_access_not_applicable",
|
||||
@@ -1146,13 +1229,15 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
|
||||
)?;
|
||||
binding.working_directory.evidence.operation_id =
|
||||
Some(request.materialization.operation_id.clone());
|
||||
binding.working_directory.evidence.credential_revision = Some(
|
||||
binding.working_directory.evidence.public_key_fingerprint = Some(
|
||||
ssh.credential_candidates
|
||||
.first()
|
||||
.expect("validated SSH credential candidate")
|
||||
.credential_revision,
|
||||
.public_key_fingerprint
|
||||
.clone(),
|
||||
);
|
||||
binding.working_directory.evidence.host_trust_revision = Some(ssh.host_trust_revision);
|
||||
binding.working_directory.evidence.host_key_fingerprint =
|
||||
Some(ssh.host_key_fingerprint.clone());
|
||||
self.write_record(&binding)?;
|
||||
self.store_repository_access(&request.working_directory_id, ssh)
|
||||
}
|
||||
@@ -1195,15 +1280,15 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
|
||||
"Repository provider returned an ambiguous ref observation",
|
||||
));
|
||||
}
|
||||
let (revision_ref, observed_selector) = line.split_once('\t').ok_or_else(|| {
|
||||
let (resolved_ref, observed_selector) = line.split_once('\t').ok_or_else(|| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"repository_ref_response_invalid",
|
||||
"Repository provider returned an invalid ref observation",
|
||||
)
|
||||
})?;
|
||||
if observed_selector != selector
|
||||
|| !matches!(revision_ref.len(), 40 | 64)
|
||||
|| !revision_ref.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
|| !matches!(resolved_ref.len(), 40 | 64)
|
||||
|| !resolved_ref.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"repository_ref_response_invalid",
|
||||
@@ -1213,10 +1298,9 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
|
||||
|
||||
Ok(RepositoryRefObservation {
|
||||
repository_id: request.repository.id.clone(),
|
||||
source_revision: request.repository.source_revision,
|
||||
source_fingerprint: request.repository.source_fingerprint.clone(),
|
||||
selector: selector.to_string(),
|
||||
revision_ref: revision_ref.to_ascii_lowercase(),
|
||||
resolved_ref: resolved_ref.to_ascii_lowercase(),
|
||||
observed_at_epoch_seconds: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -2294,11 +2378,11 @@ fn validate_ssh_materialization_access(
|
||||
if access.credential_candidates.is_empty()
|
||||
|| access.credential_candidates.iter().any(|candidate| {
|
||||
candidate.credential_id.trim().is_empty()
|
||||
|| candidate.credential_revision == 0
|
||||
|| candidate.public_key_fingerprint.trim().is_empty()
|
||||
|| !candidate.private_key.expose().contains("PRIVATE KEY")
|
||||
})
|
||||
|| access.host_trust_id.trim().is_empty()
|
||||
|| access.host_trust_revision == 0
|
||||
|| access.host_key_fingerprint.trim().is_empty()
|
||||
|| access.known_hosts_entry.expose().trim().is_empty()
|
||||
{
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
@@ -3013,8 +3097,6 @@ mod tests {
|
||||
operation: crate::resource::BackendResourceOperation::FetchOnce,
|
||||
expires_at_unix_seconds: i64::MAX,
|
||||
nonce: "repository-access-1".to_string(),
|
||||
revision: "1".to_string(),
|
||||
generation: None,
|
||||
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
@@ -3027,11 +3109,11 @@ mod tests {
|
||||
crate::catalog::RepositorySshMaterializationAccess {
|
||||
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 2,
|
||||
public_key_fingerprint: "key-2".into(),
|
||||
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"),
|
||||
}],
|
||||
host_trust_id: "trust-1".to_string(),
|
||||
host_trust_revision: 4,
|
||||
host_key_fingerprint: "key-4".into(),
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repo-main".to_string(),
|
||||
@@ -3043,7 +3125,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_access_expiry_fences_a_stale_pending_generation_after_refresh() {
|
||||
fn repository_access_expiry_fences_a_stale_pending_lease_id_after_refresh() {
|
||||
let scheduler = RepositoryAccessExpiryScheduler::new();
|
||||
let working_directory_id = "working-directory-refresh";
|
||||
let old_expiry = repository_access_now_epoch_seconds() + 60;
|
||||
@@ -3052,9 +3134,9 @@ mod tests {
|
||||
scheduler
|
||||
.store_pending(working_directory_id, old_access)
|
||||
.unwrap();
|
||||
let old_generation = scheduler
|
||||
.pending_generation(working_directory_id)
|
||||
.expect("old pending generation");
|
||||
let old_lease_id = scheduler
|
||||
.pending_lease_id(working_directory_id)
|
||||
.expect("old pending lease_id");
|
||||
|
||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||
let refresh_barrier = Arc::clone(&barrier);
|
||||
@@ -3069,12 +3151,12 @@ mod tests {
|
||||
});
|
||||
barrier.wait();
|
||||
refresh.join().unwrap();
|
||||
let refreshed_generation = scheduler
|
||||
.pending_generation(working_directory_id)
|
||||
.expect("refreshed pending generation");
|
||||
assert_ne!(old_generation, refreshed_generation);
|
||||
let refreshed_lease_id = scheduler
|
||||
.pending_lease_id(working_directory_id)
|
||||
.expect("refreshed pending lease_id");
|
||||
assert_ne!(old_lease_id, refreshed_lease_id);
|
||||
|
||||
scheduler.expire_pending_for_test(working_directory_id, old_generation, old_expiry);
|
||||
scheduler.expire_pending_for_test(working_directory_id, old_lease_id, old_expiry);
|
||||
let refreshed = scheduler
|
||||
.pending(working_directory_id)
|
||||
.unwrap()
|
||||
@@ -3175,7 +3257,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")),
|
||||
},
|
||||
@@ -3190,6 +3271,48 @@ mod tests {
|
||||
WorkerRef::new(WorkerId::from_legacy_u64(sequence))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_ssh_evidence_requires_fresh_access_and_is_rewritten_without_counters() {
|
||||
let repository = create_clean_repo();
|
||||
let runtime = tempfile::tempdir().unwrap();
|
||||
let materializer = RuntimeGitMaterializer::new(runtime.path());
|
||||
let binding = materializer.create(&request(repository.path())).unwrap();
|
||||
let id = binding.working_directory.id.clone();
|
||||
let record_path = binding.working_directory_root.join(MATERIALIZATION_RECORD);
|
||||
let mut record: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&record_path).unwrap()).unwrap();
|
||||
let evidence = record["working_directory"]["evidence"]
|
||||
.as_object_mut()
|
||||
.unwrap();
|
||||
evidence.remove("repository_access_required");
|
||||
evidence.insert("credential_revision".into(), serde_json::json!(3));
|
||||
evidence.insert("host_trust_revision".into(), serde_json::json!(2));
|
||||
fs::write(&record_path, serde_json::to_vec(&record).unwrap()).unwrap();
|
||||
drop(binding);
|
||||
assert_eq!(
|
||||
materializer
|
||||
.bind_working_directory(&id, None)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"working_directory_remote_repository_access_required"
|
||||
);
|
||||
let migrated: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&record_path).unwrap()).unwrap();
|
||||
let evidence = &migrated["working_directory"]["evidence"];
|
||||
assert_eq!(evidence["repository_access_required"], true);
|
||||
assert!(evidence.get("credential_revision").is_none());
|
||||
assert!(evidence.get("host_trust_revision").is_none());
|
||||
assert!(evidence.get("public_key_fingerprint").is_none());
|
||||
drop(materializer);
|
||||
assert_eq!(
|
||||
RuntimeGitMaterializer::new(runtime.path())
|
||||
.bind_working_directory(&id, None)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"working_directory_remote_repository_access_required"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_ref_observation_reads_the_provider_fresh() {
|
||||
let repo = create_clean_repo();
|
||||
@@ -3207,7 +3330,7 @@ mod tests {
|
||||
.observe_repository_ref(&observation_request)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
first.revision_ref,
|
||||
first.resolved_ref,
|
||||
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
|
||||
);
|
||||
fs::write(repo.path().join("second.txt"), "second\n").unwrap();
|
||||
@@ -3218,9 +3341,9 @@ mod tests {
|
||||
let second = materializer
|
||||
.observe_repository_ref(&observation_request)
|
||||
.unwrap();
|
||||
assert_ne!(first.revision_ref, second.revision_ref);
|
||||
assert_ne!(first.resolved_ref, second.resolved_ref);
|
||||
assert_eq!(
|
||||
second.revision_ref,
|
||||
second.resolved_ref,
|
||||
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
|
||||
);
|
||||
}
|
||||
@@ -3290,8 +3413,8 @@ mod tests {
|
||||
let still_first = materializer
|
||||
.observe_repository_ref(&observation_request)
|
||||
.unwrap();
|
||||
assert_eq!(still_first.revision_ref, first.revision_ref);
|
||||
assert_ne!(still_first.revision_ref, unpublished_second);
|
||||
assert_eq!(still_first.resolved_ref, first.resolved_ref);
|
||||
assert_ne!(still_first.resolved_ref, unpublished_second);
|
||||
|
||||
git(
|
||||
&workdir,
|
||||
@@ -3300,11 +3423,11 @@ mod tests {
|
||||
let second = materializer
|
||||
.observe_repository_ref(&observation_request)
|
||||
.unwrap();
|
||||
assert_eq!(second.revision_ref, unpublished_second);
|
||||
assert_ne!(second.revision_ref, first.revision_ref);
|
||||
assert_eq!(second.resolved_ref, unpublished_second);
|
||||
assert_ne!(second.resolved_ref, first.resolved_ref);
|
||||
assert_ne!(
|
||||
git_stdout(&cache, ["rev-parse", "HEAD"]).unwrap(),
|
||||
second.revision_ref
|
||||
second.resolved_ref
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3458,7 +3581,6 @@ mod tests {
|
||||
let mut file_request = request(repo.path());
|
||||
file_request.repository.source.kind = server_api::RepositorySourceKind::File;
|
||||
file_request.repository.source.uri = format!("file://{}", repo.path().display());
|
||||
file_request.repository.source_revision = 2;
|
||||
file_request.repository.source_fingerprint = "sha256:file-source".to_string();
|
||||
let file = materializer
|
||||
.materialize(&worker_ref(3), &file_request)
|
||||
@@ -3478,7 +3600,6 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 7,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh: None,
|
||||
});
|
||||
@@ -3511,8 +3632,8 @@ mod tests {
|
||||
"credential-1"
|
||||
);
|
||||
assert_eq!(
|
||||
serialized["credential_candidates"][0]["credential_revision"],
|
||||
2
|
||||
serialized["credential_candidates"][0]["public_key_fingerprint"],
|
||||
"key-2"
|
||||
);
|
||||
assert!(
|
||||
serialized["credential_candidates"][0]
|
||||
@@ -3564,27 +3685,26 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-agent".to_string(),
|
||||
config_revision: 2,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh: Some(crate::catalog::RepositorySshMaterializationAccess {
|
||||
credential_candidates: vec![
|
||||
crate::catalog::RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "key-1".into(),
|
||||
private_key: crate::catalog::SensitiveString::new(
|
||||
fs::read_to_string(&key_path).unwrap(),
|
||||
),
|
||||
},
|
||||
crate::catalog::RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
public_key_fingerprint: "key-3".into(),
|
||||
private_key: crate::catalog::SensitiveString::new(
|
||||
fs::read_to_string(&fallback_key_path).unwrap(),
|
||||
),
|
||||
},
|
||||
],
|
||||
host_trust_id: "trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
host_key_fingerprint: "key-1".into(),
|
||||
access: server_api::RepositoryAccessMode::ReadWrite,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repo-main".to_string(),
|
||||
@@ -3720,11 +3840,11 @@ mod tests {
|
||||
ssh_backed_binding
|
||||
.working_directory
|
||||
.evidence
|
||||
.credential_revision = Some(1);
|
||||
.public_key_fingerprint = Some("key-1".into());
|
||||
ssh_backed_binding
|
||||
.working_directory
|
||||
.evidence
|
||||
.host_trust_revision = Some(1);
|
||||
.host_key_fingerprint = Some("key-1".into());
|
||||
materializer.write_record(&ssh_backed_binding).unwrap();
|
||||
#[cfg(target_os = "linux")]
|
||||
fail_workdir_cleanup_once(&materializer, &id);
|
||||
@@ -3809,7 +3929,8 @@ mod tests {
|
||||
);
|
||||
let mut rotated = initial_materialization.clone();
|
||||
rotated.operation_id = "operation-agent-rotated".to_string();
|
||||
rotated.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 2;
|
||||
rotated.ssh.as_mut().unwrap().credential_candidates[0].public_key_fingerprint =
|
||||
"key-2".into();
|
||||
rotated.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadOnly;
|
||||
materializer
|
||||
.authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest {
|
||||
@@ -3819,8 +3940,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let rebound = materializer.bind_working_directory(&id, None).unwrap();
|
||||
assert_eq!(
|
||||
rebound.working_directory.evidence.credential_revision,
|
||||
Some(2)
|
||||
rebound.working_directory.evidence.public_key_fingerprint,
|
||||
Some("key-2".into())
|
||||
);
|
||||
let rebound_environment = rebound.command_environment();
|
||||
assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only");
|
||||
@@ -3927,7 +4048,8 @@ mod tests {
|
||||
|
||||
let mut read_write = rotated;
|
||||
read_write.operation_id = "operation-agent-read-write".to_string();
|
||||
read_write.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 3;
|
||||
read_write.ssh.as_mut().unwrap().credential_candidates[0].public_key_fingerprint =
|
||||
"key-3".into();
|
||||
read_write.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadWrite;
|
||||
let read_write_command_policy =
|
||||
RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap();
|
||||
@@ -4006,16 +4128,15 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-read-only".to_string(),
|
||||
config_revision: 2,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh: Some(crate::catalog::RepositorySshMaterializationAccess {
|
||||
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "key-1".into(),
|
||||
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
|
||||
}],
|
||||
host_trust_id: "trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
host_key_fingerprint: "key-1".into(),
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repo-main".to_string(),
|
||||
@@ -4176,7 +4297,6 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh: Some(access),
|
||||
});
|
||||
@@ -4205,7 +4325,6 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh,
|
||||
};
|
||||
@@ -4283,11 +4402,11 @@ mod tests {
|
||||
crate::catalog::RepositorySshMaterializationAccess {
|
||||
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
public_key_fingerprint: "key-1".into(),
|
||||
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
|
||||
}],
|
||||
host_trust_id: "trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
host_key_fingerprint: "key-1".into(),
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repo-main".to_string(),
|
||||
@@ -4321,7 +4440,6 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh: None,
|
||||
});
|
||||
@@ -4498,7 +4616,6 @@ mod tests {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 7,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
ssh,
|
||||
}
|
||||
@@ -4641,6 +4758,45 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn legacy_evidence_normalization_is_excluded_by_cleanup_and_cleanup_reads_do_not_rewrite() {
|
||||
let repo = create_clean_repo();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = RuntimeGitMaterializer::new(runtime_root.path());
|
||||
let binding = materializer.create(&request(repo.path())).unwrap();
|
||||
let id = &binding.working_directory.id;
|
||||
let path = binding
|
||||
.working_directory_root()
|
||||
.join(MATERIALIZATION_RECORD);
|
||||
let mut legacy: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
legacy["working_directory"]["evidence"]["repository_source_revision"] =
|
||||
serde_json::json!(7);
|
||||
let original = serde_json::to_vec_pretty(&legacy).unwrap();
|
||||
fs::write(&path, &original).unwrap();
|
||||
|
||||
let cleanup = materializer.occupancy.acquire_cleanup(id).unwrap();
|
||||
assert_eq!(
|
||||
materializer.read_binding(id).unwrap_err().code,
|
||||
"working_directory_cleanup_resource_busy"
|
||||
);
|
||||
materializer.read_binding_for_cleanup(id).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
drop(cleanup);
|
||||
|
||||
materializer.read_binding(id).unwrap();
|
||||
let canonical: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert!(
|
||||
canonical["working_directory"]["evidence"]
|
||||
.get("repository_source_revision")
|
||||
.is_none()
|
||||
);
|
||||
materializer.cleanup_working_directory(id).unwrap();
|
||||
assert!(!binding.working_directory_root().exists());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn removed_binding_cannot_activate_against_recreated_same_id() {
|
||||
|
||||
@@ -190,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,
|
||||
@@ -278,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)
|
||||
@@ -298,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)
|
||||
@@ -1211,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 {
|
||||
@@ -1218,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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,13 +68,14 @@ commands and generic POSIX operations are not exposed. The adapter uses the type
|
||||
an owner config API as a bypass.
|
||||
|
||||
Client-managed PATH validators are compared against freshly resolved Host
|
||||
metadata. Each mutation captures the ROOT validator, whole-tree revision/digest,
|
||||
metadata. Each mutation captures the ROOT validator, whole-tree content digest,
|
||||
canonical entrypoints and node state from that same observation. Backend resolves
|
||||
the live grant/link and validates canonical changes/CAS under its execution lock.
|
||||
No operation accepts LLM-supplied validators or `expected_digest` fields. Returned
|
||||
post-call path validators are refreshed from the exact committed revision, not
|
||||
substituted with root validators or later unrelated state. UI/Worker edits and
|
||||
same-bytes replacement revisions reject stale calls. Old descriptors cannot
|
||||
post-call path validators are refreshed from the exact committed content, not
|
||||
substituted with root validators or later unrelated state. Content changes and
|
||||
reattachment to a new connection lifetime reject stale calls, even when the new
|
||||
attachment exposes the same bytes. Old descriptors cannot
|
||||
bypass read-only restrictions, detach, revocation or Workspace boundaries.
|
||||
|
||||
Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct BackendJobExecutionBinding {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_revision: Option<String>,
|
||||
pub input_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subjektiv_consolidation: bool,
|
||||
}
|
||||
@@ -19,10 +19,10 @@ impl BackendJobExecutionBinding {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if [&self.job_id, &self.attempt_id]
|
||||
.into_iter()
|
||||
.chain(self.input_revision.iter())
|
||||
.chain(self.input_digest.iter())
|
||||
.any(|value| value.trim().is_empty() || value.chars().any(char::is_control))
|
||||
{
|
||||
return Err("Backend Job binding contains an invalid identity or input revision");
|
||||
return Err("Backend Job binding contains an invalid identity or input digest");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -164,6 +164,12 @@ impl WorkerHandle {
|
||||
))
|
||||
}
|
||||
|
||||
/// Hold through protocol subscription and initial snapshot capture so child
|
||||
/// events cannot overtake the parent's snapshot publication boundary.
|
||||
pub fn protocol_snapshot_publish_guard(&self) -> std::sync::MutexGuard<'_, ()> {
|
||||
self.spawned_registry.protocol_snapshot_publish_guard()
|
||||
}
|
||||
|
||||
pub fn snapshot_event(&self) -> Event {
|
||||
self.snapshot_event_with_entry_subscription().0
|
||||
}
|
||||
@@ -546,7 +552,7 @@ fn notification_coalesce_remaining<St: Store + Clone>(
|
||||
fn prepare_pending_run<St: Store + Clone>(
|
||||
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
|
||||
notify_buffer: &NotifyBuffer,
|
||||
fence: Option<(u64, &str)>,
|
||||
fence: Option<&str>,
|
||||
allow_notification_run: bool,
|
||||
) -> Result<Option<PendingRun>, crate::worker::PendingSubmissionError> {
|
||||
Ok(match pending_submissions.prepare_next_activation(fence)? {
|
||||
@@ -2619,10 +2625,24 @@ async fn controller_loop<C, St>(
|
||||
pending: pending_submissions.snapshot(),
|
||||
});
|
||||
}
|
||||
Method::CancelPendingSubmission {
|
||||
submission_id,
|
||||
expected_revision,
|
||||
} => match pending_submissions.cancel(&submission_id, expected_revision) {
|
||||
Method::CancelPendingSubmission { submission_id } => {
|
||||
match pending_submissions.cancel(&submission_id) {
|
||||
Ok(pending_snapshot) => {
|
||||
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
|
||||
pending: pending_snapshot,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = working_event_tx.send(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::ClearPendingSubmissions {
|
||||
expected_submission_ids,
|
||||
} => match pending_submissions.clear(&expected_submission_ids) {
|
||||
Ok(pending_snapshot) => {
|
||||
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
|
||||
pending: pending_snapshot,
|
||||
@@ -2635,25 +2655,7 @@ async fn controller_loop<C, St>(
|
||||
});
|
||||
}
|
||||
},
|
||||
Method::ClearPendingSubmissions { expected_revision } => {
|
||||
match pending_submissions.clear(expected_revision) {
|
||||
Ok(pending_snapshot) => {
|
||||
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
|
||||
pending: pending_snapshot,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = working_event_tx.send(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::ContinuePending {
|
||||
expected_revision,
|
||||
expected_head_id,
|
||||
} => {
|
||||
Method::ContinuePending { expected_head_id } => {
|
||||
if shared_state.catalog_status() != WorkerStatus::Idle {
|
||||
let _ = working_event_tx.send(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
@@ -2664,7 +2666,7 @@ async fn controller_loop<C, St>(
|
||||
match prepare_pending_run(
|
||||
&pending_submissions,
|
||||
¬ify_buffer,
|
||||
Some((expected_revision, &expected_head_id)),
|
||||
Some(&expected_head_id),
|
||||
true,
|
||||
) {
|
||||
Ok(Some(next)) => pending = Some(next),
|
||||
@@ -3023,8 +3025,9 @@ async fn controller_loop<C, St>(
|
||||
},
|
||||
|
||||
Method::Shutdown { command } => {
|
||||
// Shutdown ignores the state-revision fence but remains bound to the
|
||||
// current execution generation and command payload identity.
|
||||
// Shutdown applies to this controller at admission time, not
|
||||
// a caller-observed Run. Exact command-id/kind retries remain
|
||||
// valid so every shutdown path can reach the cleanup barrier.
|
||||
if let Err(disposition) = validate_shutdown_command(command, &shared_state) {
|
||||
acknowledge_command(
|
||||
&working_event_tx,
|
||||
@@ -3619,9 +3622,8 @@ where
|
||||
}
|
||||
Some(Method::CancelPendingSubmission {
|
||||
submission_id,
|
||||
expected_revision,
|
||||
}) => {
|
||||
match pending_submissions.cancel(&submission_id, expected_revision) {
|
||||
match pending_submissions.cancel(&submission_id) {
|
||||
Ok(pending) => {
|
||||
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending });
|
||||
}
|
||||
@@ -3633,8 +3635,8 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Method::ClearPendingSubmissions { expected_revision }) => {
|
||||
match pending_submissions.clear(expected_revision) {
|
||||
Some(Method::ClearPendingSubmissions { expected_submission_ids }) => {
|
||||
match pending_submissions.clear(&expected_submission_ids) {
|
||||
Ok(pending) => {
|
||||
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending });
|
||||
}
|
||||
@@ -4009,7 +4011,7 @@ mod tests {
|
||||
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
|
||||
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
|
||||
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
|
||||
"parent": null, "name": "", "kind": "folder", "revision": "1",
|
||||
"parent": null, "name": "", "kind": "folder", "last_mutation_id": "",
|
||||
"size": null, "content_type": null, "updated_by": "test",
|
||||
"updated_at": "2026-10-08T00:00:00Z",
|
||||
"latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1"
|
||||
|
||||
@@ -756,7 +756,7 @@ mod tests {
|
||||
workspace_id: Some("workspace".into()),
|
||||
worker_id: "worker".into(),
|
||||
session_id: "session".into(),
|
||||
session_revision: 3,
|
||||
|
||||
run_id: Some("run".into()),
|
||||
turn_index: Some(2),
|
||||
call_id: None,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! The tool intentionally accepts only the result value. Workspace and Worker
|
||||
//! identity come from the authenticated Workspace client, while Job, attempt,
|
||||
//! and input revision are resolved from the Backend-owned binding.
|
||||
//! and input digest are resolved from the Backend-owned binding.
|
||||
|
||||
use crate::feature::background::BackgroundTaskCancellation;
|
||||
use std::collections::HashMap;
|
||||
@@ -22,7 +22,7 @@ use crate::feature::{
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
const TOOL_NAME: &str = "SubmitBackendJobResult";
|
||||
const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input revision, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success.";
|
||||
const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input digest, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success.";
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -194,7 +194,7 @@ impl Tool for SubmitResultTool {
|
||||
let sealed = submission.sealed.as_mut().expect("sealed before dispatch");
|
||||
if definitive_rejection && !was_previously_sealed {
|
||||
// Only a definitively unaccepted request can be corrected. Reuse
|
||||
// a ready snapshot after a new preparation confirms its revision;
|
||||
// a ready snapshot after a new preparation confirms its Memory fingerprint;
|
||||
// a conflict invalidates even that snapshot. Failed outcomes are
|
||||
// rebuilt, since another generation can replace their marker.
|
||||
submission.reusable_surface = if response.status != 409 {
|
||||
|
||||
@@ -188,7 +188,8 @@ impl DriveFeature {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let read = self.backend.read(&args.entry, args.max_bytes).await?;
|
||||
if pinned.is_some_and(|entry| entry.revision != read.entry.revision) {
|
||||
if pinned.is_some_and(|entry| entry.last_mutation_id != read.entry.last_mutation_id)
|
||||
{
|
||||
return Err(DriveError::Conflict);
|
||||
}
|
||||
self.observe(read.entry.clone())?;
|
||||
@@ -260,7 +261,7 @@ impl DriveFeature {
|
||||
request_id,
|
||||
mutation: DriveMutation::UpdateText {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
expected_mutation_id: entry.last_mutation_id,
|
||||
text,
|
||||
content_type: entry.content_type.unwrap_or_else(default_media),
|
||||
},
|
||||
@@ -281,7 +282,7 @@ impl DriveFeature {
|
||||
.backend
|
||||
.read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32)
|
||||
.await?;
|
||||
if read.entry.revision != entry.revision {
|
||||
if read.entry.last_mutation_id != entry.last_mutation_id {
|
||||
return Err(DriveError::Conflict);
|
||||
}
|
||||
if read.truncated {
|
||||
@@ -297,7 +298,7 @@ impl DriveFeature {
|
||||
request_id,
|
||||
mutation: DriveMutation::UpdateText {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
expected_mutation_id: entry.last_mutation_id,
|
||||
text,
|
||||
content_type: entry.content_type.unwrap_or_else(default_media),
|
||||
},
|
||||
@@ -316,7 +317,7 @@ impl DriveFeature {
|
||||
request_id,
|
||||
mutation: DriveMutation::Relocate {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
expected_mutation_id: entry.last_mutation_id,
|
||||
parent: args.parent,
|
||||
name: args.name,
|
||||
},
|
||||
@@ -334,7 +335,7 @@ impl DriveFeature {
|
||||
request_id,
|
||||
mutation: DriveMutation::Delete {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
expected_mutation_id: entry.last_mutation_id,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
@@ -385,7 +386,7 @@ impl DriveFeature {
|
||||
parent_id: Some(args.parent.node_id),
|
||||
name: Some(args.name),
|
||||
id: None,
|
||||
expected_revision: None,
|
||||
expected_mutation_id: None,
|
||||
content_type: args.content_type,
|
||||
size: bytes.len() as u32,
|
||||
sha256: digest(&bytes),
|
||||
@@ -399,7 +400,7 @@ impl DriveFeature {
|
||||
}
|
||||
_ => return Err(DriveError::Invalid("unknown operation".into())),
|
||||
};
|
||||
// A successful mutation does not silently substitute its new revision
|
||||
// A successful mutation does not silently substitute its new observation
|
||||
// into a prior observation. The caller observes explicitly next time.
|
||||
if let Some(id) = mutated_id {
|
||||
self.observations
|
||||
@@ -640,13 +641,13 @@ pub(super) const SPECS: &[Spec] = &[
|
||||
Spec {
|
||||
name: "DriveMetadata",
|
||||
operation: "metadata",
|
||||
description: "Inspect one Workspace-bound stable entry ID and observe its revision for mutation.",
|
||||
description: "Inspect one Workspace-bound stable entry ID and retain its last committed request for mutation checks.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveList",
|
||||
operation: "list",
|
||||
description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or observe revisions for writes.",
|
||||
description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or establish mutation preconditions.",
|
||||
schema: schema::<ListArgs>,
|
||||
},
|
||||
Spec {
|
||||
@@ -658,7 +659,7 @@ pub(super) const SPECS: &[Spec] = &[
|
||||
Spec {
|
||||
name: "DriveRead",
|
||||
operation: "read",
|
||||
description: "Read up to 64 KiB UTF-8 text and observe entry revision. Past Session text remains an immutable observation.",
|
||||
description: "Read up to 64 KiB UTF-8 text and retain the entry’s last committed request. Past Session text remains an immutable observation.",
|
||||
schema: schema::<ReadArgs>,
|
||||
},
|
||||
Spec {
|
||||
@@ -676,31 +677,31 @@ pub(super) const SPECS: &[Spec] = &[
|
||||
Spec {
|
||||
name: "DriveWrite",
|
||||
operation: "write",
|
||||
description: "Replace <=64 KiB text using a previously read/metadata-observed revision. Conflicts are not retried with latest revision.",
|
||||
description: "Replace <=64 KiB text using a previously observed committed request. Conflicts are not retried with a newer observation.",
|
||||
schema: schema::<WriteArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveEdit",
|
||||
operation: "edit",
|
||||
description: "Partial text edit using previously observed revision and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.",
|
||||
description: "Partial text edit using previously observed committed request and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.",
|
||||
schema: schema::<EditArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveRelocate",
|
||||
operation: "relocate",
|
||||
description: "Rename and/or move an observed entry with revision precondition; stable ID and URL remain unchanged.",
|
||||
description: "Rename and/or move an observed entry only if its last committed request still matches the observation; stable ID and URL remain unchanged.",
|
||||
schema: schema::<RelocateArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveDelete",
|
||||
operation: "delete",
|
||||
description: "Delete one observed entry with revision precondition. A recreated name has a different ID; stale observations cannot target it.",
|
||||
description: "Delete one observed entry only if its last committed request still matches the observation. A recreated name has a different ID; stale observations cannot target it.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveViewImage",
|
||||
operation: "view_image",
|
||||
description: "Attach revision-fixed PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.",
|
||||
description: "Attach committed-request-bound PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
|
||||
@@ -22,7 +22,9 @@ pub enum DriveError {
|
||||
Denied,
|
||||
#[error("Drive resource not found")]
|
||||
NotFound,
|
||||
#[error("Drive revision or name conflict; observe explicitly before another operation")]
|
||||
#[error(
|
||||
"Drive content, location, or name conflict; observe explicitly before another operation"
|
||||
)]
|
||||
Conflict,
|
||||
#[error("Invalid Drive arguments: {0}")]
|
||||
Invalid(String),
|
||||
@@ -54,7 +56,11 @@ impl DriveBackend {
|
||||
if let Some(parent) = &entry.parent {
|
||||
self.validate_ref(parent)?;
|
||||
}
|
||||
if !decimal(&entry.revision) || entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES) {
|
||||
if (entry.last_mutation_id.len() > 128
|
||||
|| entry.last_mutation_id.chars().any(char::is_control)
|
||||
|| (entry.parent.is_some() && entry.last_mutation_id.is_empty()))
|
||||
|| entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES)
|
||||
{
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(())
|
||||
@@ -238,14 +244,14 @@ impl DriveBackend {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(total as usize);
|
||||
// Even an empty file gets a revision-fixed read and current authorization.
|
||||
// Even an empty file gets a read bound to the observed committed mutation and current authorization.
|
||||
loop {
|
||||
let offset = bytes.len() as u32;
|
||||
let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES);
|
||||
let query = query_string(&DriveReadChunkQuery {
|
||||
entry_workspace_id: entry.entry.workspace_id.clone(),
|
||||
id: entry.entry.node_id.clone(),
|
||||
expected_revision: entry.revision.clone(),
|
||||
expected_mutation_id: entry.last_mutation_id.clone(),
|
||||
offset,
|
||||
length,
|
||||
})?;
|
||||
|
||||
@@ -144,9 +144,8 @@ impl WorkspaceClient for InternalChildWorkspaceClient {
|
||||
|
||||
fn current_prompt_projection(
|
||||
&self,
|
||||
minimum_revision: Option<u64>,
|
||||
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||
self.inner.current_prompt_projection(minimum_revision)
|
||||
self.inner.current_prompt_projection()
|
||||
}
|
||||
|
||||
fn list_workspace_workers(
|
||||
@@ -181,7 +180,7 @@ mod tests {
|
||||
enum Call {
|
||||
Json(WorkspaceRequest, Option<Duration>),
|
||||
Binary(WorkspaceBinaryRequest, Option<Duration>),
|
||||
Projection(Option<u64>),
|
||||
Projection,
|
||||
Discovery(WorkspaceWorkerDiscoveryRequest),
|
||||
Remove(String, String, String),
|
||||
Server,
|
||||
@@ -250,9 +249,8 @@ mod tests {
|
||||
}
|
||||
fn current_prompt_projection(
|
||||
&self,
|
||||
revision: Option<u64>,
|
||||
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(Call::Projection(revision));
|
||||
self.calls.lock().unwrap().push(Call::Projection);
|
||||
Err(WorkspaceClientError::Request("projection marker".into()))
|
||||
}
|
||||
fn list_workspace_workers(
|
||||
@@ -390,7 +388,7 @@ mod tests {
|
||||
expected_bytes
|
||||
);
|
||||
assert!(
|
||||
matches!(client.current_prompt_projection(Some(42)), Err(WorkspaceClientError::Request(message)) if message == "projection marker")
|
||||
matches!(client.current_prompt_projection(), Err(WorkspaceClientError::Request(message)) if message == "projection marker")
|
||||
);
|
||||
let discovery = WorkspaceWorkerDiscoveryRequest {
|
||||
cursor: Some("next".into()),
|
||||
@@ -422,7 +420,7 @@ mod tests {
|
||||
Call::Json(request, Some(timeout)),
|
||||
Call::Binary(bytes.clone(), None),
|
||||
Call::Binary(bytes, Some(timeout)),
|
||||
Call::Projection(Some(42)),
|
||||
Call::Projection,
|
||||
Call::Discovery(discovery),
|
||||
Call::Remove("runtime".into(), "worker".into(), "reason".into()),
|
||||
Call::Server,
|
||||
|
||||
@@ -44,7 +44,7 @@ fn response(value: impl Serialize) -> WorkspaceResponse {
|
||||
body: serde_json::to_string(&value).unwrap(),
|
||||
}
|
||||
}
|
||||
fn entry(id: &str, revision: &str) -> DriveEntry {
|
||||
fn entry(id: &str, last_mutation_id: &str) -> DriveEntry {
|
||||
DriveEntry {
|
||||
entry: DriveEntryRef {
|
||||
workspace_id: "workspace".into(),
|
||||
@@ -56,7 +56,7 @@ fn entry(id: &str, revision: &str) -> DriveEntry {
|
||||
}),
|
||||
name: "note.md".into(),
|
||||
kind: DriveEntryKind::File,
|
||||
revision: revision.into(),
|
||||
last_mutation_id: last_mutation_id.into(),
|
||||
size: Some(5),
|
||||
content_type: Some("text/markdown".into()),
|
||||
updated_by: "worker".into(),
|
||||
@@ -83,7 +83,7 @@ async fn execute(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions() {
|
||||
fn drive_feature_activation_never_creates_authority_and_schemas_hide_last_mutation_ids() {
|
||||
let client = Client::new(Vec::new());
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
assert!(DriveFeature::configured(client.clone(), false, router.clone()).is_none());
|
||||
@@ -100,7 +100,7 @@ fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions()
|
||||
let (meta, _) = definition();
|
||||
assert_eq!(name, meta.name);
|
||||
let schema = meta.input_schema.to_string();
|
||||
assert!(!schema.contains("expected_revision"), "{name}");
|
||||
assert!(!schema.contains("expected_mutation_id"), "{name}");
|
||||
assert!(!schema.contains("grant_id"), "{name}");
|
||||
}
|
||||
assert_eq!(feature.descriptor().instructions.len(), 1);
|
||||
@@ -139,7 +139,7 @@ async fn foreign_or_unobserved_entry_cannot_dispatch_mutation() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_write() {
|
||||
async fn stale_edit_returns_conflict_without_upgrading_last_mutation_id_or_dispatching_write() {
|
||||
let old = entry("2", "1");
|
||||
let current = entry("2", "2");
|
||||
let client = Client::new(vec![response(DriveReadTextResponse {
|
||||
@@ -157,7 +157,10 @@ async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_w
|
||||
.await;
|
||||
assert!(matches!(result, Err(DriveError::Conflict)));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(feature.observed(&old.entry, None).unwrap().revision, "1");
|
||||
assert_eq!(
|
||||
feature.observed(&old.entry, None).unwrap().last_mutation_id,
|
||||
"1"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -192,8 +195,8 @@ async fn shared_edit_rules_reject_ambiguous_and_truncated_preimages_without_muta
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_explicit_observation()
|
||||
{
|
||||
async fn successful_mutation_uses_observed_committed_request_id_and_requires_next_explicit_observation()
|
||||
{
|
||||
let old = entry("9007199254740993", "9007199254740993");
|
||||
let updated = entry("9007199254740993", "9007199254740994");
|
||||
let client = Client::new(Vec::new());
|
||||
@@ -220,7 +223,7 @@ async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_ex
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["mutation"]["expected_revision"], "9007199254740993");
|
||||
assert_eq!(body["mutation"]["expected_mutation_id"], "9007199254740993");
|
||||
assert_eq!(body["mutation"]["id"]["node_id"], "9007199254740993");
|
||||
drop(requests);
|
||||
assert!(feature.observed(&old.entry, None).is_err());
|
||||
@@ -296,7 +299,7 @@ async fn uncommitted_receipt_snapshot_does_not_turn_unknown_into_failure_or_retr
|
||||
|
||||
fn invalid_completions(id: &str) -> Vec<DriveMutationResponse> {
|
||||
let mut invalid_entry = entry("2", "2");
|
||||
invalid_entry.revision = "not-a-revision".into();
|
||||
invalid_entry.last_mutation_id = "\ninvalid-request-id".into();
|
||||
vec![
|
||||
DriveMutationResponse {
|
||||
request_id: "another-request".into(),
|
||||
@@ -340,7 +343,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.value["request_id"], id);
|
||||
assert_eq!(output.value["entry"]["metadata"]["revision"], "2");
|
||||
assert_eq!(output.value["entry"]["metadata"]["last_mutation_id"], "2");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
@@ -354,7 +357,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese
|
||||
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["request_id"], id);
|
||||
assert_eq!(body["mutation"]["expected_revision"], "1");
|
||||
assert_eq!(body["mutation"]["expected_mutation_id"], "1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +502,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
!operations
|
||||
.iter()
|
||||
.flat_map(|op| &op.parameters)
|
||||
.any(|parameter| parameter.name == "expected_revision")
|
||||
.any(|parameter| parameter.name == "expected_mutation_id")
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
@@ -556,7 +559,7 @@ fn chunk(bytes: Vec<u8>) -> crate::worker::WorkspaceBinaryResponse {
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_content() {
|
||||
async fn multi_chunk_image_uses_one_last_mutation_id_and_never_exposes_binary_in_text_content() {
|
||||
let mut metadata = entry("2", "9007199254740993");
|
||||
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
bytes.resize(DRIVE_CHUNK_MAX_BYTES as usize + 13, 42);
|
||||
@@ -578,7 +581,11 @@ async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_co
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in &*requests {
|
||||
assert!(request.path.contains("expected_revision=9007199254740993"));
|
||||
assert!(
|
||||
request
|
||||
.path
|
||||
.contains("expected_mutation_id=9007199254740993")
|
||||
);
|
||||
assert_eq!(request.max_response_bytes, DRIVE_CHUNK_MAX_BYTES as usize);
|
||||
}
|
||||
assert!(requests[0].path.split('&').any(|pair| pair == "offset=0"));
|
||||
@@ -590,7 +597,7 @@ async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_co
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_retrying() {
|
||||
async fn expired_last_mutation_id_or_partial_chunk_aborts_image_without_rebinding_or_retrying() {
|
||||
for next in [
|
||||
crate::worker::WorkspaceBinaryResponse {
|
||||
status: 409,
|
||||
@@ -618,12 +625,12 @@ async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_ret
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| request.path.contains("expected_revision=3&"))
|
||||
.all(|request| request.path.contains("expected_mutation_id=3&"))
|
||||
);
|
||||
assert_eq!(
|
||||
client.json.requests.lock().unwrap().len(),
|
||||
1,
|
||||
"must not fetch a new revision"
|
||||
"must not fetch a new last_mutation_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -642,7 +649,7 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
|
||||
execute(
|
||||
&feature,
|
||||
"DriveRead",
|
||||
json!({"entry":metadata.entry,"expected_revision":"1"})
|
||||
json!({"entry":metadata.entry,"expected_mutation_id":"1"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Native non-indexable Drive entries. Publication resolves just one ID through
|
||||
//! current Backend authorization; the Client owns identity/revision validators.
|
||||
//! current Backend authorization; the Client owns entry/committed-request validators.
|
||||
use super::{
|
||||
DriveFeature, SPECS, Spec,
|
||||
backend::{DriveError, decimal},
|
||||
@@ -101,7 +101,7 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
};
|
||||
let descriptor = descriptor(&specs, root);
|
||||
let reference = contextual_reference(INTERFACE, path);
|
||||
let validator = entry.revision.as_bytes().to_vec();
|
||||
let validator = entry.last_mutation_id.as_bytes().to_vec();
|
||||
let object = Object {
|
||||
name: if root {
|
||||
"drive".into()
|
||||
@@ -183,7 +183,7 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
|
||||
}
|
||||
}
|
||||
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client manage revisions; never supply validators or retry conflicts/outcome-unknown with a new revision. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
|
||||
@@ -230,7 +230,7 @@ impl WipOperationHandler for DriveHandler {
|
||||
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
|
||||
{
|
||||
return Err(map_error(DriveError::Invalid(
|
||||
"unrecognized arguments; revision metadata is Client-managed".into(),
|
||||
"unrecognized arguments; committed-request observations are Client-managed".into(),
|
||||
)));
|
||||
}
|
||||
let mut input = wip_to_json(&Value::Record(arguments.clone()))
|
||||
@@ -296,12 +296,14 @@ impl WipOperationHandler for DriveHandler {
|
||||
// Attachments use the existing ToolOutput side channel and durable Engine
|
||||
// capture, not transient request injection or URL-only success.
|
||||
let result = if matches!(operation, "write" | "edit" | "relocate") {
|
||||
let revision = output.value["entry"]["metadata"]["revision"]
|
||||
let last_mutation_id = output.value["entry"]["metadata"]["last_mutation_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
WipOperationError::OutcomeUnknown("Committed Drive revision unavailable".into())
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"Committed Drive request identity unavailable".into(),
|
||||
)
|
||||
})?;
|
||||
WipOperationOutput::native_with_validator(value, revision.as_bytes().to_vec())
|
||||
WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec())
|
||||
} else {
|
||||
WipOperationOutput::native(value)
|
||||
};
|
||||
|
||||
@@ -557,7 +557,7 @@ where
|
||||
input,
|
||||
cache_key: Some(format!(
|
||||
"flow:{}:{}",
|
||||
attempt.instance_id, attempt.checked_state_revision
|
||||
attempt.instance_id, attempt.attempt_id
|
||||
)),
|
||||
max_turns: Some(12),
|
||||
engine_configurator: None,
|
||||
@@ -653,9 +653,7 @@ mod tests {
|
||||
FlowTransitionAttempt {
|
||||
attempt_id: id.to_string(),
|
||||
instance_id: "instance-1".to_string(),
|
||||
definition_revision: 1,
|
||||
definition_digest: "sha256:test".to_string(),
|
||||
checked_state_revision: 0,
|
||||
from_state: StateId::new("work").unwrap(),
|
||||
reason: "ready".to_string(),
|
||||
transitions: vec![
|
||||
@@ -711,7 +709,6 @@ mod tests {
|
||||
selector: "workspace:test-flow".parse().unwrap(),
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
flow_id: "flow-1".to_string(),
|
||||
revision: 1,
|
||||
content_digest: definition.content_digest.clone(),
|
||||
definition,
|
||||
},
|
||||
|
||||
@@ -419,7 +419,7 @@ impl WipOperationHandler for CollectionHandler {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(self.domain.route());
|
||||
if matches!(self.domain, Domain::Workdir | Domain::Attachment) {
|
||||
// The complete-set revision is computed by the existing Backend
|
||||
// The complete-set digest is computed by the existing Backend
|
||||
// registry/ledger in the same snapshot as a bounded page. Never infer
|
||||
// collection freshness from just the first page's items.
|
||||
let path = if matches!(self.domain, Domain::Workdir) {
|
||||
@@ -431,13 +431,13 @@ impl WipOperationHandler for CollectionHandler {
|
||||
.provider
|
||||
.request(path)
|
||||
.ok()
|
||||
.and_then(|p| p.get("revision").and_then(Json::as_str).map(str::to_owned))
|
||||
.and_then(|p| p.get("digest").and_then(Json::as_str).map(str::to_owned))
|
||||
{
|
||||
Some(revision) => {
|
||||
Some(inventory_digest) => {
|
||||
digest.update([0]);
|
||||
digest.update(revision);
|
||||
digest.update(inventory_digest);
|
||||
}
|
||||
None => digest.update(b"inventory-revision-unavailable"),
|
||||
None => digest.update(b"inventory-digest-unavailable"),
|
||||
}
|
||||
return Some(digest.finalize().to_vec());
|
||||
}
|
||||
@@ -533,9 +533,9 @@ impl WipOperationHandler for CollectionHandler {
|
||||
"invalid bounded inventory page".into(),
|
||||
)
|
||||
})?;
|
||||
if page.get("revision").and_then(Json::as_str).is_none() {
|
||||
if page.get("digest").and_then(Json::as_str).is_none() {
|
||||
return Err(WipOperationError::OutcomeUnknown(
|
||||
"inventory page revision missing".into(),
|
||||
"inventory page digest missing".into(),
|
||||
));
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
@@ -1162,7 +1162,7 @@ pub(crate) mod tests {
|
||||
let cursor = query.get("cursor").copied();
|
||||
let mut all: Vec<_> = state.workdirs.iter().collect();
|
||||
all.sort_by_key(|v| v["working_directory_id"].as_str().unwrap());
|
||||
let revision = Sha256::digest(serde_json::to_vec(&all).unwrap())
|
||||
let digest = Sha256::digest(serde_json::to_vec(&all).unwrap())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>();
|
||||
@@ -1179,7 +1179,7 @@ pub(crate) mod tests {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
json!({"items":items, "next_cursor":next, "revision":revision})
|
||||
json!({"items":items, "next_cursor":next, "digest":digest})
|
||||
}
|
||||
(WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => {
|
||||
let query: BTreeMap<_, _> = path
|
||||
@@ -1191,12 +1191,12 @@ pub(crate) mod tests {
|
||||
.collect();
|
||||
let mut all: Vec<_> = state.attachments.iter().collect();
|
||||
all.sort_by_key(|v| v["alias"].as_str().unwrap());
|
||||
let revision = Sha256::digest(serde_json::to_vec(&all).unwrap())
|
||||
let digest = Sha256::digest(serde_json::to_vec(&all).unwrap())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>();
|
||||
if let Some(expected) = query.get("connection_id") {
|
||||
json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::<Vec<_>>(), "next_offset":null, "revision":revision})
|
||||
json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::<Vec<_>>(), "next_offset":null, "digest":digest})
|
||||
} else {
|
||||
let limit = query
|
||||
.get("limit")
|
||||
@@ -1209,7 +1209,7 @@ pub(crate) mod tests {
|
||||
let count = all.len();
|
||||
let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect();
|
||||
let next = (offset + limit < count).then_some(offset + limit);
|
||||
json!({"items":items, "next_offset":next, "revision":revision})
|
||||
json!({"items":items, "next_offset":next, "digest":digest})
|
||||
}
|
||||
}
|
||||
(WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
||||
|
||||
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(test)]
|
||||
use std::time::Duration;
|
||||
@@ -335,21 +334,21 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
|
||||
[
|
||||
(
|
||||
"SubjektivMemoryQuery",
|
||||
"Search current Memory revisions for the delegated subject.",
|
||||
"Search current Memory changes for the delegated subject.",
|
||||
schema_for::<server_api::SubjektivMemoryQueryRequest>(),
|
||||
Operation::Query,
|
||||
),
|
||||
(
|
||||
"SubjektivMemoryRead",
|
||||
"Read one current or historical Memory revision for the delegated subject.",
|
||||
"Read one current or historical Memory change for the delegated subject.",
|
||||
schema_for::<server_api::SubjektivMemoryReadRequest>(),
|
||||
Operation::Read,
|
||||
),
|
||||
(
|
||||
"SubjektivMemoryListRevisions",
|
||||
"List immutable revisions of one Memory for the delegated subject.",
|
||||
schema_for::<server_api::SubjektivMemoryListRevisionsRequest>(),
|
||||
Operation::ListRevisions,
|
||||
"SubjektivMemoryListChanges",
|
||||
"List immutable changes of one Memory for the delegated subject.",
|
||||
schema_for::<server_api::SubjektivMemoryListChangesRequest>(),
|
||||
Operation::ListChanges,
|
||||
),
|
||||
(
|
||||
"MemoryStagingList",
|
||||
@@ -359,13 +358,13 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
|
||||
),
|
||||
(
|
||||
"MemoryStagingRead",
|
||||
"Read one pending candidate, including typed revision proposal metadata and provenance.",
|
||||
"Read one pending candidate, including typed change proposal metadata and provenance.",
|
||||
schema_for::<server_api::SubjektivMemoryCandidateReadRequest>(),
|
||||
Operation::ReadCandidate,
|
||||
),
|
||||
(
|
||||
"MemoryApplyCandidate",
|
||||
"Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact revision proposal metadata and returns concrete affected revision refs.",
|
||||
"Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact change proposal metadata and returns concrete affected change refs.",
|
||||
schema_for::<server_api::SubjektivMemoryCandidateDecisionRequest>(),
|
||||
Operation::DecideCandidate,
|
||||
),
|
||||
@@ -392,7 +391,7 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
|
||||
enum SubjectConsolidationOperation {
|
||||
Query,
|
||||
Read,
|
||||
ListRevisions,
|
||||
ListChanges,
|
||||
ListCandidates,
|
||||
ReadCandidate,
|
||||
DecideCandidate,
|
||||
@@ -418,8 +417,8 @@ impl Tool for SubjectConsolidationTool {
|
||||
Operation::Read => {
|
||||
server_api::SubjektivMemoryBackendOperation::Read(parse_input(input_json)?)
|
||||
}
|
||||
Operation::ListRevisions => {
|
||||
server_api::SubjektivMemoryBackendOperation::ListRevisions(parse_input(input_json)?)
|
||||
Operation::ListChanges => {
|
||||
server_api::SubjektivMemoryBackendOperation::ListChanges(parse_input(input_json)?)
|
||||
}
|
||||
Operation::ListCandidates => {
|
||||
server_api::SubjektivMemoryBackendOperation::ListCandidates(parse_input(
|
||||
@@ -452,12 +451,12 @@ impl Tool for SubjectConsolidationTool {
|
||||
}
|
||||
server_api::SubjektivMemoryBackendResponse::Read(value) => {
|
||||
format!(
|
||||
"Read Memory {} revision {}.",
|
||||
value.memory_id, value.revision
|
||||
"Read Memory {} change {}.",
|
||||
value.memory_id, value.change_id
|
||||
)
|
||||
}
|
||||
server_api::SubjektivMemoryBackendResponse::ListRevisions(value) => {
|
||||
format!("Listed {} revision(s).", value.items.len())
|
||||
server_api::SubjektivMemoryBackendResponse::ListChanges(value) => {
|
||||
format!("Listed {} change(s).", value.items.len())
|
||||
}
|
||||
server_api::SubjektivMemoryBackendResponse::Candidates(value) => {
|
||||
format!("Listed {} pending candidate(s).", value.items.len())
|
||||
@@ -640,8 +639,7 @@ struct WorkspaceResidentSummarySource {
|
||||
|
||||
#[derive(Default)]
|
||||
struct SubjectBehaviorRefreshState {
|
||||
applied: Option<(u64, String)>,
|
||||
pending: HashMap<u64, String>,
|
||||
applied: Option<String>,
|
||||
}
|
||||
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
@@ -727,8 +725,7 @@ impl WorkspaceSubjektivResidentSummarySource {
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned");
|
||||
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
|
||||
state.pending.remove(&output.behavior_revision);
|
||||
state.applied = Some(output.behavior_md.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,33 +766,22 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource
|
||||
.expect("subject behavior state poisoned")
|
||||
.applied
|
||||
.as_ref()
|
||||
.is_none_or(|(revision, body)| {
|
||||
*revision != output.behavior_revision || body != &output.behavior_md
|
||||
});
|
||||
.is_none_or(|body| body != &output.behavior_md);
|
||||
if !changed {
|
||||
return Ok(None);
|
||||
}
|
||||
let rendered = self.render(&output)?;
|
||||
let revision = output.behavior_revision;
|
||||
self.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.pending
|
||||
.insert(revision, output.behavior_md);
|
||||
Ok(Some(ResidentContextRefresh {
|
||||
body: rendered,
|
||||
revision,
|
||||
behavior_md: output.behavior_md,
|
||||
}))
|
||||
}
|
||||
|
||||
fn confirm_resident_context_revision(&self, revision: u64) {
|
||||
let mut state = self
|
||||
.behavior_state
|
||||
fn confirm_resident_context_behavior(&self, behavior_md: &str) {
|
||||
self.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned");
|
||||
if let Some(body) = state.pending.remove(&revision) {
|
||||
state.applied = Some((revision, body));
|
||||
}
|
||||
.expect("subject behavior state poisoned")
|
||||
.applied = Some(behavior_md.to_owned());
|
||||
}
|
||||
|
||||
fn invalidate_resident_context_representation(&self) {
|
||||
@@ -942,7 +928,7 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
|
||||
"Subject behavior refresh requires durable session append authority",
|
||||
)
|
||||
})?;
|
||||
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
|
||||
system_items.append_subject_behavior_refresh(refresh.body, refresh.behavior_md);
|
||||
}
|
||||
Ok(HookPreRequestAction::Continue)
|
||||
}
|
||||
@@ -971,7 +957,7 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
|
||||
body,
|
||||
session_store::PromptRenderProvenance {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
config_revision: projection.config_revision,
|
||||
|
||||
source_digest: projection.source_digest.clone(),
|
||||
projection_digest: projection.catalog_digest.clone(),
|
||||
logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(),
|
||||
@@ -1270,7 +1256,7 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||
content: Option<String>,
|
||||
scope_allowed: bool,
|
||||
behavior: Mutex<(u64, String)>,
|
||||
behavior: Mutex<String>,
|
||||
paths: Mutex<Vec<String>>,
|
||||
timeouts: Mutex<Vec<Duration>>,
|
||||
}
|
||||
@@ -1284,7 +1270,7 @@ mod tests {
|
||||
availability,
|
||||
content: content.map(str::to_string),
|
||||
scope_allowed: true,
|
||||
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
||||
behavior: Mutex::new("Be deliberate.".to_string()),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
@@ -1295,7 +1281,7 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some("must not be injected".into()),
|
||||
scope_allowed: false,
|
||||
behavior: Mutex::new((3, "must not be injected".to_string())),
|
||||
behavior: Mutex::new("must not be injected".to_string()),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
@@ -1333,14 +1319,13 @@ mod tests {
|
||||
body: "subject scope unavailable".into(),
|
||||
});
|
||||
}
|
||||
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
|
||||
let behavior_md = self.behavior.lock().unwrap().clone();
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md,
|
||||
behavior_revision,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
@@ -1483,7 +1468,7 @@ mod tests {
|
||||
enabled
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace".to_string(),
|
||||
settings_revision: 1,
|
||||
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1543,7 +1528,7 @@ mod tests {
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace".into(),
|
||||
settings_revision: 1,
|
||||
|
||||
language: "English".into(),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1586,7 +1571,7 @@ mod tests {
|
||||
config
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace".to_string(),
|
||||
settings_revision: 1,
|
||||
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1643,7 +1628,7 @@ permission = "write"
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
settings_revision: 1,
|
||||
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1712,7 +1697,7 @@ permission = "write"
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
|
||||
async fn subject_resident_source_emits_only_changed_behavior() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
@@ -1732,15 +1717,15 @@ permission = "write"
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
|
||||
*client.behavior.lock().unwrap() = "Ask when uncertain.".to_string();
|
||||
let refresh = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("changed behavior must produce one refresh");
|
||||
assert_eq!(refresh.revision, 4);
|
||||
assert_eq!(refresh.behavior_md, "Ask when uncertain.");
|
||||
assert!(refresh.body.contains("Ask when uncertain."));
|
||||
source.confirm_resident_context_revision(refresh.revision);
|
||||
source.confirm_resident_context_behavior(&refresh.behavior_md);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
@@ -1749,15 +1734,15 @@ permission = "write"
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (5, String::new());
|
||||
*client.behavior.lock().unwrap() = String::new();
|
||||
let cleared = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("cleared behavior must produce one refresh");
|
||||
assert_eq!(cleared.revision, 5);
|
||||
assert_eq!(cleared.behavior_md, "");
|
||||
assert!(cleared.body.contains("No user-managed behavior is set"));
|
||||
source.confirm_resident_context_revision(cleared.revision);
|
||||
source.confirm_resident_context_behavior(&cleared.behavior_md);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
@@ -1772,7 +1757,7 @@ permission = "write"
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("history rewrite must require a durable current representation");
|
||||
assert_eq!(replay_after_rewrite.revision, 5);
|
||||
assert_eq!(replay_after_rewrite.behavior_md, "");
|
||||
assert!(
|
||||
replay_after_rewrite
|
||||
.body
|
||||
@@ -1786,6 +1771,63 @@ permission = "write"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resident_behavior_ack_tracks_exact_committed_content_not_latest_fetch() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
None,
|
||||
));
|
||||
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
source.load().await;
|
||||
*client.behavior.lock().unwrap() = "first behavior".into();
|
||||
let first = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let retry = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(retry, first); // Fetch alone never acknowledges model representation.
|
||||
*client.behavior.lock().unwrap() = "second behavior".into();
|
||||
let second = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
source.confirm_resident_context_behavior(&first.behavior_md);
|
||||
assert_eq!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
second
|
||||
);
|
||||
source.confirm_resident_context_behavior(&second.behavior_md);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
source.invalidate_resident_context_representation();
|
||||
assert_eq!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
second
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
@@ -2001,14 +2043,14 @@ permission = "write"
|
||||
"MemoryApplyCandidate",
|
||||
"MemoryStagingList",
|
||||
"MemoryStagingRead",
|
||||
"SubjektivMemoryListRevisions",
|
||||
"SubjektivMemoryListChanges",
|
||||
"SubjektivMemoryQuery",
|
||||
"SubjektivMemoryRead",
|
||||
]
|
||||
);
|
||||
let decision = tool_meta(tools, "MemoryApplyCandidate").to_string();
|
||||
assert!(decision.contains("request_id"));
|
||||
assert!(decision.contains("expected_revision"));
|
||||
assert!(decision.contains("expected_change_id"));
|
||||
assert!(decision.contains("already_covered"));
|
||||
assert!(!decision.contains("subject_id"));
|
||||
assert!(!decision.contains("workspace_id"));
|
||||
|
||||
@@ -972,7 +972,7 @@ impl WorkerAuditBase {
|
||||
) -> Self {
|
||||
self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit {
|
||||
workspace_id: snapshot.workspace_id.clone(),
|
||||
settings_revision: snapshot.settings_revision,
|
||||
|
||||
language: snapshot.language.clone(),
|
||||
});
|
||||
self
|
||||
@@ -1292,7 +1292,7 @@ permission = "write"
|
||||
config
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 1,
|
||||
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1365,7 +1365,7 @@ permission = "write"
|
||||
.start_run_committed(HookInvocationContext {
|
||||
worker_id: "worker-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
session_revision: 1,
|
||||
|
||||
run_id: Some("run-1".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -1456,7 +1456,7 @@ permission = "write"
|
||||
config
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 1,
|
||||
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -2002,7 +2002,7 @@ permission = "write"
|
||||
CommittedSessionCapture {
|
||||
session_id: "session-1".to_string(),
|
||||
segment_id: "segment-1".to_string(),
|
||||
session_revision: history_len.try_into().unwrap(),
|
||||
|
||||
entry_count: history_len,
|
||||
run_exit: CommittedRunExit::Finished,
|
||||
history: (0..history_len)
|
||||
|
||||
@@ -386,7 +386,7 @@ fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin
|
||||
worker_id: None,
|
||||
flow_selector: None,
|
||||
flow_definition_id: None,
|
||||
flow_definition_revision: None,
|
||||
flow_definition_fingerprint: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,18 +161,23 @@ impl SubjektivSurfaceLifecycleFeature {
|
||||
|
||||
impl FeatureModule for SubjektivSurfaceLifecycleFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("subjektiv-memory-surface-lifecycle", "subjektiv Memory Surface Lifecycle")
|
||||
.with_description("Rebuilds one bounded subject Memory surface after each committed consolidation run.")
|
||||
FeatureDescriptor::builtin(
|
||||
"subjektiv-memory-surface-lifecycle",
|
||||
"subjektiv Memory Surface Lifecycle",
|
||||
)
|
||||
.with_description(
|
||||
"Rebuilds one bounded subject Memory surface after each committed consolidation run.",
|
||||
)
|
||||
.with_background_task(BackgroundTaskDeclaration::worker_managed(
|
||||
TASK_NAME,
|
||||
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
|
||||
"Generate and publish a current subject Memory surface from bounded confirmed changes.",
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let declaration = BackgroundTaskDeclaration::worker_managed(
|
||||
TASK_NAME,
|
||||
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
|
||||
"Generate and publish a current subject Memory surface from bounded confirmed changes.",
|
||||
);
|
||||
let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
|
||||
spec.trigger = BackgroundTaskTrigger::RunCommitted;
|
||||
@@ -240,16 +245,17 @@ impl SubjektivSurfaceLifecycleTask {
|
||||
if let Some(snapshot_id) = &generation.current_snapshot_id {
|
||||
return Ok(serde_json::json!({
|
||||
"availability": "ready", "generation_id": generation.generation_id,
|
||||
"store_revision": generation.store_revision, "snapshot_id": snapshot_id,
|
||||
"memory_fingerprint": generation.memory_fingerprint, "snapshot_id": snapshot_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Preparation is Backend-fenced to the live attempt and immutable batch.
|
||||
// Only a ready snapshot is immutable within its store revision. Never
|
||||
// Only a ready snapshot is immutable within its Memory fingerprint. Never
|
||||
// reuse a failed marker, which a later generation can replace.
|
||||
if let Some(surface) = reusable_surface.filter(|surface| {
|
||||
surface["availability"] == "ready"
|
||||
&& surface["store_revision"].as_u64() == Some(generation.store_revision)
|
||||
&& surface["memory_fingerprint"].as_str()
|
||||
== Some(generation.memory_fingerprint.as_str())
|
||||
}) {
|
||||
return Ok(surface.clone());
|
||||
}
|
||||
@@ -307,7 +313,7 @@ impl SubjektivSurfaceLifecycleTask {
|
||||
Ok(output) => {
|
||||
return Ok(serde_json::json!({
|
||||
"availability": "ready", "generation_id": generation.generation_id,
|
||||
"store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id,
|
||||
"memory_fingerprint": output.built_from_memory_fingerprint, "snapshot_id": output.snapshot_id,
|
||||
}));
|
||||
}
|
||||
Err(SubjektivHostError::Conflict { .. })
|
||||
@@ -418,23 +424,24 @@ impl SubjektivSurfaceLifecycleTask {
|
||||
.await
|
||||
.map_err(surface_hook_error)?;
|
||||
// Success must attest the actual persisted marker, not merely receipt of
|
||||
// a failure notification. A protected ready surface or moved revision
|
||||
// a failure notification. A protected ready surface or moved fingerprint
|
||||
// must yield a Backend rejection/non-failed status, never a failed claim.
|
||||
let confirmed_status = if job_confirmation_required {
|
||||
"failed_confirmed"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
if failure.status != confirmed_status || failure.store_revision != generation.store_revision
|
||||
if failure.status != confirmed_status
|
||||
|| failure.memory_fingerprint != generation.memory_fingerprint
|
||||
{
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
"Backend did not confirm a failed surface for this generation's revision; no Job result was submitted",
|
||||
"Backend did not confirm a failed surface for this generation's fingerprint; no Job result was submitted",
|
||||
));
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"availability": "failed", "generation_id": generation.generation_id,
|
||||
"store_revision": failure.store_revision, "reason_code": reason_code,
|
||||
"memory_fingerprint": failure.memory_fingerprint, "reason_code": reason_code,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -604,7 +611,7 @@ mod tests {
|
||||
) -> server_api::SubjektivSurfaceMaterial {
|
||||
server_api::SubjektivSurfaceMaterial {
|
||||
memory_id: id.into(),
|
||||
revision: 1,
|
||||
change_id: "change-1".into(),
|
||||
kind,
|
||||
body_md,
|
||||
why_useful: "budget fixture".into(),
|
||||
@@ -619,7 +626,7 @@ mod tests {
|
||||
server_api::SubjektivSurfacePrepareResponse {
|
||||
generation_id: "generation-1".into(),
|
||||
current_snapshot_id: None,
|
||||
store_revision: 1,
|
||||
memory_fingerprint: "memory-set-1".into(),
|
||||
active_memory_count: materials.len(),
|
||||
materials,
|
||||
body_token_budget: 1_024,
|
||||
@@ -646,9 +653,9 @@ mod tests {
|
||||
job_results: Mutex<Vec<serde_json::Value>>,
|
||||
result_responses:
|
||||
Mutex<std::collections::VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
|
||||
current_revision: AtomicUsize,
|
||||
current_fingerprint: Mutex<Option<String>>,
|
||||
failure_status: Mutex<String>,
|
||||
failure_revision: AtomicUsize,
|
||||
failure_fingerprint: Mutex<Option<String>>,
|
||||
ready_snapshot: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
@@ -662,9 +669,9 @@ mod tests {
|
||||
failure_reasons: Mutex::new(Vec::new()),
|
||||
job_results: Mutex::new(Vec::new()),
|
||||
result_responses: Mutex::new(Default::default()),
|
||||
current_revision: AtomicUsize::new(0),
|
||||
current_fingerprint: Mutex::new(None),
|
||||
failure_status: Mutex::new("failed".into()),
|
||||
failure_revision: AtomicUsize::new(0),
|
||||
failure_fingerprint: Mutex::new(None),
|
||||
ready_snapshot: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
@@ -676,13 +683,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn revision(&self, fallback: usize) -> u64 {
|
||||
let current = self.current_revision.load(Ordering::SeqCst);
|
||||
if current == 0 {
|
||||
fallback as u64
|
||||
} else {
|
||||
current as u64
|
||||
}
|
||||
fn fingerprint(&self, fallback: usize) -> String {
|
||||
self.current_fingerprint
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("memory-set-{fallback}"))
|
||||
}
|
||||
|
||||
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
|
||||
@@ -741,7 +747,7 @@ mod tests {
|
||||
let mut prepared =
|
||||
generation(self.input_token_budget, vec![material("memory-1", 32)]);
|
||||
prepared.generation_id = format!("generation-{call}");
|
||||
prepared.store_revision = self.revision(call);
|
||||
prepared.memory_fingerprint = self.fingerprint(call);
|
||||
prepared.current_snapshot_id = self.ready_snapshot.lock().unwrap().clone();
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
|
||||
@@ -760,7 +766,7 @@ mod tests {
|
||||
server_api::SubjektivMemoryBackendResponse::SurfacePublished(
|
||||
server_api::SubjektivSurfacePublishResponse {
|
||||
snapshot_id: format!("surface-{call}"),
|
||||
built_from_store_revision: self.revision(call),
|
||||
built_from_memory_fingerprint: self.fingerprint(call),
|
||||
empty: false,
|
||||
},
|
||||
),
|
||||
@@ -771,12 +777,14 @@ mod tests {
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
|
||||
server_api::SubjektivSurfaceFailureResponse {
|
||||
store_revision: if self.failure_revision.load(Ordering::SeqCst) > 0
|
||||
{
|
||||
self.failure_revision.load(Ordering::SeqCst) as u64
|
||||
} else {
|
||||
self.revision(self.prepare_calls.load(Ordering::SeqCst))
|
||||
},
|
||||
memory_fingerprint: self
|
||||
.failure_fingerprint
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
self.fingerprint(self.prepare_calls.load(Ordering::SeqCst))
|
||||
}),
|
||||
status: self.failure_status.lock().unwrap().clone(),
|
||||
},
|
||||
),
|
||||
@@ -814,7 +822,7 @@ mod tests {
|
||||
serde_json::json!({
|
||||
"points": [{
|
||||
"body_md": "- Keep the confirmed constraint.",
|
||||
"memory_refs": [{"memory_id": "memory-1", "revision": 1}]
|
||||
"memory_refs": [{"memory_id": "memory-1", "change_id": "change-1"}]
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
@@ -863,7 +871,7 @@ permission = "write"
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".into(),
|
||||
settings_revision: 1,
|
||||
|
||||
language: "English".into(),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -899,7 +907,7 @@ permission = "write"
|
||||
.start_run_committed(HookInvocationContext {
|
||||
worker_id: "worker-1".into(),
|
||||
session_id: "session-1".into(),
|
||||
session_revision: 1,
|
||||
|
||||
run_id: Some("run-1".into()),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -1146,7 +1154,10 @@ permission = "write"
|
||||
results[0]["result"]["surface"]["generation_id"],
|
||||
"generation-1"
|
||||
);
|
||||
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
|
||||
assert_eq!(
|
||||
results[0]["result"]["surface"]["memory_fingerprint"],
|
||||
"memory-set-1"
|
||||
);
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
tool.execute(
|
||||
@@ -1289,7 +1300,7 @@ permission = "write"
|
||||
async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() {
|
||||
for status in [400, 403, 422] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
|
||||
workspace
|
||||
.result_responses
|
||||
.lock()
|
||||
@@ -1317,7 +1328,7 @@ permission = "write"
|
||||
assert_eq!(
|
||||
workspace.prepare_calls.load(Ordering::SeqCst),
|
||||
2,
|
||||
"reuse must verify current revision"
|
||||
"reuse must verify current Memory fingerprint"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.publish_calls.load(Ordering::SeqCst),
|
||||
@@ -1338,7 +1349,7 @@ permission = "write"
|
||||
async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() {
|
||||
for status in [400, 409] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
|
||||
workspace
|
||||
.result_responses
|
||||
.lock()
|
||||
@@ -1350,14 +1361,20 @@ permission = "write"
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
workspace.current_revision.store(2, Ordering::SeqCst);
|
||||
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-2".into());
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
|
||||
assert_eq!(results[1]["result"]["surface"]["store_revision"], 2);
|
||||
assert_eq!(
|
||||
results[0]["result"]["surface"]["memory_fingerprint"],
|
||||
"memory-set-1"
|
||||
);
|
||||
assert_eq!(
|
||||
results[1]["result"]["surface"]["memory_fingerprint"],
|
||||
"memory-set-2"
|
||||
);
|
||||
assert_ne!(
|
||||
results[0]["result"]["surface"]["generation_id"],
|
||||
results[1]["result"]["surface"]["generation_id"]
|
||||
@@ -1415,18 +1432,18 @@ permission = "write"
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_never_submits_unconfirmed_failure_or_wrong_revision() {
|
||||
for (status, revision) in [
|
||||
("failed", 0),
|
||||
("ready", 0),
|
||||
("stale", 0),
|
||||
("failed_confirmed", 2),
|
||||
async fn job_result_never_submits_unconfirmed_failure_or_wrong_fingerprint() {
|
||||
for (status, fingerprint) in [
|
||||
("failed", "memory-set-1"),
|
||||
("ready", "memory-set-1"),
|
||||
("stale", "memory-set-1"),
|
||||
("failed_confirmed", "memory-set-2"),
|
||||
] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
*workspace.failure_status.lock().unwrap() = status.into();
|
||||
workspace.failure_revision.store(revision, Ordering::SeqCst);
|
||||
*workspace.failure_fingerprint.lock().unwrap() = Some(fingerprint.into());
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
@@ -1435,7 +1452,7 @@ permission = "write"
|
||||
assert!(workspace.job_results.lock().unwrap().is_empty());
|
||||
// Nothing was dispatched/sealed: a real confirmed outcome remains retryable.
|
||||
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
|
||||
workspace.failure_revision.store(0, Ordering::SeqCst);
|
||||
*workspace.failure_fingerprint.lock().unwrap() = None;
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -64,7 +64,7 @@ impl MemorySurfaceOutputState {
|
||||
let allowed = self
|
||||
.materials
|
||||
.iter()
|
||||
.map(|material| (material.memory_id.as_str(), material.revision))
|
||||
.map(|material| (material.memory_id.as_str(), material.change_id.as_str()))
|
||||
.collect::<HashSet<_>>();
|
||||
let mut bodies = Vec::with_capacity(points.len());
|
||||
for point in points {
|
||||
@@ -81,16 +81,20 @@ impl MemorySurfaceOutputState {
|
||||
}
|
||||
let mut point_refs = HashSet::new();
|
||||
for reference in &point.memory_refs {
|
||||
if !point_refs.insert((reference.memory_id.as_str(), reference.revision)) {
|
||||
if !point_refs.insert((reference.memory_id.as_str(), reference.change_id.as_str()))
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"duplicate surface point ref {}@{}",
|
||||
reference.memory_id, reference.revision
|
||||
reference.memory_id,
|
||||
reference.change_id.as_str()
|
||||
)));
|
||||
}
|
||||
if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) {
|
||||
if !allowed.contains(&(reference.memory_id.as_str(), reference.change_id.as_str()))
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"surface ref {}@{} is not in the generation materials",
|
||||
reference.memory_id, reference.revision
|
||||
reference.memory_id,
|
||||
reference.change_id.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -218,7 +222,7 @@ mod tests {
|
||||
fn material(id: &str) -> server_api::SubjektivSurfaceMaterial {
|
||||
server_api::SubjektivSurfaceMaterial {
|
||||
memory_id: id.into(),
|
||||
revision: 1,
|
||||
change_id: "change-1".into(),
|
||||
kind: CandidateKind::Constraint,
|
||||
body_md: "Keep the boundary.".into(),
|
||||
why_useful: "Avoid regression.".into(),
|
||||
@@ -229,9 +233,9 @@ mod tests {
|
||||
fn point(id: &str) -> server_api::SubjektivSurfacePoint {
|
||||
server_api::SubjektivSurfacePoint {
|
||||
body_md: "- Keep the boundary unless it changes.".into(),
|
||||
memory_refs: vec![server_api::SubjektivMemoryRevisionRef {
|
||||
memory_refs: vec![server_api::SubjektivMemoryChangeRef {
|
||||
memory_id: id.into(),
|
||||
revision: 1,
|
||||
change_id: "change-1".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,12 +509,11 @@ fn native_merge_request_tools(
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MergeRequestRevisionState {
|
||||
revisions: HashMap<String, String>,
|
||||
mutation_sequence: u64,
|
||||
struct MergeRequestObservationState {
|
||||
content_digests: HashMap<String, String>,
|
||||
}
|
||||
|
||||
type MergeRequestRevisions = Arc<Mutex<MergeRequestRevisionState>>;
|
||||
type MergeRequestObservations = Arc<Mutex<MergeRequestObservationState>>;
|
||||
|
||||
/// Mount only the Merge Request operations enabled for this Worker as a native
|
||||
/// collection plus route-bound item objects. The ordinary Tool definitions remain
|
||||
@@ -542,7 +541,7 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
.filter(|tool| tool.projection.surface == NativeMergeRequestSurface::Item)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default()));
|
||||
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
|
||||
|
||||
let collection_descriptor = merge_request_descriptor(
|
||||
&collection_tools,
|
||||
@@ -574,7 +573,7 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
tools: merge_request_operation_map(collection_tools),
|
||||
permissions: permissions.clone(),
|
||||
collection_route: collection_route.clone(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
observations: Arc::clone(&observations),
|
||||
}),
|
||||
})?;
|
||||
|
||||
@@ -593,7 +592,7 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
tools: merge_request_operation_map(item_tools),
|
||||
permissions,
|
||||
collection_route: collection_route.clone(),
|
||||
revisions,
|
||||
observations,
|
||||
}),
|
||||
})?;
|
||||
registry.replace_compatibility_tools(&collection_route, claimed_tools)?;
|
||||
@@ -613,7 +612,7 @@ struct MergeRequestCollectionWipHandler {
|
||||
tools: HashMap<String, NativeMergeRequestTool>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
collection_route: String,
|
||||
revisions: MergeRequestRevisions,
|
||||
observations: MergeRequestObservations,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -643,7 +642,7 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
record_merge_request_observation(&self.revisions, &reference, &response, true);
|
||||
record_merge_request_observation(&self.observations, &reference, &response, true);
|
||||
if let Some(object) = response.as_object_mut() {
|
||||
object.insert(
|
||||
"path".into(),
|
||||
@@ -663,7 +662,7 @@ struct MergeRequestItemResolver {
|
||||
tools: HashMap<String, NativeMergeRequestTool>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
collection_route: String,
|
||||
revisions: MergeRequestRevisions,
|
||||
observations: MergeRequestObservations,
|
||||
}
|
||||
|
||||
impl WipDynamicItemResolver for MergeRequestItemResolver {
|
||||
@@ -671,14 +670,13 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
|
||||
if !is_merge_request_route_reference(item_reference) {
|
||||
return None;
|
||||
}
|
||||
let revision = self
|
||||
.revisions
|
||||
let content_digest = self
|
||||
.observations
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.revisions
|
||||
.content_digests
|
||||
.get(item_reference)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "unobserved".into());
|
||||
.cloned();
|
||||
let route = format!("{}/{}", self.collection_route, item_reference);
|
||||
Some(WipDynamicItem {
|
||||
object: Object {
|
||||
@@ -686,13 +684,15 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
|
||||
description: Some("Authoritative Merge Request bound to this object route".into()),
|
||||
interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("merge-request:{item_reference}")),
|
||||
validator: Some(merge_request_route_validator(&route, &revision)),
|
||||
validator: content_digest
|
||||
.as_deref()
|
||||
.map(|digest| merge_request_route_validator(&route, digest)),
|
||||
},
|
||||
handler: Arc::new(MergeRequestItemWipHandler {
|
||||
tools: self.tools.clone(),
|
||||
permissions: self.permissions.clone(),
|
||||
merge_request_id: item_reference.into(),
|
||||
revisions: Arc::clone(&self.revisions),
|
||||
observations: Arc::clone(&self.observations),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -702,7 +702,7 @@ struct MergeRequestItemWipHandler {
|
||||
tools: HashMap<String, NativeMergeRequestTool>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
merge_request_id: String,
|
||||
revisions: MergeRequestRevisions,
|
||||
observations: MergeRequestObservations,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -737,7 +737,7 @@ impl WipOperationHandler for MergeRequestItemWipHandler {
|
||||
tool.kind.mutating(),
|
||||
)?;
|
||||
record_merge_request_observation(
|
||||
&self.revisions,
|
||||
&self.observations,
|
||||
&self.merge_request_id,
|
||||
&response,
|
||||
tool.kind.mutating(),
|
||||
@@ -911,23 +911,22 @@ fn merge_request_reference(response: &Value) -> Option<&str> {
|
||||
}
|
||||
|
||||
fn record_merge_request_observation(
|
||||
revisions: &MergeRequestRevisions,
|
||||
observations: &MergeRequestObservations,
|
||||
reference: &str,
|
||||
response: &Value,
|
||||
mutation: bool,
|
||||
) {
|
||||
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let revision = if mutation {
|
||||
state.mutation_sequence = state.mutation_sequence.saturating_add(1);
|
||||
format!(
|
||||
"{}#mutation-{}",
|
||||
merge_request_response_fingerprint(response),
|
||||
state.mutation_sequence
|
||||
)
|
||||
let mut state = observations
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if mutation {
|
||||
state.content_digests.remove(reference);
|
||||
} else {
|
||||
merge_request_response_fingerprint(response)
|
||||
};
|
||||
state.revisions.insert(reference.to_string(), revision);
|
||||
state.content_digests.insert(
|
||||
reference.to_string(),
|
||||
merge_request_response_fingerprint(response),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_request_response_fingerprint(response: &Value) -> String {
|
||||
@@ -947,11 +946,11 @@ fn is_merge_request_route_reference(reference: &str) -> bool {
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
|
||||
}
|
||||
|
||||
fn merge_request_route_validator(route: &str, revision: &str) -> Vec<u8> {
|
||||
fn merge_request_route_validator(route: &str, content_digest: &str) -> Vec<u8> {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(route.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(revision.as_bytes());
|
||||
digest.update(content_digest.as_bytes());
|
||||
digest.finalize().to_vec()
|
||||
}
|
||||
|
||||
@@ -1428,7 +1427,7 @@ mod tests {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/merge-requests".into(),
|
||||
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
|
||||
observations: Arc::new(Mutex::new(MergeRequestObservationState::default())),
|
||||
};
|
||||
assert!(
|
||||
resolver
|
||||
@@ -1574,16 +1573,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn successful_observations_and_mutations_stale_only_affected_merge_requests() {
|
||||
let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default()));
|
||||
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
|
||||
let resolver = MergeRequestItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/merge-requests".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
observations: Arc::clone(&observations),
|
||||
};
|
||||
let first = resolver.resolve("MR-1").unwrap().object.validator;
|
||||
record_merge_request_observation(
|
||||
&revisions,
|
||||
&observations,
|
||||
"MR-1",
|
||||
&json!({"merge_request_id": "MR-1", "updated_at": "rev-1"}),
|
||||
false,
|
||||
@@ -1592,7 +1591,7 @@ mod tests {
|
||||
assert_ne!(first, observed);
|
||||
let other = resolver.resolve("MR-2").unwrap().object.validator;
|
||||
record_merge_request_observation(
|
||||
&revisions,
|
||||
&observations,
|
||||
"MR-1",
|
||||
&json!({"event_id": "review-1"}),
|
||||
true,
|
||||
@@ -1601,6 +1600,35 @@ mod tests {
|
||||
assert_eq!(other, resolver.resolve("MR-2").unwrap().object.validator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_request_observation_digest_survives_identical_reads_without_mutation_counters() {
|
||||
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
|
||||
let resolver = MergeRequestItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/merge-requests".into(),
|
||||
observations: Arc::clone(&observations),
|
||||
};
|
||||
let response = json!({"merge_request_id": "MR-1", "state": "open", "thread": []});
|
||||
assert!(resolver.resolve("MR-1").unwrap().object.validator.is_none());
|
||||
record_merge_request_observation(&observations, "MR-1", &response, false);
|
||||
let observed = resolver.resolve("MR-1").unwrap().object.validator;
|
||||
assert!(observed.is_some());
|
||||
record_merge_request_observation(&observations, "MR-1", &response, false);
|
||||
assert_eq!(resolver.resolve("MR-1").unwrap().object.validator, observed);
|
||||
for _ in 0..2 {
|
||||
record_merge_request_observation(
|
||||
&observations,
|
||||
"MR-1",
|
||||
&json!({"event_id": "same-review"}),
|
||||
true,
|
||||
);
|
||||
assert!(resolver.resolve("MR-1").unwrap().object.validator.is_none());
|
||||
}
|
||||
record_merge_request_observation(&observations, "MR-1", &response, false);
|
||||
assert_eq!(resolver.resolve("MR-1").unwrap().object.validator, observed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_errors_preserve_backend_rejections_and_unknown_post_dispatch_outcomes() {
|
||||
for (status, code) in [
|
||||
@@ -1681,7 +1709,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_hide_revision_and_commit_authority() {
|
||||
fn schemas_hide_content_digest_and_commit_authority() {
|
||||
let schemas = [
|
||||
schemars::schema_for!(OpenMergeRequestInput),
|
||||
schemars::schema_for!(CompleteMergeRequestInput),
|
||||
@@ -1689,7 +1717,7 @@ mod tests {
|
||||
for s in schemas {
|
||||
let j = serde_json::to_string(&s).unwrap();
|
||||
for banned in [
|
||||
"revision_id",
|
||||
"content_digest_id",
|
||||
"attempt_id",
|
||||
"base_commit",
|
||||
"head_commit",
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
//! local `.yoi/objectives` paths, so model-visible Objective tools go through
|
||||
//! the scoped Workspace API.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
#[cfg(test)]
|
||||
use std::sync::Mutex;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
@@ -536,14 +538,6 @@ const OBJECTIVE_TOOL_NAMES: [&str; 7] = [
|
||||
"ObjectiveUnlinkTicket",
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
struct ObjectiveRevisionState {
|
||||
revisions: HashMap<String, String>,
|
||||
aliases_by_canonical: HashMap<String, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
type ObjectiveRevisions = Arc<Mutex<ObjectiveRevisionState>>;
|
||||
|
||||
/// Contribute the Objective Feature's native collection and item projections to
|
||||
/// the Host-owned WIP registry. Ordinary Objective tools remain registered for
|
||||
/// Tool mode and are claimed only from the WIP compatibility projection.
|
||||
@@ -555,14 +549,13 @@ pub fn mount_workspace_http_objective_wip(
|
||||
) -> Result<(), WipMountError> {
|
||||
let collection_route = namespace_route.root().to_string();
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
||||
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||
|
||||
let collection_descriptor = objective_collection_descriptor();
|
||||
let collection_validator = descriptor_validator(&collection_descriptor);
|
||||
let collection_handler = Arc::new(ObjectiveCollectionWipHandler {
|
||||
backend: backend.clone(),
|
||||
permissions: permissions.clone(),
|
||||
collection_route: collection_route.clone(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
});
|
||||
registry.mount(WipProjection {
|
||||
route: collection_route.clone(),
|
||||
@@ -600,7 +593,6 @@ pub fn mount_workspace_http_objective_wip(
|
||||
backend,
|
||||
permissions,
|
||||
collection_route: collection_route.clone(),
|
||||
revisions,
|
||||
}),
|
||||
})?;
|
||||
registry.replace_compatibility_tools(&collection_route, OBJECTIVE_TOOL_NAMES)?;
|
||||
@@ -611,7 +603,6 @@ struct ObjectiveCollectionWipHandler {
|
||||
backend: WorkspaceHttpObjectiveBackend,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
collection_route: String,
|
||||
revisions: ObjectiveRevisions,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -695,7 +686,7 @@ impl WipOperationHandler for ObjectiveCollectionWipHandler {
|
||||
.create_value(&input)
|
||||
.await
|
||||
.map_err(map_backend_error)?;
|
||||
record_revision(&self.revisions, None, &response);
|
||||
|
||||
if let Some(reference) = response
|
||||
.get("objective")
|
||||
.and_then(Json::as_str)
|
||||
@@ -723,7 +714,6 @@ struct ObjectiveItemResolver {
|
||||
backend: WorkspaceHttpObjectiveBackend,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
collection_route: String,
|
||||
revisions: ObjectiveRevisions,
|
||||
}
|
||||
|
||||
impl WipDynamicItemResolver for ObjectiveItemResolver {
|
||||
@@ -731,14 +721,6 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
|
||||
if !is_objective_route_reference(item_reference) {
|
||||
return None;
|
||||
}
|
||||
let revision = self
|
||||
.revisions
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.revisions
|
||||
.get(item_reference)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "unobserved".into());
|
||||
let route = format!("{}/{}", self.collection_route, item_reference);
|
||||
Some(WipDynamicItem {
|
||||
object: Object {
|
||||
@@ -746,13 +728,14 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
|
||||
description: Some("Authoritative Objective bound to this object route".into()),
|
||||
interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("objective:{item_reference}")),
|
||||
validator: Some(route_validator(&route, &revision)),
|
||||
// The Object publishes fixed operation signatures, not Objective content.
|
||||
// Each operation reads/writes current Backend state under its authority.
|
||||
validator: Some(route_validator(&route, "objective-item")),
|
||||
},
|
||||
handler: Arc::new(ObjectiveItemWipHandler {
|
||||
backend: self.backend.clone(),
|
||||
permissions: self.permissions.clone(),
|
||||
objective_reference: item_reference.to_string(),
|
||||
revisions: Arc::clone(&self.revisions),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -762,7 +745,6 @@ struct ObjectiveItemWipHandler {
|
||||
backend: WorkspaceHttpObjectiveBackend,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
objective_reference: String,
|
||||
revisions: ObjectiveRevisions,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -832,7 +814,7 @@ impl WipOperationHandler for ObjectiveItemWipHandler {
|
||||
}
|
||||
_ => return Err(operation_not_found()),
|
||||
};
|
||||
record_revision(&self.revisions, Some(&self.objective_reference), &response);
|
||||
|
||||
Ok(WipOperationOutput::native(
|
||||
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
|
||||
))
|
||||
@@ -1022,36 +1004,6 @@ fn string_list_argument(
|
||||
}
|
||||
}
|
||||
|
||||
fn record_revision(revisions: &ObjectiveRevisions, bound_reference: Option<&str>, response: &Json) {
|
||||
let (Some(revision), Some(canonical)) = (
|
||||
response.get("revision").and_then(Json::as_str),
|
||||
response.get("objective").and_then(Json::as_str),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
|
||||
state
|
||||
.aliases_by_canonical
|
||||
.entry(canonical.to_string())
|
||||
.or_default()
|
||||
.insert(canonical.to_string());
|
||||
if let Some(reference) = bound_reference {
|
||||
state
|
||||
.aliases_by_canonical
|
||||
.entry(canonical.to_string())
|
||||
.or_default()
|
||||
.insert(reference.to_string());
|
||||
}
|
||||
let aliases = state
|
||||
.aliases_by_canonical
|
||||
.get(canonical)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for alias in aliases {
|
||||
state.revisions.insert(alias, revision.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_objective_route_reference(reference: &str) -> bool {
|
||||
is_canonical_resource_key(reference, "O-")
|
||||
|| (reference.len() >= 8
|
||||
@@ -1059,11 +1011,11 @@ fn is_objective_route_reference(reference: &str) -> bool {
|
||||
&& reference.bytes().all(|byte| byte.is_ascii_alphanumeric()))
|
||||
}
|
||||
|
||||
fn route_validator(route: &str, revision: &str) -> Vec<u8> {
|
||||
fn route_validator(route: &str, signature: &str) -> Vec<u8> {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(route.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(revision.as_bytes());
|
||||
digest.update(signature.as_bytes());
|
||||
digest.finalize().to_vec()
|
||||
}
|
||||
|
||||
@@ -1115,7 +1067,7 @@ fn objective_collection_descriptor() -> InterfaceDescriptor {
|
||||
parameter("state", false, TypeExpr::String),
|
||||
parameter("linked_tickets", false, list_of(TypeExpr::String)),
|
||||
],
|
||||
returns: json_return("Created Objective with revision and bounded context"),
|
||||
returns: json_return("Created Objective with bounded context"),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1182,7 +1134,7 @@ fn operation(
|
||||
details: None,
|
||||
}),
|
||||
parameters,
|
||||
returns: json_return("Authoritative Objective detail with revision and bounded context"),
|
||||
returns: json_return("Authoritative Objective detail with bounded context"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1293,7 +1245,7 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
|
||||
}
|
||||
|
||||
const LIST_DESCRIPTION: &str = "Query authoritative Objectives with bounded typed filters, stable snippets, linked-Ticket context, and cursor metadata.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with its revision, full linked-Ticket context, bounded body, and paged event metadata.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with full linked-Ticket context, bounded body, and paged event metadata.";
|
||||
const CREATE_DESCRIPTION: &str =
|
||||
"Create an Objective record through Backend Workspace API authority.";
|
||||
const EDIT_DESCRIPTION: &str =
|
||||
@@ -1549,17 +1501,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn objective_response(revision: &str) -> crate::worker::WorkspaceResponse {
|
||||
fn objective_response(title: &str) -> crate::worker::WorkspaceResponse {
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({
|
||||
"id": "00001OBJECTIVE",
|
||||
"resource_key": "O-3",
|
||||
"title": "Objective",
|
||||
"title": title,
|
||||
"body": "Body",
|
||||
"body_truncated": false,
|
||||
"state": "active",
|
||||
"revision": revision,
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"linked_ticket_summaries": [],
|
||||
@@ -1635,7 +1586,7 @@ mod tests {
|
||||
"body": "Body",
|
||||
"body_truncated": false,
|
||||
"state": "active",
|
||||
"revision": "rev-1",
|
||||
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"linked_ticket_summaries": [],
|
||||
@@ -1704,7 +1655,6 @@ mod tests {
|
||||
"body": "Body",
|
||||
"body_truncated": false,
|
||||
"state": "active",
|
||||
"revision": format!("rev-{mutation}"),
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"linked_ticket_summaries": [],
|
||||
@@ -1798,15 +1748,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_objective_routes_accept_only_objective_references_and_refresh_revision() {
|
||||
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||
fn dynamic_objective_routes_accept_only_objective_references() {
|
||||
let resolver = ObjectiveItemResolver {
|
||||
backend: WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||
)),
|
||||
permissions: None,
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let initial = resolver.resolve("O-3").unwrap();
|
||||
assert_eq!(initial.object.name, "O-3");
|
||||
@@ -1814,22 +1762,10 @@ mod tests {
|
||||
assert!(resolver.resolve("T-3").is_none());
|
||||
assert!(resolver.resolve("O-3/other").is_none());
|
||||
|
||||
record_revision(
|
||||
&revisions,
|
||||
Some("00001OBJECTIVE"),
|
||||
&json!({"objective": "O-3", "revision": "rev-1"}),
|
||||
assert_ne!(
|
||||
initial.object.validator,
|
||||
resolver.resolve("O-4").unwrap().object.validator
|
||||
);
|
||||
let canonical_rev_1 = resolver.resolve("O-3").unwrap().object.validator;
|
||||
let internal_rev_1 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator;
|
||||
record_revision(
|
||||
&revisions,
|
||||
Some("O-3"),
|
||||
&json!({"objective": "O-3", "revision": "rev-2"}),
|
||||
);
|
||||
let canonical_rev_2 = resolver.resolve("O-3").unwrap().object.validator;
|
||||
let internal_rev_2 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator;
|
||||
assert_ne!(canonical_rev_1, canonical_rev_2);
|
||||
assert_ne!(internal_rev_1, internal_rev_2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1948,7 +1884,6 @@ mod tests {
|
||||
rules: Vec::new(),
|
||||
}),
|
||||
objective_reference: "O-3".into(),
|
||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||
};
|
||||
let result = denied
|
||||
.call(
|
||||
@@ -1972,7 +1907,6 @@ mod tests {
|
||||
backend,
|
||||
permissions: None,
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||
};
|
||||
let result = collection
|
||||
.call(
|
||||
@@ -2009,12 +1943,11 @@ mod tests {
|
||||
objective_response("rev-4"),
|
||||
]));
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(client.clone());
|
||||
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||
|
||||
let collection = ObjectiveCollectionWipHandler {
|
||||
backend: backend.clone(),
|
||||
permissions: None,
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let context = || WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
@@ -2035,7 +1968,6 @@ mod tests {
|
||||
backend,
|
||||
permissions: None,
|
||||
objective_reference: "O-3".into(),
|
||||
revisions,
|
||||
};
|
||||
assert!(item.call("read", &BTreeMap::new(), context()).await.is_ok());
|
||||
assert!(
|
||||
@@ -2100,7 +2032,6 @@ mod tests {
|
||||
backend: WorkspaceHttpObjectiveBackend::new(client),
|
||||
permissions: None,
|
||||
objective_reference: "O-404".into(),
|
||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||
};
|
||||
for (operation, arguments) in [
|
||||
("read", BTreeMap::new()),
|
||||
@@ -2171,7 +2102,6 @@ mod tests {
|
||||
)),
|
||||
permissions: None,
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||
};
|
||||
let output = match handler
|
||||
.call(
|
||||
@@ -2198,7 +2128,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_edit_binds_target_from_route_and_publishes_new_revision() {
|
||||
async fn native_edit_returns_updated_content_without_changing_object_signatures() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
@@ -2217,7 +2147,7 @@ mod tests {
|
||||
"body": "Body",
|
||||
"body_truncated": false,
|
||||
"state": "active",
|
||||
"revision": "rev-2",
|
||||
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"linked_ticket_summaries": [],
|
||||
@@ -2233,7 +2163,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||
));
|
||||
@@ -2241,14 +2171,12 @@ mod tests {
|
||||
backend: backend.clone(),
|
||||
permissions: None,
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let before = resolver.resolve("O-3").unwrap().object.validator;
|
||||
let handler = ObjectiveItemWipHandler {
|
||||
backend,
|
||||
permissions: None,
|
||||
objective_reference: "O-3".into(),
|
||||
revisions,
|
||||
};
|
||||
let output = match handler
|
||||
.call(
|
||||
@@ -2267,9 +2195,9 @@ mod tests {
|
||||
server.join().unwrap();
|
||||
let output = crate::wip::wip_to_json(&output.value).unwrap();
|
||||
assert_eq!(output["objective"], "O-3");
|
||||
assert_eq!(output["revision"], "rev-2");
|
||||
assert_eq!(output["title"], "Changed");
|
||||
let after = resolver.resolve("O-3").unwrap().object.validator;
|
||||
assert_ne!(before, after);
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,7 +35,7 @@ struct ModelTicketQueryItem {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelTicketDetail {
|
||||
ticket: String,
|
||||
item_revision: String,
|
||||
content_digest: String,
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
@@ -82,7 +82,6 @@ pub(super) struct ModelObjectiveDetail {
|
||||
body: String,
|
||||
body_truncated: bool,
|
||||
state: String,
|
||||
revision: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<ModelTicketSummary>,
|
||||
@@ -284,7 +283,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
|
||||
Ok(ModelTicketDetail {
|
||||
ticket: resource_ref(root, "resource_key", "T-")?,
|
||||
item_revision: string_field(root, "item_revision")?,
|
||||
content_digest: string_field(root, "content_digest")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
@@ -369,7 +368,6 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
|
||||
body: string_field(root, "body")?,
|
||||
body_truncated: bool_field(root, "body_truncated")?,
|
||||
state: string_field(root, "state")?,
|
||||
revision: string_field(root, "revision")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
updated_at: optional_string(root, "updated_at")?,
|
||||
linked_tickets: array_field(root, "linked_ticket_summaries")?
|
||||
@@ -705,7 +703,7 @@ mod tests {
|
||||
"closed",
|
||||
] {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-718", "item_revision": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
|
||||
"linked_objectives": [], "assignments": [], "implementation_reports": [],
|
||||
"merge_requests": [], "merge_request": null,
|
||||
@@ -735,7 +733,6 @@ mod tests {
|
||||
"body": "Body",
|
||||
"body_truncated": false,
|
||||
"state": "active",
|
||||
"revision": "rev-3",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-02T00:00:00Z",
|
||||
"linked_tickets": ["00001M0E82D1V"],
|
||||
@@ -759,7 +756,6 @@ mod tests {
|
||||
let text = json.to_string();
|
||||
assert!(text.contains("O-543"));
|
||||
assert!(text.contains("T-496"));
|
||||
assert_eq!(json["revision"], "rev-3");
|
||||
assert_eq!(json["body_truncated"], false);
|
||||
assert!(!text.contains("00001M10HW6BV"));
|
||||
assert!(!text.contains("00001M0E82D1V"));
|
||||
@@ -845,7 +841,7 @@ mod tests {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"id": "internal-ticket",
|
||||
"resource_key": "T-588",
|
||||
"item_revision": "revision-1",
|
||||
"content_digest": "revision-1",
|
||||
"title": "Queued Submit",
|
||||
"body": "Body",
|
||||
"state": "planning",
|
||||
@@ -896,7 +892,7 @@ mod tests {
|
||||
.expect("current Ticket blocker shape must project");
|
||||
|
||||
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
|
||||
assert_eq!(projected["item_revision"], "revision-1");
|
||||
assert_eq!(projected["content_digest"], "revision-1");
|
||||
assert_eq!(
|
||||
projected["relations"]["blockers"],
|
||||
json!([{
|
||||
@@ -948,7 +944,7 @@ mod tests {
|
||||
});
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-716",
|
||||
"item_revision": "current-ticket-revision",
|
||||
"content_digest": "current-ticket-revision",
|
||||
"title": "Source evidence",
|
||||
"body": "Body",
|
||||
"state": if merged { "done" } else { "inprogress" },
|
||||
@@ -986,7 +982,7 @@ mod tests {
|
||||
Some(&json!(integration_error))
|
||||
);
|
||||
}
|
||||
assert_eq!(projected["item_revision"], "current-ticket-revision");
|
||||
assert_eq!(projected["content_digest"], "current-ticket-revision");
|
||||
assert_eq!(projected["evidence"]["review_after_rescope"], attested);
|
||||
assert_eq!(projected["evidence"]["complete_for_integration"], merged);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Host-scoped model tools for revisioned subjektiv Memory.
|
||||
//! Host-scoped model tools for change-identified subjektiv Memory.
|
||||
//!
|
||||
//! Confirmed Memory is read-only here. Explicit remembering and revision requests
|
||||
//! Confirmed Memory is read-only here. Explicit remembering and change requests
|
||||
//! become immutable staging candidates; T-670 remains the only authority that may
|
||||
//! adopt a candidate as a confirmed revision.
|
||||
//! adopt a candidate as a confirmed change.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
@@ -13,10 +13,9 @@ use memory::extract::CandidateKind;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use server_api::{
|
||||
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse,
|
||||
SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest,
|
||||
SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
|
||||
SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal,
|
||||
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse, SubjektivMemoryChangeIntent,
|
||||
SubjektivMemoryChangeProposal, SubjektivMemoryListChangesRequest, SubjektivMemoryQueryRequest,
|
||||
SubjektivMemoryReadRequest, SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
|
||||
SubjektivMemoryStageExplicitRequest, SubjektivMemoryStageExplicitResponse,
|
||||
SubjektivMemoryValidateProposalRequest,
|
||||
};
|
||||
@@ -41,19 +40,19 @@ use server_api::SubjektivMemoryBackendRequest;
|
||||
|
||||
const QUERY_TOOL: &str = "SubjektivMemoryQuery";
|
||||
const READ_TOOL: &str = "SubjektivMemoryRead";
|
||||
const LIST_REVISIONS_TOOL: &str = "SubjektivMemoryListRevisions";
|
||||
const LIST_CHANGES_TOOL: &str = "SubjektivMemoryListChanges";
|
||||
const REMEMBER_TOOL: &str = "SubjektivMemoryRemember";
|
||||
const PROPOSE_REVISION_TOOL: &str = "SubjektivMemoryProposeRevision";
|
||||
const PROPOSE_CHANGE_TOOL: &str = "SubjektivMemoryProposeChange";
|
||||
const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit";
|
||||
const RETRY_HOOK: &str = "retry-committed-explicit-memory";
|
||||
const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite";
|
||||
const MAX_EVIDENCE_REFS: usize = 10;
|
||||
|
||||
const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot.";
|
||||
const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line- and byte-bounded Markdown plus paged provenance anchors. Continue partial reads using the returned exact revision, line offset, and byte offset.";
|
||||
const LIST_REVISIONS_DESCRIPTION: &str = "List immutable revisions of one connected-subject Memory in descending revision order using snapshot-bound pagination.";
|
||||
const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory changes. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot.";
|
||||
const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory change for the connected subject, with line- and byte-bounded Markdown plus paged provenance anchors. Continue partial reads using the returned exact change, line offset, and byte offset.";
|
||||
const LIST_CHANGES_DESCRIPTION: &str = "List immutable changes of one connected-subject Memory in newest-first change order using snapshot-bound pagination.";
|
||||
const REMEMBER_DESCRIPTION: &str = "Explicitly stage a new Memory candidate for the connected subject. This never writes confirmed Memory. With no entry_refs, the committed tool call itself becomes model-origin evidence after the run commits and status is pending_commit.";
|
||||
const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory revision. This validates optimistic concurrency now but never creates a confirmed revision.";
|
||||
const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory change. This validates optimistic concurrency now but never creates a confirmed change.";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SubjektivMemoryFeature {
|
||||
@@ -73,7 +72,7 @@ struct PendingExplicit {
|
||||
claim: String,
|
||||
why_useful: String,
|
||||
staleness: Option<String>,
|
||||
proposal: Option<SubjektivMemoryRevisionProposal>,
|
||||
proposal: Option<SubjektivMemoryChangeProposal>,
|
||||
}
|
||||
|
||||
impl SubjektivMemoryFeature {
|
||||
@@ -102,12 +101,12 @@ impl FeatureModule for SubjektivMemoryFeature {
|
||||
.with_tool(ToolDeclaration::new(QUERY_TOOL, QUERY_DESCRIPTION))
|
||||
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
LIST_REVISIONS_TOOL,
|
||||
LIST_REVISIONS_DESCRIPTION,
|
||||
LIST_CHANGES_TOOL,
|
||||
LIST_CHANGES_DESCRIPTION,
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(REMEMBER_TOOL, REMEMBER_DESCRIPTION))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
PROPOSE_REVISION_TOOL,
|
||||
PROPOSE_CHANGE_TOOL,
|
||||
PROPOSE_DESCRIPTION,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
@@ -129,9 +128,9 @@ impl FeatureModule for SubjektivMemoryFeature {
|
||||
(QUERY_TOOL, QUERY_DESCRIPTION, ReadOperation::Query),
|
||||
(READ_TOOL, READ_DESCRIPTION, ReadOperation::Read),
|
||||
(
|
||||
LIST_REVISIONS_TOOL,
|
||||
LIST_REVISIONS_DESCRIPTION,
|
||||
ReadOperation::ListRevisions,
|
||||
LIST_CHANGES_TOOL,
|
||||
LIST_CHANGES_DESCRIPTION,
|
||||
ReadOperation::ListChanges,
|
||||
),
|
||||
] {
|
||||
context.tools().register(ToolContribution::new(
|
||||
@@ -149,9 +148,9 @@ impl FeatureModule for SubjektivMemoryFeature {
|
||||
),
|
||||
))?;
|
||||
context.tools().register(ToolContribution::new(
|
||||
PROPOSE_REVISION_TOOL,
|
||||
PROPOSE_CHANGE_TOOL,
|
||||
explicit_tool_definition(
|
||||
PROPOSE_REVISION_TOOL,
|
||||
PROPOSE_CHANGE_TOOL,
|
||||
PROPOSE_DESCRIPTION,
|
||||
self.state.clone(),
|
||||
ExplicitOperation::Propose,
|
||||
@@ -184,7 +183,7 @@ impl FeatureModule for SubjektivMemoryFeature {
|
||||
enum ReadOperation {
|
||||
Query,
|
||||
Read,
|
||||
ListRevisions,
|
||||
ListChanges,
|
||||
}
|
||||
|
||||
fn read_tool_definition(
|
||||
@@ -197,7 +196,7 @@ fn read_tool_definition(
|
||||
let schema = match operation {
|
||||
ReadOperation::Query => schema_for::<SubjektivMemoryQueryRequest>(),
|
||||
ReadOperation::Read => schema_for::<SubjektivMemoryReadRequest>(),
|
||||
ReadOperation::ListRevisions => schema_for::<SubjektivMemoryListRevisionsRequest>(),
|
||||
ReadOperation::ListChanges => schema_for::<SubjektivMemoryListChangesRequest>(),
|
||||
};
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubjektivReadTool {
|
||||
state: state.clone(),
|
||||
@@ -231,10 +230,9 @@ impl Tool for SubjektivReadTool {
|
||||
ReadOperation::Read => {
|
||||
SubjektivMemoryBackendOperation::Read(parse(input_json, READ_TOOL)?)
|
||||
}
|
||||
ReadOperation::ListRevisions => SubjektivMemoryBackendOperation::ListRevisions(parse(
|
||||
input_json,
|
||||
LIST_REVISIONS_TOOL,
|
||||
)?),
|
||||
ReadOperation::ListChanges => {
|
||||
SubjektivMemoryBackendOperation::ListChanges(parse(input_json, LIST_CHANGES_TOOL)?)
|
||||
}
|
||||
};
|
||||
let response = match self.state.execute(operation) {
|
||||
Ok(response) => response,
|
||||
@@ -247,14 +245,14 @@ impl Tool for SubjektivReadTool {
|
||||
),
|
||||
SubjektivMemoryBackendResponse::Read(value) => (
|
||||
format!(
|
||||
"Read Memory {} revision {}.",
|
||||
value.memory_id, value.revision
|
||||
"Read Memory {} change {}.",
|
||||
value.memory_id, value.change_id
|
||||
),
|
||||
serde_json::to_value(value),
|
||||
),
|
||||
SubjektivMemoryBackendResponse::ListRevisions(value) => (
|
||||
SubjektivMemoryBackendResponse::ListChanges(value) => (
|
||||
format!(
|
||||
"Listed {} revision(s) for Memory {}.",
|
||||
"Listed {} change(s) for Memory {}.",
|
||||
value.items.len(),
|
||||
value.memory_id
|
||||
),
|
||||
@@ -296,7 +294,7 @@ fn explicit_tool_definition(
|
||||
Arc::new(move || {
|
||||
let schema = match operation {
|
||||
ExplicitOperation::Remember => schema_for::<RememberParams>(),
|
||||
ExplicitOperation::Propose => schema_for::<ProposeRevisionParams>(),
|
||||
ExplicitOperation::Propose => schema_for::<ProposeChangeParams>(),
|
||||
};
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubjektivExplicitTool {
|
||||
state: state.clone(),
|
||||
@@ -325,11 +323,11 @@ struct RememberParams {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProposeRevisionParams {
|
||||
struct ProposeChangeParams {
|
||||
memory_id: String,
|
||||
#[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))]
|
||||
expected_revision: u64,
|
||||
intent: SubjektivMemoryRevisionIntent,
|
||||
#[schemars(length(min = 1))]
|
||||
expected_change_id: String,
|
||||
intent: SubjektivMemoryChangeIntent,
|
||||
claim: String,
|
||||
why_useful: String,
|
||||
change_reason: String,
|
||||
@@ -371,10 +369,10 @@ impl Tool for SubjektivExplicitTool {
|
||||
)
|
||||
}
|
||||
ExplicitOperation::Propose => {
|
||||
let params: ProposeRevisionParams = parse(input_json, PROPOSE_REVISION_TOOL)?;
|
||||
if params.expected_revision == 0 {
|
||||
let params: ProposeChangeParams = parse(input_json, PROPOSE_CHANGE_TOOL)?;
|
||||
if params.expected_change_id.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"expected_revision must be a positive integer".into(),
|
||||
"expected_change_id must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let validation =
|
||||
@@ -383,7 +381,7 @@ impl Tool for SubjektivExplicitTool {
|
||||
.execute(SubjektivMemoryBackendOperation::ValidateProposal(
|
||||
SubjektivMemoryValidateProposalRequest {
|
||||
memory_id: params.memory_id.clone(),
|
||||
expected_revision: params.expected_revision,
|
||||
expected_change_id: params.expected_change_id.clone(),
|
||||
intent: params.intent,
|
||||
},
|
||||
)) {
|
||||
@@ -398,9 +396,9 @@ impl Tool for SubjektivExplicitTool {
|
||||
)));
|
||||
}
|
||||
};
|
||||
let proposal = SubjektivMemoryRevisionProposal {
|
||||
let proposal = SubjektivMemoryChangeProposal {
|
||||
memory_id: params.memory_id,
|
||||
expected_revision: params.expected_revision,
|
||||
expected_change_id: params.expected_change_id.clone(),
|
||||
intent: params.intent,
|
||||
change_reason: params.change_reason,
|
||||
};
|
||||
@@ -447,7 +445,7 @@ impl Tool for SubjektivExplicitTool {
|
||||
"status": "pending_commit",
|
||||
"target": proposal.as_ref().map(|value| serde_json::json!({
|
||||
"memory_id": value.memory_id,
|
||||
"expected_revision": value.expected_revision,
|
||||
"expected_change_id": value.expected_change_id,
|
||||
})),
|
||||
"intent": proposal.as_ref().map(|value| value.intent),
|
||||
"message": "Candidate is not confirmed Memory. It will be staged only after this tool call is durably committed; an uncommitted/cancelled operation is discarded.",
|
||||
@@ -514,7 +512,7 @@ impl SubjektivMemoryState {
|
||||
let session_id = capture.session_id.clone();
|
||||
let view = SessionCapture::from_history_entries(capture.segment_id, capture.history);
|
||||
let committed = view
|
||||
.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL])
|
||||
.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL])
|
||||
.into_iter()
|
||||
.any(|call| {
|
||||
receipt_id(&session_id, &call.call_id)
|
||||
@@ -537,7 +535,7 @@ impl SubjektivMemoryState {
|
||||
why_useful: String,
|
||||
staleness: Option<String>,
|
||||
entries: Vec<SessionEntryEvidence>,
|
||||
proposal: Option<SubjektivMemoryRevisionProposal>,
|
||||
proposal: Option<SubjektivMemoryChangeProposal>,
|
||||
) -> Result<SubjektivMemoryStageExplicitResponse, ToolError> {
|
||||
if entries.is_empty() {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
@@ -620,7 +618,7 @@ fn replay_pending_committed_memory_with_policy(
|
||||
let view =
|
||||
SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone());
|
||||
let mut failures = Vec::new();
|
||||
for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) {
|
||||
for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL]) {
|
||||
let receipt_id = match receipt_id(&capture.session_id, &call.call_id) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
@@ -693,12 +691,12 @@ fn pending_from_committed_call(
|
||||
None,
|
||||
)
|
||||
}
|
||||
PROPOSE_REVISION_TOOL => {
|
||||
let params: ProposeRevisionParams = parse(arguments, PROPOSE_REVISION_TOOL)?;
|
||||
PROPOSE_CHANGE_TOOL => {
|
||||
let params: ProposeChangeParams = parse(arguments, PROPOSE_CHANGE_TOOL)?;
|
||||
let response = state.execute(SubjektivMemoryBackendOperation::ValidateProposal(
|
||||
SubjektivMemoryValidateProposalRequest {
|
||||
memory_id: params.memory_id.clone(),
|
||||
expected_revision: params.expected_revision,
|
||||
expected_change_id: params.expected_change_id.clone(),
|
||||
intent: params.intent,
|
||||
},
|
||||
))?;
|
||||
@@ -716,9 +714,9 @@ fn pending_from_committed_call(
|
||||
params.why_useful,
|
||||
params.staleness,
|
||||
params.entry_refs,
|
||||
Some(SubjektivMemoryRevisionProposal {
|
||||
Some(SubjektivMemoryChangeProposal {
|
||||
memory_id: params.memory_id,
|
||||
expected_revision: params.expected_revision,
|
||||
expected_change_id: params.expected_change_id.clone(),
|
||||
intent: params.intent,
|
||||
change_reason: params.change_reason,
|
||||
}),
|
||||
@@ -1067,7 +1065,7 @@ mod tests {
|
||||
Ok(CommittedSessionCapture {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
session_revision: 1,
|
||||
|
||||
entry_count: 0,
|
||||
run_exit: CommittedRunExit::Finished,
|
||||
history: Vec::new(),
|
||||
@@ -1096,9 +1094,9 @@ mod tests {
|
||||
vec![
|
||||
QUERY_TOOL,
|
||||
READ_TOOL,
|
||||
LIST_REVISIONS_TOOL,
|
||||
LIST_CHANGES_TOOL,
|
||||
REMEMBER_TOOL,
|
||||
PROPOSE_REVISION_TOOL,
|
||||
PROPOSE_CHANGE_TOOL,
|
||||
]
|
||||
);
|
||||
assert_eq!(descriptor.hooks.len(), 3);
|
||||
@@ -1128,11 +1126,11 @@ mod tests {
|
||||
.any(|field| field == "entry_refs")
|
||||
);
|
||||
|
||||
let propose = schema_for::<ProposeRevisionParams>();
|
||||
let propose = schema_for::<ProposeChangeParams>();
|
||||
let propose_text = serde_json::to_string(&propose).unwrap();
|
||||
assert!(!propose_text.contains("subject_id"));
|
||||
assert!(!propose_text.contains("\"kind\""));
|
||||
assert!(propose_text.contains("expected_revision"));
|
||||
assert!(propose_text.contains("expected_change_id"));
|
||||
assert!(propose_text.contains("change_reason"));
|
||||
|
||||
let parsed: RememberParams = serde_json::from_str(
|
||||
@@ -1152,7 +1150,7 @@ mod tests {
|
||||
worker_id: Some("w".repeat(64)),
|
||||
flow_selector: Some("f".repeat(64)),
|
||||
flow_definition_id: Some("d".repeat(64)),
|
||||
flow_definition_revision: Some(1),
|
||||
flow_definition_fingerprint: Some("fingerprint".into()),
|
||||
};
|
||||
let evidence = (0..MAX_EVIDENCE_REFS)
|
||||
.map(|index| memory::extract::StagingEvidence {
|
||||
@@ -1178,8 +1176,8 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
let response = server_api::SubjektivMemoryReadResponse {
|
||||
memory_id: "memory-1".into(),
|
||||
revision: 1,
|
||||
current_revision: 1,
|
||||
change_id: "change-1".into(),
|
||||
current_change_id: "change-1".into(),
|
||||
kind: CandidateKind::Lesson,
|
||||
state: server_api::SubjektivMemoryState::Active,
|
||||
claim: "Escape-aware read".into(),
|
||||
@@ -1252,7 +1250,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_conflict_discriminants_survive_as_structured_tool_output() {
|
||||
for code in ["revision_conflict", "stale_cursor"] {
|
||||
for code in ["change_conflict", "stale_cursor"] {
|
||||
let tool = SubjektivReadTool {
|
||||
state: SubjektivMemoryState {
|
||||
host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient(
|
||||
@@ -1291,7 +1289,7 @@ mod tests {
|
||||
operation: ExplicitOperation::Remember,
|
||||
};
|
||||
let input =
|
||||
r#"{"kind":"lesson","claim":"Use fixed revisions","why_useful":"Avoid mixed reads"}"#;
|
||||
r#"{"kind":"lesson","claim":"Use fixed changes","why_useful":"Avoid mixed reads"}"#;
|
||||
let output = tool
|
||||
.execute(input, ToolExecutionContext::new("call-1", "batch-1", 0))
|
||||
.await
|
||||
@@ -1321,7 +1319,7 @@ mod tests {
|
||||
Ok(CommittedSessionCapture {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
session_revision: 1,
|
||||
|
||||
entry_count: 3,
|
||||
run_exit: CommittedRunExit::Interrupted,
|
||||
history: vec![
|
||||
@@ -1380,7 +1378,7 @@ mod tests {
|
||||
Ok(CommittedSessionCapture {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
session_revision: 2,
|
||||
|
||||
entry_count: 2,
|
||||
run_exit: CommittedRunExit::Finished,
|
||||
history: vec![
|
||||
@@ -1457,7 +1455,7 @@ mod tests {
|
||||
Ok(CommittedSessionCapture {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
session_revision: 2,
|
||||
|
||||
entry_count: 2,
|
||||
run_exit: CommittedRunExit::Finished,
|
||||
history: vec![
|
||||
|
||||
@@ -63,7 +63,7 @@ impl WorkspaceTicketReadKind {
|
||||
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
|
||||
}
|
||||
Self::Show => {
|
||||
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
|
||||
"Show one authoritative Workspace Ticket with its content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,8 +292,8 @@ struct WorkspaceCompleteTicketInput {
|
||||
ticket: String,
|
||||
/// Stable key for exact replay of this completion decision.
|
||||
operation_key: String,
|
||||
/// Current item revision from ShowTicket.
|
||||
expected_item_revision: String,
|
||||
/// SHA-256 of the current editable Ticket content from ShowTicket.
|
||||
expected_content_digest: String,
|
||||
expected_state: TicketWorkflowState,
|
||||
/// Required explanation of the completion decision; MR approval is not a prerequisite.
|
||||
reason: String,
|
||||
@@ -308,8 +308,8 @@ struct WorkspaceTicketStateUpdateInput {
|
||||
/// Ticket reference. Prefer `T-*`.
|
||||
ticket: String,
|
||||
operation_key: String,
|
||||
/// Current item revision from ShowTicket.
|
||||
expected_item_revision: String,
|
||||
/// SHA-256 of the current editable Ticket content from ShowTicket.
|
||||
expected_content_digest: String,
|
||||
expected_state: TicketWorkflowState,
|
||||
/// Progress-display state only: this does not start Workers or integrate Merge Requests.
|
||||
state: TicketWorkflowState,
|
||||
@@ -326,8 +326,8 @@ struct WorkspaceTicketCloseInput {
|
||||
ticket: String,
|
||||
/// Stable key for exact replay of this close decision.
|
||||
operation_key: String,
|
||||
/// Current item revision from ShowTicket.
|
||||
expected_item_revision: String,
|
||||
/// SHA-256 of the current editable Ticket content from ShowTicket.
|
||||
expected_content_digest: String,
|
||||
expected_state: TicketWorkflowState,
|
||||
/// Required explanation for closing this Ticket.
|
||||
resolution: String,
|
||||
@@ -381,13 +381,13 @@ struct WorkspaceTicketDecisionTool {
|
||||
fn validate_ticket_decision(
|
||||
ticket: &str,
|
||||
operation_key: &str,
|
||||
revision: &str,
|
||||
content_digest: &str,
|
||||
reason: &str,
|
||||
) -> Result<(), ToolError> {
|
||||
for (name, value) in [
|
||||
("ticket", ticket),
|
||||
("operation_key", operation_key),
|
||||
("expected_item_revision", revision),
|
||||
("expected_content_digest", content_digest),
|
||||
("reason", reason),
|
||||
] {
|
||||
if value.trim().is_empty() {
|
||||
@@ -412,7 +412,7 @@ impl Tool for WorkspaceTicketDecisionTool {
|
||||
validate_ticket_decision(
|
||||
&value.ticket,
|
||||
&value.operation_key,
|
||||
&value.expected_item_revision,
|
||||
&value.expected_content_digest,
|
||||
&value.reason,
|
||||
)?;
|
||||
let mut body = serde_json::to_value(&value)
|
||||
@@ -428,12 +428,12 @@ impl Tool for WorkspaceTicketDecisionTool {
|
||||
validate_ticket_decision(
|
||||
&value.ticket,
|
||||
&value.operation_key,
|
||||
&value.expected_item_revision,
|
||||
&value.expected_content_digest,
|
||||
&value.resolution,
|
||||
)?;
|
||||
let body = json!({
|
||||
"operation_key": value.operation_key,
|
||||
"expected_item_revision": value.expected_item_revision,
|
||||
"expected_content_digest": value.expected_content_digest,
|
||||
"expected_state": value.expected_state,
|
||||
"state": TicketWorkflowState::Closed,
|
||||
"reason": value.resolution,
|
||||
@@ -447,7 +447,7 @@ impl Tool for WorkspaceTicketDecisionTool {
|
||||
validate_ticket_decision(
|
||||
&value.ticket,
|
||||
&value.operation_key,
|
||||
&value.expected_item_revision,
|
||||
&value.expected_content_digest,
|
||||
&value.reason,
|
||||
)?;
|
||||
let mut body = serde_json::to_value(&value)
|
||||
@@ -1005,7 +1005,7 @@ impl WorkspaceHttpTicketBackend {
|
||||
)?;
|
||||
serde_json::to_value(ticket::TicketStateUpdate {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_item_revision: ticket::ticket_item_revision(&snapshot),
|
||||
expected_content_digest: ticket::ticket_content_digest(&snapshot),
|
||||
expected_state,
|
||||
state,
|
||||
reason: reason.into(),
|
||||
@@ -1205,7 +1205,7 @@ impl WorkspaceHttpTicketBackend {
|
||||
)?;
|
||||
let body = serde_json::to_value(ticket::TicketCompletion {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_item_revision: ticket::ticket_item_revision(&snapshot),
|
||||
expected_content_digest: ticket::ticket_content_digest(&snapshot),
|
||||
expected_state: snapshot.meta.workflow_state,
|
||||
reason: resolution.as_str().to_owned(),
|
||||
references: Vec::new(),
|
||||
@@ -1763,13 +1763,12 @@ fn native_ticket_tools(
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TicketRevisionState {
|
||||
revisions: HashMap<String, String>,
|
||||
struct TicketObservationState {
|
||||
content_digests: HashMap<String, String>,
|
||||
aliases_by_canonical: HashMap<String, BTreeSet<String>>,
|
||||
mutation_sequence: u64,
|
||||
}
|
||||
|
||||
type TicketRevisions = Arc<Mutex<TicketRevisionState>>;
|
||||
type TicketObservations = Arc<Mutex<TicketObservationState>>;
|
||||
|
||||
/// Mount the enabled Ticket Feature surface as native collection and route-bound
|
||||
/// item objects. Only tools already enabled for this Worker are projected and
|
||||
@@ -1797,7 +1796,7 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||
|
||||
let collection_descriptor = ticket_descriptor(
|
||||
&collection_tools,
|
||||
@@ -1824,7 +1823,7 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
tools: operation_map(collection_tools),
|
||||
permissions: permissions.clone(),
|
||||
collection_route: collection_route.clone(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
observations: Arc::clone(&observations),
|
||||
}),
|
||||
})?;
|
||||
|
||||
@@ -1843,7 +1842,7 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
tools: operation_map(item_tools),
|
||||
permissions,
|
||||
collection_route: collection_route.clone(),
|
||||
revisions,
|
||||
observations,
|
||||
}),
|
||||
})?;
|
||||
registry.replace_compatibility_tools(&collection_route, claimed_tools)?;
|
||||
@@ -1861,7 +1860,7 @@ struct TicketCollectionWipHandler {
|
||||
tools: HashMap<String, NativeTicketTool>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
collection_route: String,
|
||||
revisions: TicketRevisions,
|
||||
observations: TicketObservations,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -1879,7 +1878,7 @@ impl WipOperationHandler for TicketCollectionWipHandler {
|
||||
match operation {
|
||||
"query" => add_ticket_paths(&mut response, &self.collection_route),
|
||||
"create" => {
|
||||
record_ticket_observation(&self.revisions, None, &response, true);
|
||||
record_ticket_observation(&self.observations, None, &response, true);
|
||||
if let Some(reference) = ticket_reference(&response).map(ToOwned::to_owned) {
|
||||
response.as_object_mut().map(|object| {
|
||||
object.insert(
|
||||
@@ -1901,7 +1900,7 @@ struct TicketItemResolver {
|
||||
tools: HashMap<String, NativeTicketTool>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
collection_route: String,
|
||||
revisions: TicketRevisions,
|
||||
observations: TicketObservations,
|
||||
}
|
||||
|
||||
impl WipDynamicItemResolver for TicketItemResolver {
|
||||
@@ -1909,14 +1908,13 @@ impl WipDynamicItemResolver for TicketItemResolver {
|
||||
if !is_ticket_route_reference(item_reference) {
|
||||
return None;
|
||||
}
|
||||
let revision = self
|
||||
.revisions
|
||||
let content_digest = self
|
||||
.observations
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.revisions
|
||||
.content_digests
|
||||
.get(item_reference)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "unobserved".into());
|
||||
.cloned();
|
||||
let route = format!("{}/{}", self.collection_route, item_reference);
|
||||
Some(WipDynamicItem {
|
||||
object: Object {
|
||||
@@ -1924,13 +1922,15 @@ impl WipDynamicItemResolver for TicketItemResolver {
|
||||
description: Some("Authoritative Ticket bound to this object route".into()),
|
||||
interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("ticket:{item_reference}")),
|
||||
validator: Some(route_validator(&route, &revision)),
|
||||
validator: content_digest
|
||||
.as_deref()
|
||||
.map(|digest| route_validator(&route, digest)),
|
||||
},
|
||||
handler: Arc::new(TicketItemWipHandler {
|
||||
tools: self.tools.clone(),
|
||||
permissions: self.permissions.clone(),
|
||||
ticket_reference: item_reference.into(),
|
||||
revisions: Arc::clone(&self.revisions),
|
||||
observations: Arc::clone(&self.observations),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1940,7 +1940,7 @@ struct TicketItemWipHandler {
|
||||
tools: HashMap<String, NativeTicketTool>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
ticket_reference: String,
|
||||
revisions: TicketRevisions,
|
||||
observations: TicketObservations,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -1960,13 +1960,13 @@ impl WipOperationHandler for TicketItemWipHandler {
|
||||
let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?;
|
||||
let response = ticket_tool_output_json(output)?;
|
||||
record_ticket_observation(
|
||||
&self.revisions,
|
||||
&self.observations,
|
||||
Some(&self.ticket_reference),
|
||||
&response,
|
||||
tool.projection.mutating,
|
||||
);
|
||||
if tool.projection.mutating {
|
||||
record_affected_ticket_mutations(&self.revisions, &self.ticket_reference, &response);
|
||||
record_affected_ticket_mutations(&self.observations, &self.ticket_reference, &response);
|
||||
}
|
||||
Ok(WipOperationOutput::native(
|
||||
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
|
||||
@@ -2122,23 +2122,16 @@ fn ticket_reference(response: &Value) -> Option<&str> {
|
||||
.filter(|reference| is_canonical_ticket_resource_key(reference))
|
||||
}
|
||||
|
||||
fn response_revision(response: &Value) -> Option<&str> {
|
||||
response
|
||||
.get("item_revision")
|
||||
.or_else(|| response.get("revision"))
|
||||
.or_else(|| response.get("updated_at"))
|
||||
.or_else(|| response.get("meta").and_then(|meta| meta.get("updated_at")))
|
||||
.and_then(Value::as_str)
|
||||
}
|
||||
|
||||
fn record_ticket_observation(
|
||||
revisions: &TicketRevisions,
|
||||
observations: &TicketObservations,
|
||||
bound_reference: Option<&str>,
|
||||
response: &Value,
|
||||
mutation: bool,
|
||||
) {
|
||||
let observed_canonical = ticket_reference(response).map(ToOwned::to_owned);
|
||||
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = observations
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let existing_bound_group = bound_reference.and_then(|reference| {
|
||||
state
|
||||
.aliases_by_canonical
|
||||
@@ -2180,29 +2173,22 @@ fn record_ticket_observation(
|
||||
aliases.insert(reference.to_string());
|
||||
}
|
||||
|
||||
let revision = if mutation {
|
||||
state.mutation_sequence = state.mutation_sequence.saturating_add(1);
|
||||
format!(
|
||||
"{}#mutation-{}",
|
||||
response_revision(response).unwrap_or("observed"),
|
||||
state.mutation_sequence
|
||||
)
|
||||
} else if let Some(revision) = response_revision(response) {
|
||||
revision.to_string()
|
||||
} else {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| state.revisions.get(alias).cloned())
|
||||
.unwrap_or_else(|| "observed".into())
|
||||
};
|
||||
// Mutation receipts are not full representations. Forget the observed
|
||||
// content rather than manufacturing a local change counter.
|
||||
for alias in &aliases {
|
||||
state.revisions.insert(alias.clone(), revision.clone());
|
||||
if mutation {
|
||||
state.content_digests.remove(alias);
|
||||
} else {
|
||||
let bytes = Sha256::digest(response.to_string().as_bytes());
|
||||
let digest = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
state.content_digests.insert(alias.clone(), digest);
|
||||
}
|
||||
}
|
||||
state.aliases_by_canonical.insert(group_key, aliases);
|
||||
}
|
||||
|
||||
fn record_affected_ticket_mutations(
|
||||
revisions: &TicketRevisions,
|
||||
observations: &TicketObservations,
|
||||
bound_reference: &str,
|
||||
response: &Value,
|
||||
) {
|
||||
@@ -2228,7 +2214,7 @@ fn record_affected_ticket_mutations(
|
||||
);
|
||||
}
|
||||
for reference in affected.difference(&subject_references) {
|
||||
record_ticket_observation(revisions, Some(reference), &Value::Null, true);
|
||||
record_ticket_observation(observations, Some(reference), &Value::Null, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2239,11 +2225,11 @@ fn is_ticket_route_reference(reference: &str) -> bool {
|
||||
&& reference.bytes().all(|byte| byte.is_ascii_alphanumeric()))
|
||||
}
|
||||
|
||||
fn route_validator(route: &str, revision: &str) -> Vec<u8> {
|
||||
fn route_validator(route: &str, content_digest: &str) -> Vec<u8> {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(route.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(revision.as_bytes());
|
||||
digest.update(content_digest.as_bytes());
|
||||
digest.finalize().to_vec()
|
||||
}
|
||||
|
||||
@@ -2427,7 +2413,7 @@ mod tests {
|
||||
fn decision_input() -> Value {
|
||||
json!({
|
||||
"ticket": "T-718", "operation_key": "decision-1",
|
||||
"expected_item_revision": "revision-1", "expected_state": "planning",
|
||||
"expected_content_digest": "content_digest-1", "expected_state": "planning",
|
||||
"reason": "No repository changes are needed"
|
||||
})
|
||||
}
|
||||
@@ -2482,8 +2468,8 @@ mod tests {
|
||||
let request: ticket::TicketStateUpdate = serde_json::from_value(body.clone()).unwrap();
|
||||
assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok());
|
||||
assert_eq!(
|
||||
request.expected_item_revision,
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
request.expected_content_digest,
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
);
|
||||
assert_eq!(request.expected_state, TicketWorkflowState::Planning);
|
||||
assert_eq!(request.state, TicketWorkflowState::Closed);
|
||||
@@ -2592,8 +2578,8 @@ mod tests {
|
||||
assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok());
|
||||
assert!(keys.insert(request.operation_key));
|
||||
assert_eq!(
|
||||
request.expected_item_revision,
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
request.expected_content_digest,
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
);
|
||||
assert_eq!(request.expected_state, TicketWorkflowState::Ready);
|
||||
assert_eq!(request.reason, "Resolved without repository changes");
|
||||
@@ -2643,7 +2629,7 @@ mod tests {
|
||||
);
|
||||
for field in [
|
||||
"operation_key",
|
||||
"expected_item_revision",
|
||||
"expected_content_digest",
|
||||
"expected_state",
|
||||
"resolution",
|
||||
] {
|
||||
@@ -2660,13 +2646,19 @@ mod tests {
|
||||
.iter()
|
||||
.any(|p| p.name == "references" && !p.required)
|
||||
);
|
||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||
let resolver = TicketItemResolver {
|
||||
tools: operation_map(tools),
|
||||
permissions: None,
|
||||
collection_route: "/tickets".into(),
|
||||
revisions,
|
||||
observations,
|
||||
};
|
||||
record_ticket_observation(
|
||||
&resolver.observations,
|
||||
Some("T-718"),
|
||||
&json!({"ticket": "T-718", "body": "observed requirements"}),
|
||||
false,
|
||||
);
|
||||
let bound = resolver.resolve("T-718").unwrap();
|
||||
let before = bound.object.validator;
|
||||
let other = resolver.resolve("T-719").unwrap().object.validator;
|
||||
@@ -2702,7 +2694,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
"operation_key": "decision-1", "expected_item_revision": "revision-1",
|
||||
"operation_key": "decision-1", "expected_content_digest": "content_digest-1",
|
||||
"expected_state": "planning", "state": "closed",
|
||||
"reason": "No repository changes are needed", "references": []
|
||||
})
|
||||
@@ -2794,7 +2786,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
"operation_key": "decision-1", "expected_item_revision": "revision-1",
|
||||
"operation_key": "decision-1", "expected_content_digest": "content_digest-1",
|
||||
"expected_state": "planning", "reason": "No repository changes are needed", "references": []
|
||||
})
|
||||
);
|
||||
@@ -2859,7 +2851,7 @@ mod tests {
|
||||
assert!(validator.is_valid(&valid));
|
||||
for field in [
|
||||
"operation_key",
|
||||
"expected_item_revision",
|
||||
"expected_content_digest",
|
||||
"expected_state",
|
||||
reason_field,
|
||||
] {
|
||||
@@ -2875,7 +2867,7 @@ mod tests {
|
||||
for field in [
|
||||
reason_field,
|
||||
"operation_key",
|
||||
"expected_item_revision",
|
||||
"expected_content_digest",
|
||||
"ticket",
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
@@ -2968,7 +2960,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() {
|
||||
let client = decision_client(200, TicketWorkflowState::Done);
|
||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||
let tools =
|
||||
native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap();
|
||||
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
||||
@@ -2980,7 +2972,7 @@ mod tests {
|
||||
assert!(!complete.parameters.iter().any(|p| p.name == "ticket"));
|
||||
for field in [
|
||||
"operation_key",
|
||||
"expected_item_revision",
|
||||
"expected_content_digest",
|
||||
"expected_state",
|
||||
"reason",
|
||||
] {
|
||||
@@ -2995,8 +2987,14 @@ mod tests {
|
||||
tools: operation_map(tools),
|
||||
permissions: None,
|
||||
collection_route: "/tickets".into(),
|
||||
revisions: revisions.clone(),
|
||||
observations: observations.clone(),
|
||||
};
|
||||
record_ticket_observation(
|
||||
&resolver.observations,
|
||||
Some("T-718"),
|
||||
&json!({"ticket": "T-718", "body": "observed requirements"}),
|
||||
false,
|
||||
);
|
||||
let bound = resolver.resolve("T-718").unwrap();
|
||||
let before = bound.object.validator;
|
||||
let other = resolver.resolve("T-719").unwrap().object.validator;
|
||||
@@ -3138,12 +3136,12 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/tickets".into(),
|
||||
revisions,
|
||||
observations,
|
||||
};
|
||||
assert!(resolver.resolve("T-42").is_some());
|
||||
assert!(resolver.resolve("00001TICKET").is_some());
|
||||
@@ -3202,36 +3200,36 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ticket_alias_validators_refresh_together_after_mutation() {
|
||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/tickets".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
observations: Arc::clone(&observations),
|
||||
};
|
||||
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
|
||||
record_ticket_observation(
|
||||
&revisions,
|
||||
&observations,
|
||||
Some("00001TICKET"),
|
||||
&json!({"ticket": "00001TICKET", "ok": true}),
|
||||
true,
|
||||
);
|
||||
assert_ne!(
|
||||
assert_eq!(
|
||||
unobserved_internal,
|
||||
resolver.resolve("00001TICKET").unwrap().object.validator,
|
||||
"a first mutation through an internal-id route must stale that route"
|
||||
"an unobserved route stays unvalidated after mutation"
|
||||
);
|
||||
|
||||
record_ticket_observation(
|
||||
&revisions,
|
||||
&observations,
|
||||
Some("00001TICKET"),
|
||||
&json!({"ticket": "T-42", "item_revision": "rev-1"}),
|
||||
&json!({"ticket": "T-42", "content_digest": "rev-1"}),
|
||||
false,
|
||||
);
|
||||
let canonical_v1 = resolver.resolve("T-42").unwrap().object.validator;
|
||||
let internal_v1 = resolver.resolve("00001TICKET").unwrap().object.validator;
|
||||
record_ticket_observation(
|
||||
&revisions,
|
||||
&observations,
|
||||
Some("T-42"),
|
||||
&json!({"ticket": "T-42", "updated_at": "rev-2"}),
|
||||
true,
|
||||
@@ -3246,14 +3244,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_observation_digest_is_content_based_and_repeated_mutations_only_invalidate() {
|
||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/tickets".into(),
|
||||
observations: Arc::clone(&observations),
|
||||
};
|
||||
let response = json!({"ticket": "T-42", "body": "actual content", "state": "planning"});
|
||||
assert!(resolver.resolve("T-42").unwrap().object.validator.is_none());
|
||||
record_ticket_observation(&observations, Some("T-42"), &response, false);
|
||||
let observed = resolver.resolve("T-42").unwrap().object.validator;
|
||||
assert!(observed.is_some());
|
||||
record_ticket_observation(&observations, Some("T-42"), &response, false);
|
||||
assert_eq!(resolver.resolve("T-42").unwrap().object.validator, observed);
|
||||
for _ in 0..2 {
|
||||
record_ticket_observation(
|
||||
&observations,
|
||||
Some("T-42"),
|
||||
&json!({"operation_key": "same-replay"}),
|
||||
true,
|
||||
);
|
||||
assert!(resolver.resolve("T-42").unwrap().object.validator.is_none());
|
||||
}
|
||||
record_ticket_observation(&observations, Some("T-42"), &response, false);
|
||||
assert_eq!(resolver.resolve("T-42").unwrap().object.validator, observed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn related_ticket_mutations_invalidate_target_and_queue_observations() {
|
||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||
for ticket in ["T-42", "T-43", "T-44"] {
|
||||
record_ticket_observation(
|
||||
&revisions,
|
||||
&observations,
|
||||
Some(ticket),
|
||||
&json!({"ticket": ticket, "item_revision": "rev-1"}),
|
||||
&json!({"ticket": ticket, "content_digest": "rev-1"}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -3261,12 +3288,12 @@ mod tests {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/tickets".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
observations: Arc::clone(&observations),
|
||||
};
|
||||
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
|
||||
let queued_v1 = resolver.resolve("T-44").unwrap().object.validator;
|
||||
record_affected_ticket_mutations(
|
||||
&revisions,
|
||||
&observations,
|
||||
"T-42",
|
||||
&json!({
|
||||
"ticket": "T-42",
|
||||
|
||||
@@ -99,7 +99,6 @@ fn same_snapshot(
|
||||
) -> Result<(), WipOperationError> {
|
||||
if snapshot.connection_id != other.connection_id
|
||||
|| snapshot.validator != other.validator
|
||||
|| snapshot.revision != other.revision
|
||||
|| snapshot.digest != other.digest
|
||||
|| snapshot.entrypoints != other.entrypoints
|
||||
{
|
||||
@@ -504,7 +503,7 @@ impl ConfigHandler {
|
||||
"Workspace config text exceeds limit",
|
||||
));
|
||||
}
|
||||
// Resolve canonical per-file preconditions at the SAME captured tree revision.
|
||||
// Resolve canonical per-file preconditions at the SAME captured tree content.
|
||||
let metadata = observe(
|
||||
&self.backend,
|
||||
&self.attachment.connection_id,
|
||||
@@ -716,7 +715,6 @@ impl ConfigHandler {
|
||||
connection_id: self.attachment.connection_id.clone(),
|
||||
validator: self.snapshot.validator.clone(),
|
||||
request: ConfigCommitRequest {
|
||||
base_revision: self.snapshot.revision,
|
||||
base_digest: self.snapshot.digest.clone(),
|
||||
changes,
|
||||
entrypoints: self.snapshot.entrypoints.clone(),
|
||||
@@ -735,10 +733,7 @@ impl ConfigHandler {
|
||||
)
|
||||
.await
|
||||
.map_err(|_| unknown())?;
|
||||
if updated.revision != result.revision
|
||||
|| updated.digest != result.digest
|
||||
|| updated.validator != result.validator
|
||||
{
|
||||
if updated.digest != result.digest || updated.validator != result.validator {
|
||||
return Err(unknown());
|
||||
}
|
||||
let node = updated
|
||||
@@ -746,7 +741,7 @@ impl ConfigHandler {
|
||||
.iter()
|
||||
.find(|n| n.path == self.node.path)
|
||||
.ok_or_else(unknown)?;
|
||||
let value = json_to_wip(&json!({"revision": result.revision, "digest": result.digest,
|
||||
let value = json_to_wip(&json!({"digest": result.digest,
|
||||
"activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?;
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
|
||||
@@ -14,7 +14,6 @@ use protocol::{FeatureInvocation, FeatureInvocationIdentity};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value as Json, json};
|
||||
use server_api::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -24,7 +23,6 @@ struct State {
|
||||
read_only: bool,
|
||||
active: Option<WorkspaceConfigAttachment>,
|
||||
next_connection: u64,
|
||||
revision: u64,
|
||||
entries: BTreeMap<String, (ConfigContentType, String)>,
|
||||
commits: Vec<WorkspaceConfigCommitRequest>,
|
||||
requests: Vec<WorkspaceRequest>,
|
||||
@@ -32,6 +30,7 @@ struct State {
|
||||
fail_current: bool,
|
||||
lose_commit_reply: bool,
|
||||
race_commit: bool,
|
||||
mismatched_commit_reply: Option<&'static str>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Router {
|
||||
@@ -47,7 +46,6 @@ impl Router {
|
||||
read_only: false,
|
||||
active: None,
|
||||
next_connection: 0,
|
||||
revision: 1,
|
||||
entries: BTreeMap::from([
|
||||
(
|
||||
"main.dcdl".into(),
|
||||
@@ -64,6 +62,7 @@ impl Router {
|
||||
fail_current: false,
|
||||
lose_commit_reply: false,
|
||||
race_commit: false,
|
||||
mismatched_commit_reply: None,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -88,20 +87,30 @@ fn denied(status: u16, classification: WorkspaceConfigFailureClassification) ->
|
||||
}
|
||||
}
|
||||
fn digest(content: &str) -> String {
|
||||
Sha256::digest(content.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
config_source::digest_bytes(content.as_bytes())
|
||||
}
|
||||
impl State {
|
||||
fn tree_digest(&self) -> String {
|
||||
digest(&serde_json::to_string(&self.entries).unwrap())
|
||||
config_source::ConfigTreeSnapshot::from_entries(self.entries.iter().map(
|
||||
|(path, (content_type, content))| {
|
||||
config_source::ConfigEntry::new(
|
||||
config_source::VirtualPath::parse(path).unwrap(),
|
||||
match content_type {
|
||||
ConfigContentType::Decodal => config_source::ConfigContentType::Decodal,
|
||||
ConfigContentType::Text => config_source::ConfigContentType::Text,
|
||||
},
|
||||
content.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.digest
|
||||
}
|
||||
fn validator(&self, path: &str) -> String {
|
||||
format!(
|
||||
"wc:{}:{}:{}:{}",
|
||||
"wc:{}:{}:{}",
|
||||
self.active.as_ref().unwrap().connection_id,
|
||||
self.revision,
|
||||
self.tree_digest(),
|
||||
path
|
||||
)
|
||||
@@ -262,7 +271,6 @@ impl WorkspaceClient for Router {
|
||||
Ok(response(&WorkspaceConfigObserveResponse {
|
||||
connection_id: state.active.as_ref().unwrap().connection_id.clone(),
|
||||
validator: state.validator(""),
|
||||
revision: state.revision,
|
||||
digest: state.tree_digest(),
|
||||
entrypoints: vec!["main.dcdl".into()],
|
||||
nodes,
|
||||
@@ -294,7 +302,7 @@ impl WorkspaceClient for Router {
|
||||
let request: WorkspaceConfigCommitRequest = serde_json::from_value(input).unwrap();
|
||||
state.commits.push(request.clone());
|
||||
if state.race_commit {
|
||||
state.revision += 1;
|
||||
state.entries.get_mut("main.dcdl").unwrap().1 = "model = concurrent".into();
|
||||
state.race_commit = false;
|
||||
}
|
||||
if state.read_only {
|
||||
@@ -305,7 +313,6 @@ impl WorkspaceClient for Router {
|
||||
}
|
||||
assert_eq!(request.request.entrypoints, ["main.dcdl"]);
|
||||
if request.validator != state.validator("")
|
||||
|| request.request.base_revision != state.revision
|
||||
|| request.request.base_digest != state.tree_digest()
|
||||
{
|
||||
return Ok(denied(
|
||||
@@ -396,18 +403,23 @@ impl WorkspaceClient for Router {
|
||||
));
|
||||
}
|
||||
state.entries = entries;
|
||||
state.revision += 1;
|
||||
if state.lose_commit_reply {
|
||||
state.lose_commit_reply = false;
|
||||
return Err(WorkspaceClientError::Request(
|
||||
"secret-token /private/host/config".into(),
|
||||
));
|
||||
}
|
||||
Ok(response(&WorkspaceConfigCommitResponse {
|
||||
let mut result = WorkspaceConfigCommitResponse {
|
||||
validator: state.validator(""),
|
||||
revision: state.revision,
|
||||
digest: state.tree_digest(),
|
||||
}))
|
||||
};
|
||||
match state.mismatched_commit_reply.take() {
|
||||
Some("digest") => result.digest = "different-content".into(),
|
||||
Some("validator") => result.validator = "different-authority".into(),
|
||||
None => {}
|
||||
Some(_) => unreachable!("unknown fixture mismatch"),
|
||||
}
|
||||
Ok(response(&result))
|
||||
}
|
||||
_ => panic!("unregistered endpoint: {suffix}"),
|
||||
}
|
||||
@@ -668,13 +680,20 @@ fn runtime_for_cross(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeatur
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_production_stale_same_bytes_and_commit_race_never_overwrite() {
|
||||
async fn workspace_config_production_stale_content_and_commit_race_never_overwrite() {
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("initial", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
observe_interface(&runtime, path, true).await;
|
||||
client.state.lock().unwrap().revision += 1; // Same bytes, different identity revision.
|
||||
client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut("main.dcdl")
|
||||
.unwrap()
|
||||
.1 = "model = external".into();
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"overwritten"}))
|
||||
.await
|
||||
@@ -690,7 +709,7 @@ async fn workspace_config_production_stale_same_bytes_and_commit_race_never_over
|
||||
);
|
||||
assert_eq!(
|
||||
client.state.lock().unwrap().entries["main.dcdl"].1,
|
||||
"model = old"
|
||||
"model = concurrent"
|
||||
);
|
||||
assert_eq!(client.state.lock().unwrap().commits.len(), 1);
|
||||
}
|
||||
@@ -758,6 +777,26 @@ async fn workspace_config_production_invalid_size_save_failure_unknown_are_safe_
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_commit_observation_mismatch_reports_unknown_without_replaying_write() {
|
||||
for field in ["digest", "validator"] {
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("initial", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
observe_interface(&runtime, path, true).await;
|
||||
client.state.lock().unwrap().mismatched_commit_reply = Some(field);
|
||||
let error = call(&runtime, path, "write", json!({"content":"saved"}))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("unknown"), "{field}: {error}");
|
||||
let state = client.state.lock().unwrap();
|
||||
assert_eq!(state.commits.len(), 1);
|
||||
assert_eq!(state.entries["main.dcdl"].1, "saved");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_production_bounds_and_traversal_reject_before_content_calls() {
|
||||
let client = Router::new();
|
||||
@@ -876,7 +915,6 @@ async fn workspace_config_edit_shared_argument_rejection_never_commits() {
|
||||
assert!(error.to_string().contains(expected), "{error:?}");
|
||||
let state = client.state.lock().unwrap();
|
||||
assert!(state.commits.is_empty());
|
||||
assert_eq!(state.revision, 1);
|
||||
assert_eq!(state.entries["main.dcdl"].1, "model = old");
|
||||
// Argument-only rejection must not even fetch a preimage.
|
||||
assert!(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user