feat: compose Subject Ticket assignment with selected dependency contracts
Adapt T-778 source6d15569and selected T-465 plugin plans on T-80351f0c7e0. Preserve frozen Workspace107 and append Subject108, current assignee/target checks, recipient-scoped notification recovery and public contract generation. This is a candidate composition, not target adoption.
This commit is contained in:
@@ -4093,3 +4093,52 @@ mod tests {
|
||||
assert_eq!(diagnostics[0].kind, "cycle");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plugin_preflight_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../../resources/config-schema/fixtures/plugin-preflight.json"
|
||||
))
|
||||
.unwrap();
|
||||
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"plugin-preflight",
|
||||
"plugins",
|
||||
"1",
|
||||
fixture["schema"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle);
|
||||
for case in fixture["cases"].as_array().unwrap() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
path.clone(),
|
||||
ConfigContentType::Decodal,
|
||||
case["source"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract);
|
||||
match case.get("diagnostic") {
|
||||
Some(kind) => {
|
||||
let errors = result.unwrap_err();
|
||||
assert_eq!(errors[0].kind, kind.as_str().unwrap());
|
||||
assert_eq!(errors[0].path.as_str(), "main.dcdl");
|
||||
}
|
||||
None => {
|
||||
let evaluated = result.unwrap();
|
||||
assert_eq!(
|
||||
evaluated.projections[0]
|
||||
.data_json
|
||||
.pointer("/plugins/enabled/echo/config/mode"),
|
||||
Some(&case["mode"])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
|
||||
/// never starts a local MCP subprocess.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Omission retains the lower layer; an explicit section replaces the entire
|
||||
/// selection list, including an empty list. Never unions Plugin grants.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plugins: Option<crate::PluginProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfigPartial>,
|
||||
/// First-class web tool opt-in. See [`WebConfig`].
|
||||
@@ -800,11 +804,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
||||
"unknown field in manifest: memory (removed; configure feature.memory)",
|
||||
));
|
||||
}
|
||||
if value.get("plugins").is_some() {
|
||||
return Err(toml::de::Error::custom(
|
||||
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
|
||||
));
|
||||
}
|
||||
if value
|
||||
.get("feature")
|
||||
.and_then(toml::Value::as_table)
|
||||
@@ -935,6 +934,7 @@ impl WorkerManifestConfig {
|
||||
),
|
||||
feature: self.feature.merge(upper.feature),
|
||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||
plugins: upper.plugins.or(self.plugins),
|
||||
compaction: merge_option(
|
||||
self.compaction,
|
||||
upper.compaction,
|
||||
@@ -1450,6 +1450,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
mcp: cfg.mcp,
|
||||
plugins: cfg.plugins.unwrap_or_default(),
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
skills: cfg.skills,
|
||||
@@ -1497,6 +1498,7 @@ mod tests {
|
||||
permissions: None,
|
||||
feature: FeatureConfigPartial::default(),
|
||||
mcp: McpConfig::default(),
|
||||
plugins: None,
|
||||
session: None,
|
||||
compaction: None,
|
||||
web: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ mod model;
|
||||
pub mod model_catalog;
|
||||
pub mod paths;
|
||||
pub mod plugin;
|
||||
mod plugin_profile;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
@@ -24,6 +25,7 @@ pub use model::{
|
||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||
};
|
||||
pub use paths::user_profiles_path;
|
||||
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection};
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
||||
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
|
||||
/// consumer boundary.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
|
||||
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
|
||||
/// their serialized fingerprint inputs.
|
||||
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
|
||||
pub plugins: PluginProfileConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// First-class web tools configuration. Network access remains fail-closed
|
||||
@@ -1119,6 +1126,7 @@ impl Default for CompactionConfig {
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
|| !self.plugins.is_empty()
|
||||
|| self.feature.memory.workspace_settings.is_some()
|
||||
|| self.feature.subjektiv.workspace_settings.is_some()
|
||||
}
|
||||
@@ -1598,9 +1606,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
);
|
||||
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("dynamic Plugins are not supported"),
|
||||
error.to_string().contains("unknown field `enabled`"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
@@ -1620,7 +1626,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
}),
|
||||
);
|
||||
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown field `plugins`"));
|
||||
assert!(error.to_string().contains("unknown field `resolved`"));
|
||||
|
||||
let mut nested = base;
|
||||
nested
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Narrowing requests for Workspace-managed Plugin installations.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Profile input only, not Workspace enablement or a validated execution plan.
|
||||
///
|
||||
/// The Workspace owner must resolve each installation against its enabled exact
|
||||
/// artifact and reject selections outside its Tool/HTTPS/logging ceiling. Neither
|
||||
/// these names nor config values grant authority, select package paths, or cause
|
||||
/// discovery, dynamic registration, or guest execution.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginProfileConfig {
|
||||
#[serde(default)]
|
||||
pub selections: Vec<PluginProfileSelection>,
|
||||
}
|
||||
|
||||
impl PluginProfileConfig {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.selections.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// One installation's explicit subset; omitted surfaces and capabilities are off.
|
||||
///
|
||||
/// `tools` and `https_slots` are package-declared names, not public Tool names,
|
||||
/// URLs, host paths, or connection bindings. `config` contains non-secret override
|
||||
/// candidates only: the Workspace schema validator must reject them unless the
|
||||
/// package's closed typed schema permits those overrides. Deserialization alone
|
||||
/// cannot certify that arbitrary package-specific values are non-secret or valid.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginProfileSelection {
|
||||
pub installation_id: String,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub logging: bool,
|
||||
#[serde(default)]
|
||||
pub https_slots: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub config: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
ProfileSource, WorkerManifest, WorkerManifestConfig,
|
||||
read_persisted_worker_manifest_snapshot, resolve_profile_artifact_value,
|
||||
write_persisted_worker_manifest_snapshot,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn partial(plugins: Value) -> WorkerManifestConfig {
|
||||
serde_json::from_value(json!({
|
||||
"worker": { "name": "plugin-worker" },
|
||||
"plugins": plugins,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn resolve_profile(plugins: Value) -> crate::ResolvedProfile {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
resolve_profile_artifact_value(
|
||||
json!({ "plugins": plugins }),
|
||||
ProfileSource::Archive {
|
||||
archive_id: "profile-input".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_plugin_selections_and_grants_default_off() {
|
||||
let omitted = WorkerManifestConfig::from_toml("[worker]\nname = 'plain'\n").unwrap();
|
||||
assert!(
|
||||
serde_json::to_value(&omitted)
|
||||
.unwrap()
|
||||
.get("plugins")
|
||||
.is_none()
|
||||
);
|
||||
let manifest = WorkerManifest::try_from(omitted).unwrap();
|
||||
assert!(manifest.plugins.selections.is_empty());
|
||||
assert!(!manifest.requires_persisted_execution_snapshot());
|
||||
assert!(
|
||||
serde_json::to_value(manifest)
|
||||
.unwrap()
|
||||
.get("plugins")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let manifest = WorkerManifest::try_from(partial(json!({
|
||||
"selections": [{ "installation_id": "installation-a" }]
|
||||
})))
|
||||
.unwrap();
|
||||
let selection = &manifest.plugins.selections[0];
|
||||
assert!(selection.tools.is_empty());
|
||||
assert!(!selection.logging);
|
||||
assert!(selection.https_slots.is_empty());
|
||||
assert!(selection.config.is_empty());
|
||||
assert!(manifest.requires_persisted_execution_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_partial_merge_replaces_instead_of_unioning_and_can_clear() {
|
||||
let lower = partial(json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read", "write"],
|
||||
"logging": true, "https_slots": ["remote"], "config": {"limit": 10}
|
||||
}] }));
|
||||
let omitted = lower.clone().merge(WorkerManifestConfig::default());
|
||||
assert_eq!(omitted.plugins, lower.plugins);
|
||||
|
||||
let upper = partial(json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read"]
|
||||
}] }));
|
||||
let merged = lower.clone().merge(upper.clone());
|
||||
assert_eq!(merged.plugins, upper.plugins);
|
||||
let round_trip: WorkerManifestConfig =
|
||||
serde_json::from_value(serde_json::to_value(&merged).unwrap()).unwrap();
|
||||
assert_eq!(round_trip.plugins, merged.plugins);
|
||||
|
||||
for empty in [json!({}), json!({ "selections": [] })] {
|
||||
let cleared = lower.clone().merge(partial(empty));
|
||||
assert_eq!(cleared.plugins, Some(PluginProfileConfig::default()));
|
||||
let manifest = WorkerManifest::try_from(cleared).unwrap();
|
||||
assert!(manifest.plugins.selections.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_profile_toml_and_resolved_snapshots_preserve_typed_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = r#"
|
||||
[[plugins.selections]]
|
||||
installation_id = "installation-a"
|
||||
tools = ["lookup"]
|
||||
logging = true
|
||||
https_slots = ["api"]
|
||||
[plugins.selections.config]
|
||||
limit = 5
|
||||
labels = ["one", "two"]
|
||||
[plugins.selections.config.nested]
|
||||
strict = true
|
||||
"#;
|
||||
let parsed = WorkerManifestConfig::from_toml(source).unwrap();
|
||||
let authored: toml::Value = toml::from_str(source).unwrap();
|
||||
let resolved = resolve_profile_artifact_value(
|
||||
serde_json::to_value(authored).unwrap(),
|
||||
ProfileSource::Archive {
|
||||
archive_id: "typed-profile".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(Some(&resolved.manifest.plugins), parsed.plugins.as_ref());
|
||||
assert_eq!(
|
||||
resolved.manifest.plugins.selections[0].config["nested"],
|
||||
json!({"strict": true})
|
||||
);
|
||||
let snapshot = write_persisted_worker_manifest_snapshot(&resolved.manifest).unwrap();
|
||||
let restored = read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
|
||||
assert_eq!(restored.plugins, resolved.manifest.plugins);
|
||||
assert_eq!(
|
||||
write_persisted_worker_manifest_snapshot(&restored).unwrap(),
|
||||
snapshot
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored).unwrap(),
|
||||
resolved.manifest_snapshot
|
||||
);
|
||||
let toml_round_trip = toml::to_string(&resolved.manifest).unwrap();
|
||||
assert_eq!(
|
||||
WorkerManifest::from_toml(&toml_round_trip).unwrap().plugins,
|
||||
resolved.manifest.plugins
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_fields_change_serialized_profile_fingerprint_input() {
|
||||
let baseline = json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read"],
|
||||
"logging": false, "https_slots": [], "config": {"limit": 5}
|
||||
}] });
|
||||
let fingerprint = |plugins| {
|
||||
let resolved = resolve_profile(plugins);
|
||||
Sha256::digest(serde_json::to_vec(&resolved.manifest_snapshot).unwrap())
|
||||
};
|
||||
let original = fingerprint(baseline.clone());
|
||||
assert_eq!(fingerprint(baseline.clone()), original);
|
||||
for (field, value) in [
|
||||
("installation_id", json!("installation-b")),
|
||||
("tools", json!(["write"])),
|
||||
("logging", json!(true)),
|
||||
("https_slots", json!(["api"])),
|
||||
("config", json!({"limit": 6})),
|
||||
] {
|
||||
let mut changed = baseline.clone();
|
||||
changed["selections"][0][field] = value;
|
||||
assert_ne!(fingerprint(changed), original, "{field} was lost");
|
||||
}
|
||||
assert_eq!(
|
||||
fingerprint(json!({})),
|
||||
fingerprint(json!({"selections": []}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_config_rejects_legacy_package_and_unknown_authority_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut invalid = vec![
|
||||
json!({ "enabled": [{ "id": "project:legacy" }] }),
|
||||
json!({ "packages": ["/tmp/legacy.yoi-plugin"] }),
|
||||
json!({ "resolved": [{ "package_path": "/tmp/legacy.yoi-plugin" }] }),
|
||||
json!({ "enable": true }),
|
||||
];
|
||||
for (field, value) in [
|
||||
("package_path", json!("/tmp/legacy.yoi-plugin")),
|
||||
("path", json!("./plugin")),
|
||||
("url", json!("https://example.invalid/plugin")),
|
||||
("artifact_digest", json!("sha256:uninstalled")),
|
||||
("secret", json!("not-a-secret-reference")),
|
||||
("data", json!(true)),
|
||||
("events", json!(["changed"])),
|
||||
("websocket", json!(true)),
|
||||
("workdir", json!(true)),
|
||||
("unknown", json!(true)),
|
||||
] {
|
||||
let mut selection = json!({ "installation_id": "installation-a" });
|
||||
selection[field] = value;
|
||||
invalid.push(json!({ "selections": [selection] }));
|
||||
}
|
||||
invalid.extend([
|
||||
json!({ "selections": [{ "tools": ["read"] }] }),
|
||||
json!({ "selections": [{ "installation_id": "a", "config": "raw" }] }),
|
||||
]);
|
||||
for plugins in invalid {
|
||||
let partial_input = json!({ "plugins": plugins.clone() });
|
||||
assert!(
|
||||
serde_json::from_value::<WorkerManifestConfig>(partial_input.clone()).is_err(),
|
||||
"{plugins}"
|
||||
);
|
||||
let toml = toml::to_string(&partial_input).unwrap();
|
||||
assert!(WorkerManifestConfig::from_toml(&toml).is_err(), "{toml}");
|
||||
let error = resolve_profile_artifact_value(
|
||||
partial_input,
|
||||
ProfileSource::Archive {
|
||||
archive_id: "invalid".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("failed to decode Profile"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,7 @@ fn resolve_profile_value(
|
||||
permissions: profile.permissions,
|
||||
feature: profile.feature,
|
||||
mcp: profile.mcp,
|
||||
plugins: profile.plugins,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
skills: profile.skills,
|
||||
@@ -700,6 +701,8 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
plugins: Option<crate::PluginProfileConfig>,
|
||||
#[serde(default)]
|
||||
compaction: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
web: Option<WebConfig>,
|
||||
|
||||
@@ -1227,6 +1227,20 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<WorkspacePromptProjection, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/w/{workspace_id}/config/source-tree/preview",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 409, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn workspace_config_tree_preview(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: ConfigCommitRequest,
|
||||
) -> Result<WorkspaceConfigTreeResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/w/{workspace_id}/config/source-tree/commit",
|
||||
status = 201,
|
||||
@@ -8159,6 +8173,9 @@ pub struct TicketAssignmentSummary {
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))]
|
||||
pub enum TicketAssignmentPrincipal {
|
||||
Subject {
|
||||
subject_id: String,
|
||||
},
|
||||
User {
|
||||
account_id: String,
|
||||
},
|
||||
@@ -12876,6 +12893,11 @@ mod tests {
|
||||
HttpMethod::Get,
|
||||
"/api/w/{workspace_id}/config/projections/prompts",
|
||||
),
|
||||
(
|
||||
"workspace_config_tree_preview",
|
||||
HttpMethod::Post,
|
||||
"/api/w/{workspace_id}/config/source-tree/preview",
|
||||
),
|
||||
(
|
||||
"workspace_config_tree_commit",
|
||||
HttpMethod::Post,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use server_api::{SetTicketRoleAssignmentRequest, TicketAssignmentPrincipal, TicketQueryRequest};
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_and_query_use_the_same_exact_principal_identity() {
|
||||
for subject_id in ["subject-a", "subject-b"] {
|
||||
let principal = serde_json::json!({ "kind": "subject", "subject_id": subject_id });
|
||||
let assign: SetTicketRoleAssignmentRequest = serde_json::from_value(serde_json::json!({
|
||||
"operation_id": "assign-subject", "expected_assignment_id": null, "principal": principal
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(assign.principal, TicketAssignmentPrincipal::Subject { subject_id: ref id } if id == subject_id)
|
||||
);
|
||||
let query: TicketQueryRequest = serde_json::from_value(serde_json::json!({
|
||||
"assigned_principal": principal
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(query.assigned_principal).unwrap(),
|
||||
principal
|
||||
);
|
||||
assert_eq!(serde_json::to_value(assign.principal).unwrap(), principal);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_principal_keeps_legacy_audit_and_rejects_subject_identity_spoofing() {
|
||||
for principal in [
|
||||
serde_json::json!({ "kind": "user", "account_id": "alice" }),
|
||||
serde_json::json!({ "kind": "worker", "runtime_id": "runtime", "worker_id": "worker" }),
|
||||
serde_json::json!({ "kind": "workspace_agent", "agent_key": "workspace-orchestrator" }),
|
||||
] {
|
||||
let retained: TicketAssignmentPrincipal =
|
||||
serde_json::from_value(principal.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(retained).unwrap(), principal);
|
||||
}
|
||||
for malformed in [
|
||||
serde_json::json!({ "kind": "subject" }),
|
||||
serde_json::json!({ "kind": "subject", "subject_id": 1 }),
|
||||
serde_json::json!({ "kind": "subject", "subject_id": "a", "worker_id": "old-body" }),
|
||||
serde_json::json!({ "kind": "subject", "subject_id": "a", "agent_key": "workspace-orchestrator" }),
|
||||
] {
|
||||
assert!(serde_json::from_value::<TicketAssignmentPrincipal>(malformed).is_err());
|
||||
}
|
||||
}
|
||||
@@ -49,3 +49,38 @@ record (`changes` includes `body`), never a later title-only edit. Missing or em
|
||||
creator identities remain explicitly nullable, not inferred from later editors.
|
||||
The retained base SQL and frozen fixture schemas remain unchanged; fresh creation
|
||||
composes the ordered post migration.
|
||||
|
||||
## Subject responsibility schema
|
||||
|
||||
Component migration 14 owns the Subject principal CHECKs and assignment/current,
|
||||
operation, and legacy tracking/history guards. Fresh standalone stores and Workspace
|
||||
hosts use the same `migrate_sqlite_ticket_schema_in_transaction(&Connection)` startup
|
||||
composition; the host owns its surrounding transaction, authentication, live Subject
|
||||
body bindings, delivery/start fences, and notification routing. Request paths only
|
||||
verify the owning schema and never install a host-dependent fixture or fallback.
|
||||
|
||||
Retained host startup migrations must instead call
|
||||
`migrate_historical_sqlite_ticket_schema_in_transaction(&Connection, HistoricalTicketSchemaTarget)`
|
||||
with the explicit `Cases10`, `Tracking11`, `Posts12`, or `Content13` boundary. This
|
||||
bounded API shares the latest composer's migration engine; it cannot select arbitrary
|
||||
versions or downgrade a current store. If the applied version is already newer, it
|
||||
verifies that actual contract without replaying old steps or relabelling receipts.
|
||||
Original baseline receipts are retained, including v6–9 baselines; their ordered
|
||||
v7/10 composition receipts are appended instead of overwriting the baseline. Previously
|
||||
retained normalized baseline10 histories remain supported. Neither composer is a
|
||||
request-path API or a grant of legacy execution authority.
|
||||
|
||||
The additive migration leaves applied core/tracking SQL unchanged. It preflights
|
||||
complete known table generations (standalone or Workspace-parent FKs), widens only
|
||||
the three principal CHECKs with exact-SQL CAS, restores `writable_schema` on failure,
|
||||
and checks integrity before accepting the migration. This avoids rebuilding immutable
|
||||
parents and their host-owned FK graph inside a host transaction. Unsupported/mixed
|
||||
tables and weakened guards fail closed; a trigger name alone never selects a schema
|
||||
generation. Migration history selects the exact verified contract.
|
||||
|
||||
Old `workspace_agent` relationships, handling/results, audit source JSON, and receipts
|
||||
remain readable as historical data, with a nonoperational diagnostic. They are not
|
||||
converted to an arbitrary Subject or replayed as new input. Newly authorized Subject
|
||||
Assign/reissue creates a distinct identity. Tracking sources use the Rust/JSON field
|
||||
`subject_id`; historical `agent_key` audit JSON remains an unmodified JSON value,
|
||||
not an alias accepted as current Subject authority.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! Close/reopen does not change handling; current responsibility can report after close
|
||||
//! without restoring execution grants. Retained unknown handling is not inferred waiting/done.
|
||||
//!
|
||||
//! The host authenticates the current workspace-agent Worker and its actual Runtime
|
||||
//! The host authenticates the current Subject body Worker and its actual Runtime
|
||||
//! binding, including retries, inside the same write transaction as receipt comparison,
|
||||
//! current-assignment/event CAS, audit, and result. Never split that transaction around
|
||||
//! awaits. A replayed receipt is not a fresh event observation: reread before a new update,
|
||||
@@ -48,7 +48,7 @@ pub struct TicketAssignmentTrackingUpdate {
|
||||
pub tracking: TicketAssignmentTracking,
|
||||
}
|
||||
|
||||
/// Backend-owned authenticated context, never decoded from model input. Agent
|
||||
/// Backend-owned authenticated context, never decoded from model input. Subject
|
||||
/// principal remains stable across Worker exchange; exact replay binds to the
|
||||
/// authenticated concrete sender and its binding, not merely a claimed role.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
@@ -56,7 +56,7 @@ pub struct TicketAssignmentTrackingSourceContext {
|
||||
pub actor: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub agent_key: String,
|
||||
pub subject_id: String,
|
||||
pub authentication_binding_id: String,
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ pub struct TicketAssignmentTrackingProjection {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TicketAssignmentPrincipalSnapshot {
|
||||
Subject {
|
||||
subject_id: String,
|
||||
},
|
||||
User {
|
||||
account_id: String,
|
||||
},
|
||||
@@ -156,11 +159,11 @@ fn validate_update(
|
||||
]
|
||||
.into_iter()
|
||||
.any(|value| !valid_identity(value))
|
||||
|| source.agent_key != "workspace-orchestrator"
|
||||
|| !valid_identity(&source.subject_id)
|
||||
|| source.subject_id == "workspace-orchestrator"
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking requires bounded identities and authenticated workspace-orchestrator source"
|
||||
.into(),
|
||||
"tracking requires bounded identities and authenticated Subject source".into(),
|
||||
));
|
||||
}
|
||||
for text in [
|
||||
@@ -216,7 +219,8 @@ pub fn sqlite_ticket_assignment_tracking(
|
||||
.transpose()?;
|
||||
// Legacy migration may retain current responsibility without evidence of
|
||||
// handling status. Its real Assign audit is still an immutable observation
|
||||
// against which the first explicit self-report can CAS, even after close.
|
||||
// for a later explicitly authorized operation, even after close. This does
|
||||
// not authorize a legacy agent relationship to report as a Subject.
|
||||
// Never manufacture a tracking event/status or repair a missing audit. Once
|
||||
// tracking exists, only its event identity is a valid observation.
|
||||
let last_event_id = if current && tracking.is_none() && last_event_id.is_none() {
|
||||
@@ -238,6 +242,9 @@ pub fn sqlite_ticket_assignment_tracking(
|
||||
let missing =
|
||||
|| TicketError::Conflict("stored assignment principal identity is incomplete".into());
|
||||
let principal = match kind.as_str() {
|
||||
"subject" => TicketAssignmentPrincipalSnapshot::Subject {
|
||||
subject_id: principal_id.ok_or_else(missing)?,
|
||||
},
|
||||
"workspace_agent" => TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: principal_id.ok_or_else(missing)?,
|
||||
},
|
||||
@@ -441,10 +448,10 @@ pub fn sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
"tracking assignment is removed/replaced or last event changed".into(),
|
||||
));
|
||||
}
|
||||
let principal_matches:bool=conn.query_row("SELECT principal_kind='workspace_agent' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.agent_key],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
let principal_matches:bool=conn.query_row("SELECT principal_kind='subject' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.subject_id],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
if !principal_matches {
|
||||
return Err(TicketError::Conflict(
|
||||
"authenticated agent does not own this assignment".into(),
|
||||
"authenticated Subject does not own this assignment".into(),
|
||||
));
|
||||
}
|
||||
let event_id=format!("tracking-{}",allocate_record_id(unix_epoch_millis_now().unwrap_or(0), |candidate| conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND event_id=?2)",params![workspace_id,format!("tracking-{candidate}")],|r|r.get::<_,bool>(0)).unwrap_or(true)).map_err(sqlite_err)?);
|
||||
@@ -572,7 +579,10 @@ fn reconcile_legacy_tracking(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()> {
|
||||
pub(crate) fn verify_assignment_tracking_schema(
|
||||
conn: &Connection,
|
||||
subject_contract: bool,
|
||||
) -> Result<()> {
|
||||
// Object names alone would accept same-named no-op triggers after drift.
|
||||
let canonical = Connection::open_in_memory().map_err(sqlite_err)?;
|
||||
canonical
|
||||
@@ -603,6 +613,13 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
|
||||
}
|
||||
result.replace("ifnotexists", "")
|
||||
};
|
||||
// Generation is selected by the owning migration ledger, never by a trigger name.
|
||||
if subject_contract {
|
||||
canonical
|
||||
.execute_batch(crate::subject_assignment::SUBJECT_GUARDS)
|
||||
.map_err(sqlite_err)?;
|
||||
crate::subject_assignment::verify(conn)?;
|
||||
}
|
||||
for name in [
|
||||
"typed_ticket_assignment_tracking_history",
|
||||
"typed_ticket_assignment_tracking_operations",
|
||||
@@ -811,6 +828,14 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let expected_sql = if subject_contract {
|
||||
expected_sql.replace(
|
||||
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))",
|
||||
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))",
|
||||
)
|
||||
} else {
|
||||
expected_sql
|
||||
};
|
||||
let expected_sql = normalize(expected_sql);
|
||||
let actual_sql = normalize(actual_sql);
|
||||
let mut remainder = expected_sql.as_str();
|
||||
|
||||
@@ -25,11 +25,20 @@ fn seed_ticket(conn: &Connection, workspace: &str, ticket: &str) {
|
||||
}
|
||||
|
||||
fn assign(conn: &Connection, id: &str, event: &str) {
|
||||
assign_principal(conn, id, event, "subject", "subject-a");
|
||||
}
|
||||
|
||||
// Historical schema fixture only: never infer an authority fallback from DB shape.
|
||||
fn assign_legacy(conn: &Connection, id: &str, event: &str) {
|
||||
assign_principal(conn, id, event, "workspace_agent", "workspace-orchestrator");
|
||||
}
|
||||
|
||||
fn assign_principal(conn: &Connection, id: &str, event: &str, kind: &str, principal: &str) {
|
||||
let previous: Option<String> = conn.query_row("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[],|r|r.get(0)).optional().unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','author','2026-10-10T00:00:00Z')",[id]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator',?2,?3,'author','2026-10-10T00:00:00Z')",params![id,kind,principal]).unwrap();
|
||||
conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','now')",[id]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator',?2,?3,'now')",params![id,kind,principal]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,previous_assignment_id,actor,created_at,operation_id) VALUES ('w','t','orchestrator',?2,?4,?1,?3,'author','now',?2)",params![id,event,previous,if previous.is_some(){"reassigned"}else{"assigned"}]).unwrap();
|
||||
conn.execute_batch("COMMIT").unwrap();
|
||||
}
|
||||
@@ -39,7 +48,7 @@ fn source() -> TicketAssignmentTrackingSourceContext {
|
||||
actor: "orchestrator".into(),
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
subject_id: "subject-a".into(),
|
||||
authentication_binding_id: "binding".into(),
|
||||
}
|
||||
}
|
||||
@@ -92,7 +101,7 @@ fn assignment_initializes_waiting_and_audit_atomically() {
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].event_id, "assigned-a");
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','subject','subject-a','author','now')",[]).unwrap();
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
assert!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "rolled-back")
|
||||
@@ -222,13 +231,13 @@ fn tracking_cas_and_receipts_bind_full_payload_and_authenticated_source() {
|
||||
1 => sender.runtime_id = "other".into(),
|
||||
2 => sender.worker_id = "replacement".into(),
|
||||
3 => sender.authentication_binding_id = "replacement-binding".into(),
|
||||
_ => sender.agent_key = "other-agent".into(),
|
||||
_ => sender.subject_id = "subject-b".into(),
|
||||
};
|
||||
assert!(apply(&conn, &input, &sender).is_err());
|
||||
}
|
||||
let mut other = source();
|
||||
other.worker_id = "replacement".into();
|
||||
// Backend authenticated the replacement as the current stable agent. A new
|
||||
// Backend authenticated the replacement as the current Subject body. A new
|
||||
// operation can proceed on the same assignment without starting a new row.
|
||||
let next = update(
|
||||
"a",
|
||||
@@ -323,7 +332,7 @@ fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> C
|
||||
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-time');").unwrap();
|
||||
install_assignment_core(&conn).unwrap();
|
||||
seed_ticket(&conn, "w", "t");
|
||||
assign(&conn, "a", "assigned-a");
|
||||
assign_legacy(&conn, "a", "assigned-a");
|
||||
let snapshot = TicketLegacyWorkflowSnapshot {
|
||||
workflow_state: original.into(),
|
||||
workflow_state_explicit: true,
|
||||
@@ -347,7 +356,7 @@ fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> C
|
||||
conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',0,'closed-later','case-fingerprint','{}')",[]).unwrap();
|
||||
}
|
||||
if reassigned {
|
||||
assign(&conn, "b", "assigned-b");
|
||||
assign_legacy(&conn, "b", "assigned-b");
|
||||
}
|
||||
conn
|
||||
}
|
||||
@@ -408,7 +417,7 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
14
|
||||
);
|
||||
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
@@ -459,8 +468,8 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|
||||
fn tracking_schema_rejects_drift_and_duplicate_current_singleton_roles() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap();
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err());
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','subject','subject-a','author','now')",[]).unwrap();
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','subject','subject-a','now')",[]).is_err());
|
||||
conn.execute_batch("DROP TRIGGER typed_ticket_assignment_tracking_history_no_update; CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update BEFORE UPDATE ON typed_ticket_assignment_tracking_history BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(
|
||||
verify_sqlite_ticket_schema(&conn)
|
||||
@@ -493,7 +502,7 @@ fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_
|
||||
{
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','original-time','original-fingerprint')",[]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','subject','subject-a','a','original-time','original-fingerprint')",[]).unwrap();
|
||||
for sql in [
|
||||
"UPDATE ticket_worker_assignments SET assignment_id='changed' WHERE assignment_id='a'",
|
||||
"UPDATE ticket_worker_assignments SET assigned_by='forged' WHERE assignment_id='a'",
|
||||
@@ -518,7 +527,7 @@ fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_
|
||||
// current row, even in a standalone Ticket store without host constraints.
|
||||
conn.execute("DELETE FROM ticket_current_worker_assignments", [])
|
||||
.unwrap();
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','workspace_agent','different-agent','now')",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','subject','subject-b','now')",[]).is_err());
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state) VALUES('w','pending','assign','t','worker','worker','runtime','pending-worker','claim-time','claim-fingerprint','pending')",[]).unwrap();
|
||||
conn.execute("UPDATE ticket_assignment_operations SET worker_id='resolved-worker',binding_recovery_json='{}' WHERE operation_id='pending'",[]).unwrap();
|
||||
conn.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='Admission failed' WHERE operation_id='pending'",[]).unwrap();
|
||||
@@ -573,7 +582,7 @@ fn release_projection_preserves_actor_time_reason_and_optional_legacy_tracking()
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
assert!(conn.execute("UPDATE ticket_worker_assignments SET tracking_json='{\"status\":\"done\"}' WHERE assignment_id='b'",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','subject','subject-a','now')",[]).is_err());
|
||||
let legacy = legacy("done", true, false, false);
|
||||
migrate_sqlite_ticket_schema(&legacy).unwrap();
|
||||
legacy
|
||||
@@ -694,15 +703,15 @@ fn standalone_assignment_constraints_reject_cross_ticket_principals_or_orphan_au
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
for sql in [
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','workspace_agent','workspace-orchestrator','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','subject','subject-a','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-orchestrator','orchestrator','user','user','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','t','bad-owner','owner','worker','r','worker','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','workspace_agent','workspace-orchestrator','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','subject','subject-a','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,assigned_by,assigned_at) VALUES('w','t','missing-worker-identity','worker','worker','author','now')",
|
||||
"INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('other','t','orchestrator','cross-audit','assigned','a','author','now')",
|
||||
"INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('w','missing','orchestrator','cross-ticket-audit','assigned','a','author','now')",
|
||||
"INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,created_at) VALUES('other','orphan-operation','assign','t','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','workspace_agent','workspace-orchestrator','author','now','{\"status\":\"running\"}')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','subject','subject-a','author','now','{\"status\":\"running\"}')",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err(), "reject {sql}");
|
||||
}
|
||||
@@ -850,3 +859,269 @@ fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_app
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Frozen component13 composition, not a latest-schema fixture relabelled as old.
|
||||
fn legacy_v13() -> Connection {
|
||||
let conn = legacy("no-snapshot", false, false, false);
|
||||
migrate_assignment_tracking(&conn).unwrap();
|
||||
conn.execute_batch(include_str!("post_migration.sql"))
|
||||
.unwrap();
|
||||
conn.execute_batch(include_str!("content_changes.sql"))
|
||||
.unwrap();
|
||||
conn.execute_batch(
|
||||
"INSERT INTO ticket_schema_migrations VALUES
|
||||
(11,'Ticket-owned assignment tracking','v11-time'),
|
||||
(12,'Canonical Ticket posts','v12-time'),
|
||||
(13,'Ticket content change facts','v13-time');",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_migration_preserves_legacy_audit_and_receipts_without_inventing_subject_authority() {
|
||||
let conn = legacy_v13();
|
||||
let tracking = r#"{"status":"done","waiting_reason":null,"progress":null,"next_action":null,"result":"Historical result"}"#;
|
||||
let old_source = r#"{"actor":"old-actor","runtime_id":"old-runtime","worker_id":"old-worker","agent_key":"workspace-orchestrator","authentication_binding_id":"old-binding"}"#;
|
||||
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?1,last_tracking_event_id='old-event',tracking_updated_at='old-time' WHERE assignment_id='a'",[tracking]).unwrap();
|
||||
conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','t','a','old-event','old-op',?1,?2,'old-time')",params![tracking,old_source]).unwrap();
|
||||
let before = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap();
|
||||
// Pre-Subject saved results remain byte-identical and are not new requests.
|
||||
let mut old_input = update(
|
||||
"a",
|
||||
"assigned-a",
|
||||
"old-op",
|
||||
TicketAssignmentTrackingStatus::Done,
|
||||
);
|
||||
old_input.tracking = before.tracking.clone().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_assignment_tracking_operations VALUES('w','old-op','t',?1,?2,?3)",
|
||||
params![
|
||||
serde_json::to_string(&("t", &old_input)).unwrap(),
|
||||
old_source,
|
||||
serde_json::to_string(&before).unwrap()
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','legacy-assign','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','old-time','old-fingerprint')",[]).unwrap();
|
||||
let receipts = |db: &Connection| -> (String, String) {
|
||||
db.query_row("SELECT source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE operation_key='old-op'",[],|r|Ok((r.get(0)?,r.get(1)?))).unwrap()
|
||||
};
|
||||
let saved_receipt = receipts(&conn);
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
let after = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after.principal,
|
||||
TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(after.tracking, before.tracking);
|
||||
assert_eq!(after.last_event_id, before.last_event_id);
|
||||
assert_eq!(after.updated_at, before.updated_at);
|
||||
assert!(after.current);
|
||||
assert!(
|
||||
after
|
||||
.migration_diagnostic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("nonoperational")
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(),
|
||||
history
|
||||
);
|
||||
assert_eq!(
|
||||
history.last().unwrap().source_context["agent_key"],
|
||||
"workspace-orchestrator"
|
||||
);
|
||||
assert_eq!(receipts(&conn), saved_receipt);
|
||||
assert_eq!(conn.query_row("SELECT principal_kind FROM ticket_assignment_operations WHERE operation_id='legacy-assign'",[],|r|r.get::<_,String>(0)).unwrap(),"workspace_agent");
|
||||
// Neither a random Subject nor spelling the old singleton as a Subject owns it.
|
||||
for subject in ["subject-a", "workspace-orchestrator"] {
|
||||
let mut sender = source();
|
||||
sender.actor = "old-actor".into();
|
||||
sender.runtime_id = "old-runtime".into();
|
||||
sender.worker_id = "old-worker".into();
|
||||
sender.authentication_binding_id = "old-binding".into();
|
||||
sender.subject_id = subject.into();
|
||||
assert!(
|
||||
apply(
|
||||
&conn,
|
||||
&update(
|
||||
"a",
|
||||
"old-event",
|
||||
"new-op",
|
||||
TicketAssignmentTrackingStatus::Inprogress
|
||||
),
|
||||
&sender
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
// Exact historical input and concrete sender still cannot reinterpret
|
||||
// old agent_key JSON as a new Subject-authenticated receipt.
|
||||
assert!(apply(&conn, &old_input, &sender).is_err());
|
||||
}
|
||||
for sql in [
|
||||
"UPDATE ticket_worker_assignments SET tracking_updated_at='new-time' WHERE assignment_id='a'",
|
||||
"INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','t','a','new-event','new-op','{\"status\":\"done\"}','{}','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','legacy-new','orchestrator','workspace_agent','workspace-orchestrator','actor','now')",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err(), "reject {sql}");
|
||||
}
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
after
|
||||
);
|
||||
// Explicit reissue is a new Subject identity, not a conversion of old audit.
|
||||
assign(&conn, "subject-reissue", "subject-reissue-event");
|
||||
apply(
|
||||
&conn,
|
||||
&update(
|
||||
"subject-reissue",
|
||||
"subject-reissue-event",
|
||||
"subject-update",
|
||||
TicketAssignmentTrackingStatus::Done,
|
||||
),
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(receipts(&conn), saved_receipt);
|
||||
assert!(
|
||||
!sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.current
|
||||
);
|
||||
let wire = serde_json::to_value(source()).unwrap();
|
||||
assert_eq!(wire["subject_id"], "subject-a");
|
||||
assert!(wire.get("agent_key").is_none());
|
||||
// Old serialized principal snapshots and audit JSON are still readable as old types.
|
||||
let old: TicketAssignmentTrackingProjection = serde_json::from_str(&saved_receipt.1).unwrap();
|
||||
assert_eq!(old.principal, before.principal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_owner_is_required_for_new_operations_and_worker_replacement_is_not_reassignment() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let input = update(
|
||||
"a",
|
||||
"assigned-a",
|
||||
"fresh",
|
||||
TicketAssignmentTrackingStatus::Inprogress,
|
||||
);
|
||||
let mut wrong_subject = source();
|
||||
wrong_subject.subject_id = "subject-b".into();
|
||||
assert!(apply(&conn, &input, &wrong_subject).is_err());
|
||||
let projection = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projection.principal,
|
||||
TicketAssignmentPrincipalSnapshot::Subject {
|
||||
subject_id: "subject-a".into()
|
||||
}
|
||||
);
|
||||
let mut replacement = source();
|
||||
replacement.worker_id = "new-body".into();
|
||||
replacement.authentication_binding_id = "new-binding".into();
|
||||
let result = apply(&conn, &input, &replacement).unwrap();
|
||||
assert_eq!(result.assignment_id, "a");
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM typed_ticket_assignment_tracking_operations",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_subject_migration_rolls_back_schema_and_receipt_but_preserves_host_transaction() {
|
||||
let conn = legacy_v13();
|
||||
let original: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER host_reject_subject_migration BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments BEGIN SELECT RAISE(ABORT,'simulated failure after CHECK widening'); END;").unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE; CREATE TABLE host_pending(value TEXT); INSERT INTO host_pending VALUES('keep');").unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
|
||||
assert!(!conn.is_autocommit());
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT value FROM host_pending", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"keep"
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
original
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.pragma_query_value(None, "writable_schema", |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_schema WHERE name='ticket_subject_current_insert'",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
conn.execute_batch("DROP TRIGGER host_reject_subject_migration;")
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&conn).unwrap();
|
||||
conn.execute_batch("COMMIT").unwrap();
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_generation_is_ledger_selected_and_never_repaired_by_a_marker_trigger() {
|
||||
let conn = legacy_v13();
|
||||
conn.execute_batch("CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema(&conn).is_err());
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
);
|
||||
let conn = db();
|
||||
conn.execute_batch("DROP TRIGGER ticket_subject_current_insert; CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(verify_sqlite_ticket_schema(&conn).is_err());
|
||||
assert!(migrate_sqlite_ticket_schema(&conn).is_err());
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ mod assignment_tracking;
|
||||
pub mod config;
|
||||
mod content_changes;
|
||||
mod sqlite_schema;
|
||||
mod subject_assignment;
|
||||
mod tags;
|
||||
pub use assignment_tracking::*;
|
||||
pub use tags::{
|
||||
@@ -39,6 +40,7 @@ pub use tags::{
|
||||
pub mod tool;
|
||||
|
||||
pub use sqlite_schema::{
|
||||
HistoricalTicketSchemaTarget, migrate_historical_sqlite_ticket_schema_in_transaction,
|
||||
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_in_transaction,
|
||||
verify_legacy_sqlite_ticket_schema, verify_pre_classification_ticket_schema,
|
||||
verify_sqlite_ticket_schema,
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::{Result, TicketError, sqlite_err};
|
||||
|
||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 13;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 14;
|
||||
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -320,11 +320,60 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends assignment, post, and content-change fact migrations without replacing applied markers.
|
||||
/// The host owns the surrounding transaction.
|
||||
/// A savepoint rolls back this component on failure, without committing host work.
|
||||
/// Current component 13 is verified only; historical edits are never backfilled.
|
||||
/// Startup-only historical composition targets retained by Workspace migrations.
|
||||
/// These are frozen component boundaries, not selectable request schemas or a
|
||||
/// way to restore legacy execution authority. Current stores must use the latest
|
||||
/// composer and request paths must use read-only verification instead.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HistoricalTicketSchemaTarget {
|
||||
/// Two-state cases plus tags; no owning assignment tracking yet.
|
||||
Cases10,
|
||||
/// Owning assignment tracking, before canonical posts.
|
||||
Tracking11,
|
||||
/// Canonical posts, before content-change facts.
|
||||
Posts12,
|
||||
/// Content-change facts, before Subject responsibility constraints.
|
||||
Content13,
|
||||
}
|
||||
impl HistoricalTicketSchemaTarget {
|
||||
fn shape(self) -> TicketSchemaShape {
|
||||
match self {
|
||||
Self::Cases10 => TicketSchemaShape::Composed10,
|
||||
Self::Tracking11 => TicketSchemaShape::Tracking11,
|
||||
Self::Posts12 => TicketSchemaShape::Posts12,
|
||||
Self::Content13 => TicketSchemaShape::ContentChanges13,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose one explicit retained host-migration boundary in the host's startup
|
||||
/// transaction. This is NOT a request-path API or a downgrade/repair operation.
|
||||
///
|
||||
/// A recognized older preimage is verified before composition. Applied receipts
|
||||
/// are retained; only missing migration receipts are appended. If the stored
|
||||
/// version is already at or above the target, its ACTUAL version and complete
|
||||
/// shape are verified without downgrading, relabelling, or replaying old steps.
|
||||
/// Unknown history and weakened constraints fail closed. The component savepoint
|
||||
/// rolls back failure without committing or discarding the caller's other work.
|
||||
pub fn migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
connection: &Connection,
|
||||
target: HistoricalTicketSchemaTarget,
|
||||
) -> Result<()> {
|
||||
migrate_ticket_schema_to_in_transaction(connection, target.shape())
|
||||
}
|
||||
|
||||
/// Appends owning component migrations through the latest contract without
|
||||
/// replacing applied receipts. Startup only; the host owns the transaction.
|
||||
/// A savepoint rolls back this component on failure without committing host work.
|
||||
/// Already-current schemas are verified, never repaired or backfilled.
|
||||
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
|
||||
migrate_ticket_schema_to_in_transaction(connection, TicketSchemaShape::Subjects14)
|
||||
}
|
||||
|
||||
fn migrate_ticket_schema_to_in_transaction(
|
||||
connection: &Connection,
|
||||
target: TicketSchemaShape,
|
||||
) -> Result<()> {
|
||||
if connection.is_autocommit() {
|
||||
return Err(TicketError::Sqlite(
|
||||
"Ticket component migration requires a host transaction".into(),
|
||||
@@ -344,26 +393,36 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
|
||||
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
if applied.is_empty() {
|
||||
create_latest_ticket_schema(connection)?;
|
||||
connection
|
||||
.execute_batch(include_str!("latest_schema.sql"))
|
||||
.map_err(sqlite_err)?;
|
||||
for version in 11..=target.version() {
|
||||
apply_component_migration(connection, version)?;
|
||||
}
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations VALUES (13, ?1, ?2)",
|
||||
params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()],
|
||||
"INSERT INTO ticket_schema_migrations VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
target.version(),
|
||||
BASELINE_MIGRATION_NAME,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
} else {
|
||||
let version = recognized_schema_version(&applied)?;
|
||||
let mut version = recognized_schema_version(&applied)?;
|
||||
if version == 6 {
|
||||
// Retained v6 conversion establishes the verified v7 preimage.
|
||||
// Preserve its original baseline receipt instead of renumbering it.
|
||||
migrate_v6_to_v7(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE ticket_schema_migrations SET version = 7 WHERE version = 6",
|
||||
[],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
append_component_receipt(connection, 7)?;
|
||||
version = 7;
|
||||
}
|
||||
let shape = TicketSchemaShape::from_version(if version == 6 { 7 } else { version });
|
||||
let shape = TicketSchemaShape::from_version(version);
|
||||
verify_ticket_schema_shape(connection, shape)?;
|
||||
if version >= target.version() {
|
||||
return Ok(());
|
||||
}
|
||||
match shape {
|
||||
TicketSchemaShape::Legacy7 => {
|
||||
add_tag_schema(connection)?;
|
||||
@@ -374,30 +433,19 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
|
||||
TicketSchemaShape::Composed10
|
||||
| TicketSchemaShape::Tracking11
|
||||
| TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13 => {}
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14 => {}
|
||||
}
|
||||
if version < 10 {
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1",
|
||||
params![chrono::Utc::now().to_rfc3339()],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
append_component_receipt(connection, 10)?;
|
||||
version = 10;
|
||||
}
|
||||
if version < 11 {
|
||||
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
|
||||
connection.execute("INSERT INTO ticket_schema_migrations VALUES (11, 'Ticket-owned assignment tracking', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
|
||||
}
|
||||
if version < 12 {
|
||||
migrate_posts(connection)?;
|
||||
connection.execute("INSERT INTO ticket_schema_migrations VALUES (12, 'Canonical Ticket posts', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
|
||||
}
|
||||
if version < 13 {
|
||||
migrate_content_changes(connection)?;
|
||||
connection.execute("INSERT INTO ticket_schema_migrations VALUES (13, 'Ticket content change facts', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
|
||||
for next in version + 1..=target.version() {
|
||||
apply_component_migration(connection, next)?;
|
||||
append_component_receipt(connection, next)?;
|
||||
}
|
||||
}
|
||||
verify_sqlite_ticket_schema(connection)
|
||||
verify_ticket_schema_shape(connection, target)
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => connection
|
||||
@@ -421,6 +469,7 @@ enum TicketSchemaShape {
|
||||
Tracking11,
|
||||
Posts12,
|
||||
ContentChanges13,
|
||||
Subjects14,
|
||||
}
|
||||
impl TicketSchemaShape {
|
||||
fn from_version(version: i64) -> Self {
|
||||
@@ -432,6 +481,7 @@ impl TicketSchemaShape {
|
||||
11 => Self::Tracking11,
|
||||
12 => Self::Posts12,
|
||||
13 => Self::ContentChanges13,
|
||||
14 => Self::Subjects14,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -444,6 +494,7 @@ impl TicketSchemaShape {
|
||||
Self::Tracking11 => 11,
|
||||
Self::Posts12 => 12,
|
||||
Self::ContentChanges13 => 13,
|
||||
Self::Subjects14 => 14,
|
||||
}
|
||||
}
|
||||
fn tags(self) -> bool {
|
||||
@@ -454,6 +505,7 @@ impl TicketSchemaShape {
|
||||
| Self::Tracking11
|
||||
| Self::Posts12
|
||||
| Self::ContentChanges13
|
||||
| Self::Subjects14
|
||||
)
|
||||
}
|
||||
fn cases(self) -> bool {
|
||||
@@ -464,13 +516,14 @@ impl TicketSchemaShape {
|
||||
| Self::Tracking11
|
||||
| Self::Posts12
|
||||
| Self::ContentChanges13
|
||||
| Self::Subjects14
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
|
||||
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::ContentChanges13)
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::Subjects14)
|
||||
}
|
||||
|
||||
/// Retained Workspace schema-66 migration produces the pre-classification v7
|
||||
@@ -484,7 +537,7 @@ pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Resul
|
||||
/// treating historical workflow values as the current two-state case authority.
|
||||
pub fn verify_legacy_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
let version = applied.keys().next().copied().unwrap_or_default();
|
||||
let version = recognized_schema_version(&applied)?;
|
||||
if !matches!(version, 7 | 8) {
|
||||
return Err(TicketError::Sqlite(
|
||||
"legacy Ticket schema requires component 7 or 8".into(),
|
||||
@@ -586,7 +639,9 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
let mut columns = columns.to_vec();
|
||||
if matches!(
|
||||
shape,
|
||||
TicketSchemaShape::Posts12 | TicketSchemaShape::ContentChanges13
|
||||
TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
) {
|
||||
if table == "typed_tickets" {
|
||||
columns.retain(|c| c.name != "body");
|
||||
@@ -831,16 +886,20 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
TicketSchemaShape::Tracking11
|
||||
| TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
) {
|
||||
if let Err(error) =
|
||||
crate::assignment_tracking::verify_assignment_tracking_schema(connection)
|
||||
{
|
||||
if let Err(error) = crate::assignment_tracking::verify_assignment_tracking_schema(
|
||||
connection,
|
||||
matches!(shape, TicketSchemaShape::Subjects14),
|
||||
) {
|
||||
push_diagnostic(&mut diagnostics, error.to_string());
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
shape,
|
||||
TicketSchemaShape::Posts12 | TicketSchemaShape::ContentChanges13
|
||||
TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
) {
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
@@ -851,7 +910,10 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
&mut diagnostics,
|
||||
);
|
||||
}
|
||||
if matches!(shape, TicketSchemaShape::ContentChanges13) {
|
||||
if matches!(
|
||||
shape,
|
||||
TicketSchemaShape::ContentChanges13 | TicketSchemaShape::Subjects14
|
||||
) {
|
||||
collect_content_change_diagnostics(connection, &mut diagnostics)?;
|
||||
}
|
||||
if diagnostics.is_empty() {
|
||||
@@ -870,13 +932,41 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
// Both fresh and retained composition use this single ordered step dispatcher.
|
||||
fn apply_component_migration(connection: &Connection, version: i64) -> Result<()> {
|
||||
match version {
|
||||
11 => crate::assignment_tracking::migrate_assignment_tracking(connection),
|
||||
12 => migrate_posts(connection),
|
||||
13 => migrate_content_changes(connection),
|
||||
14 => crate::subject_assignment::migrate(connection),
|
||||
_ => unreachable!("bounded component migration"),
|
||||
}
|
||||
}
|
||||
|
||||
fn component_migration_name(version: i64) -> &'static str {
|
||||
match version {
|
||||
7 => "Ordered Ticket targets",
|
||||
10 => "Ticket case and tag composition",
|
||||
11 => "Ticket-owned assignment tracking",
|
||||
12 => "Canonical Ticket posts",
|
||||
13 => "Ticket content change facts",
|
||||
14 => "Subject Ticket responsibility",
|
||||
_ => unreachable!("bounded component receipt"),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_component_receipt(connection: &Connection, version: i64) -> Result<()> {
|
||||
connection
|
||||
.execute_batch(include_str!("latest_schema.sql"))
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
version,
|
||||
component_migration_name(version),
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
|
||||
migrate_posts(connection)?;
|
||||
migrate_content_changes(connection)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_content_changes(connection: &Connection) -> Result<()> {
|
||||
@@ -1212,26 +1302,24 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
|
||||
}
|
||||
|
||||
fn recognized_schema_version(applied: &BTreeMap<i64, String>) -> Result<i64> {
|
||||
if applied.len() == 1 {
|
||||
if let Some((&version, name)) = applied.first_key_value() {
|
||||
if (6..=13).contains(&version) && name == BASELINE_MIGRATION_NAME {
|
||||
return Ok(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
for baseline in [10, 11, 12] {
|
||||
let mut expected = BTreeMap::from([(baseline, BASELINE_MIGRATION_NAME.to_owned())]);
|
||||
for (version, name) in [
|
||||
(11, "Ticket-owned assignment tracking"),
|
||||
(12, "Canonical Ticket posts"),
|
||||
(13, "Ticket content change facts"),
|
||||
] {
|
||||
if version <= baseline {
|
||||
continue;
|
||||
}
|
||||
expected.insert(version, name.to_owned());
|
||||
if let Some((&baseline, name)) = applied.first_key_value() {
|
||||
if (6..=LATEST_SQLITE_TICKET_SCHEMA_VERSION).contains(&baseline)
|
||||
&& name == BASELINE_MIGRATION_NAME
|
||||
{
|
||||
let mut expected = BTreeMap::from([(baseline, name.clone())]);
|
||||
if applied == &expected {
|
||||
return Ok(version);
|
||||
return Ok(baseline);
|
||||
}
|
||||
// Versions 8 and 9 were independent baseline generations, not
|
||||
// ordered upgrades. Both compose at10; v6 additionally needs v7.
|
||||
for version in [7, 10, 11, 12, 13, 14] {
|
||||
if version <= baseline || (version == 7 && baseline != 6) {
|
||||
continue;
|
||||
}
|
||||
expected.insert(version, component_migration_name(version).to_owned());
|
||||
if applied == &expected {
|
||||
return Ok(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1733,10 +1821,13 @@ mod tests {
|
||||
assert_eq!(
|
||||
load_applied_migrations(&connection).unwrap(),
|
||||
BTreeMap::from([
|
||||
(10, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(6, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(7, "Ordered Ticket targets".to_owned()),
|
||||
(10, "Ticket case and tag composition".to_owned()),
|
||||
(11, "Ticket-owned assignment tracking".to_owned()),
|
||||
(12, "Canonical Ticket posts".to_owned()),
|
||||
(13, "Ticket content change facts".to_owned())
|
||||
(13, "Ticket content change facts".to_owned()),
|
||||
(14, "Subject Ticket responsibility".to_owned())
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -1879,7 +1970,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain a canonical baseline and ordered migrations through 13"
|
||||
"migration history must contain a canonical baseline and ordered migrations through 14"
|
||||
));
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
|
||||
}
|
||||
@@ -1901,7 +1992,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain a canonical baseline and ordered migrations through 13"
|
||||
"migration history must contain a canonical baseline and ordered migrations through 14"
|
||||
));
|
||||
assert!(!table_exists(&connection, "typed_tickets").unwrap());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Component 14 owns Subject assignment table constraints and guards. It never
|
||||
//! maps a legacy agent to a Subject, starts work, or rewrites historical receipts.
|
||||
//! The host separately owns authenticated live Subject bindings, delivery and startup fences.
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
const PRINCIPAL_TABLES: [&str; 3] = [
|
||||
"ticket_assignment_operations",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignments",
|
||||
];
|
||||
const OLD_KINDS: &str = "CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))";
|
||||
const SUBJECT_KINDS: &str =
|
||||
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))";
|
||||
pub(crate) const SUBJECT_GUARDS: &str = include_str!("subject_assignment_schema.sql");
|
||||
const LEGACY_DIAGNOSTIC: &str = "Legacy Orchestrator identity retained without a trustworthy historical Subject mapping; nonoperational, explicit authorized Subject assignment required. Saved history and receipts do not grant execution authority.";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum MigrationError {
|
||||
#[error("{0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("{0}")]
|
||||
Contract(String),
|
||||
}
|
||||
type Result<T> = std::result::Result<T, MigrationError>;
|
||||
|
||||
/// Called only by the owning startup migration, within its savepoint. The caller
|
||||
/// rolls back on error. No independent ledger, transaction, or authority fallback.
|
||||
pub(crate) fn migrate(conn: &Connection) -> crate::Result<()> {
|
||||
let result = (|| -> Result<()> {
|
||||
if conn.is_autocommit() {
|
||||
return Err(MigrationError::Contract(
|
||||
"Subject assignment migration requires a caller-owned transaction".into(),
|
||||
));
|
||||
}
|
||||
widen_principal_checks(conn)?;
|
||||
conn.execute_batch(SUBJECT_GUARDS)?;
|
||||
conn.execute(
|
||||
"UPDATE ticket_worker_assignments
|
||||
SET tracking_migration_diagnostic = CASE
|
||||
WHEN tracking_migration_diagnostic IS NULL OR tracking_migration_diagnostic=''
|
||||
THEN ?1 ELSE tracking_migration_diagnostic || char(10) || ?1 END
|
||||
WHERE role='orchestrator' AND principal_kind!='subject'",
|
||||
[LEGACY_DIAGNOSTIC],
|
||||
)?;
|
||||
verify_inner(conn)?;
|
||||
verify_integrity(conn)
|
||||
})();
|
||||
result.map_err(super::sqlite_err)
|
||||
}
|
||||
|
||||
pub(crate) fn verify(conn: &Connection) -> crate::Result<()> {
|
||||
verify_inner(conn).map_err(super::sqlite_err)
|
||||
}
|
||||
|
||||
fn verify_inner(conn: &Connection) -> Result<()> {
|
||||
require_schema_writes_closed(conn)?;
|
||||
let canonical = known_table_shape(conn, true)?;
|
||||
canonical.execute_batch(SUBJECT_GUARDS)?;
|
||||
let mut statement =
|
||||
canonical.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' ORDER BY name")?;
|
||||
for name in statement.query_map([], |row| row.get::<_, String>(0))? {
|
||||
require_same_sql(conn, &canonical, "trigger", &name?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_schema_writes_closed(conn: &Connection) -> Result<()> {
|
||||
for setting in ["writable_schema", "ignore_check_constraints"] {
|
||||
if conn.pragma_query_value(None, setting, |row| row.get::<_, bool>(0))? {
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Subject assignment schema requires {setting} OFF"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn schema_sql(conn: &Connection, kind: &str, name: &str) -> Result<String> {
|
||||
conn.query_row(
|
||||
"SELECT sql FROM main.sqlite_schema WHERE type=?1 AND name=?2",
|
||||
params![kind, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| MigrationError::Contract(format!("Missing Subject assignment {kind} {name}")))
|
||||
}
|
||||
|
||||
fn require_same_sql(
|
||||
conn: &Connection,
|
||||
canonical: &Connection,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
if normalize_sql(&schema_sql(conn, kind, name)?)
|
||||
!= normalize_sql(&schema_sql(canonical, kind, name)?)
|
||||
{
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Subject assignment {kind} {name} differs from canonical schema"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Token boundaries and quoted literal bytes are significant. Only whitespace,
|
||||
/// unquoted ASCII case and SQLite's ALTER-generated quoting of these three known
|
||||
/// table identifiers are normalized. In particular, do not globally strip quotes,
|
||||
/// comments, IF NOT EXISTS, whitespace in literals, or merge adjacent SQL tokens.
|
||||
fn normalize_sql(sql: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = sql.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
if matches!(ch, '\'' | '"' | '`' | '[') {
|
||||
let end = if ch == '[' { ']' } else { ch };
|
||||
let mut token = String::from(ch);
|
||||
while let Some(next) = chars.next() {
|
||||
token.push(next);
|
||||
if next == end {
|
||||
if chars.peek() == Some(&end) && end != ']' {
|
||||
token.push(chars.next().expect("peeked quote"));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ch == '"'
|
||||
&& PRINCIPAL_TABLES
|
||||
.iter()
|
||||
.any(|table| token == format!("\"{table}\""))
|
||||
{
|
||||
token = token[1..token.len() - 1].to_owned();
|
||||
}
|
||||
tokens.push(token);
|
||||
} else if ch.is_alphanumeric() || matches!(ch, '_' | '$') {
|
||||
let mut token = ch.to_string();
|
||||
while chars
|
||||
.peek()
|
||||
.is_some_and(|next| next.is_alphanumeric() || matches!(*next, '_' | '$'))
|
||||
{
|
||||
token.push(chars.next().expect("peeked token"));
|
||||
}
|
||||
tokens.push(token.to_ascii_lowercase());
|
||||
} else {
|
||||
tokens.push(ch.to_string());
|
||||
}
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Reconstruct both explicitly supported historical generations from the immutable
|
||||
/// Ticket-owned core and tracking DDL. Only Workspace-parent FK presence differs.
|
||||
fn canonical_tables(subject: bool, workspace_parent: bool) -> Result<Connection> {
|
||||
let canonical = Connection::open_in_memory()?;
|
||||
let historical = super::TICKET_ASSIGNMENT_SCHEMA_SQL;
|
||||
let mut sql = if workspace_parent {
|
||||
historical.to_owned()
|
||||
} else {
|
||||
historical.replace("FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,", "")
|
||||
.replace(",\n FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE\n", "\n")
|
||||
};
|
||||
if subject {
|
||||
sql = sql.replace(OLD_KINDS, SUBJECT_KINDS);
|
||||
}
|
||||
canonical.execute_batch(&sql)?;
|
||||
canonical.execute_batch(include_str!("assignment_tracking_schema.sql"))?;
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
/// Match a whole published three-table generation. The operation table selects
|
||||
/// the known Workspace-FK variant; the other two must match that SAME variant.
|
||||
/// Mixed principal generations or one-off dropped constraints are not accepted.
|
||||
fn known_table_shape(conn: &Connection, subject: bool) -> Result<Connection> {
|
||||
let operation = normalize_sql(&schema_sql(conn, "table", PRINCIPAL_TABLES[0])?);
|
||||
for workspace_parent in [true, false] {
|
||||
let canonical = canonical_tables(subject, workspace_parent)?;
|
||||
if operation != normalize_sql(&schema_sql(&canonical, "table", PRINCIPAL_TABLES[0])?) {
|
||||
continue;
|
||||
}
|
||||
for table in PRINCIPAL_TABLES {
|
||||
if normalize_sql(&schema_sql(conn, "table", table)?)
|
||||
!= normalize_sql(&schema_sql(&canonical, "table", table)?)
|
||||
{
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Unsupported complete assignment table SQL in {table}; refusing unknown or mixed schema"
|
||||
)));
|
||||
}
|
||||
}
|
||||
return Ok(canonical);
|
||||
}
|
||||
Err(MigrationError::Contract(format!(
|
||||
"Unsupported complete assignment table SQL in {}; refusing unknown schema",
|
||||
PRINCIPAL_TABLES[0]
|
||||
)))
|
||||
}
|
||||
|
||||
/// SQLite's documented CHECK-only schema-change procedure avoids rebuilding
|
||||
/// immutable assignment parents and their many FK dependents. No columns,
|
||||
/// rootpages, rowids, index definitions, or data bytes change. The *entire* known
|
||||
/// old/new table SQL is preflighted before schema writes. The UPDATE targets are
|
||||
/// exactly the three named main-schema table rows and use exact-SQL CAS.
|
||||
///
|
||||
/// A normal DROP/rebuild needs foreign_keys=OFF *before* the caller transaction:
|
||||
/// defer_foreign_keys does not defer CASCADE actions or immutable child-delete
|
||||
/// triggers. Renaming with FK enforcement rewrites inbound references. Rebuilding
|
||||
/// the complete FK graph would include immutable tracking, receipts, work releases,
|
||||
/// origins, deliveries, startup fences and historical implementation delegations.
|
||||
/// That would require a different migration boundary and much broader data churn.
|
||||
/// For this CHECK-only change under the agreed caller transaction, the documented
|
||||
/// bounded schema edit is safer than an improvised graph rebuild. If table layout
|
||||
/// ever changes, this helper must reject it; it is not a general migration engine.
|
||||
fn widen_principal_checks(conn: &Connection) -> Result<()> {
|
||||
require_schema_writes_closed(conn)?;
|
||||
let old = known_table_shape(conn, false)?;
|
||||
let mut changes = Vec::new();
|
||||
for table in PRINCIPAL_TABLES {
|
||||
let sql = schema_sql(conn, "table", table)?;
|
||||
if sql.matches(OLD_KINDS).count() != 1 {
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Unsupported principal CHECK spelling in {table}"
|
||||
)));
|
||||
}
|
||||
changes.push((table, sql.replace(OLD_KINDS, SUBJECT_KINDS), sql));
|
||||
}
|
||||
// Do not silently repair same-named weakened principal triggers on upgrade.
|
||||
for name in [
|
||||
"typed_ticket_assignment_orchestrator_principal_insert",
|
||||
"typed_ticket_assignment_role_principal_insert",
|
||||
] {
|
||||
require_same_sql(conn, &old, "trigger", name)?;
|
||||
}
|
||||
let cookie: i64 = conn.pragma_query_value(None, "schema_version", |row| row.get(0))?;
|
||||
if !(0..i64::from(i32::MAX)).contains(&cookie) {
|
||||
return Err(MigrationError::Contract(
|
||||
"SQLite schema cookie cannot be advanced".into(),
|
||||
));
|
||||
}
|
||||
conn.pragma_update(None, "writable_schema", true)?;
|
||||
let change_result = (|| {
|
||||
let enabled: bool = conn.pragma_query_value(None, "writable_schema", |row| row.get(0))?;
|
||||
if !enabled {
|
||||
return Err(MigrationError::Contract(
|
||||
"SQLite refused the CHECK-only Subject schema migration".into(),
|
||||
));
|
||||
}
|
||||
for (table, new_sql, old_sql) in changes {
|
||||
let changed = conn.execute(
|
||||
"UPDATE main.sqlite_schema SET sql=?2 WHERE type='table' AND name=?1 AND tbl_name=?1 AND sql=?3",
|
||||
params![table, new_sql, old_sql],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Principal CHECK changed concurrently in {table}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Engine schema cache generation, NOT the application migration ledger.
|
||||
conn.pragma_update(None, "schema_version", cookie + 1)?;
|
||||
Ok(())
|
||||
})();
|
||||
// writable_schema is connection state, not transactional: restore it even
|
||||
// on failure, before returning control to the caller's rollback path.
|
||||
let restore_result = conn.pragma_update(None, "writable_schema", false);
|
||||
restore_result?;
|
||||
require_schema_writes_closed(conn)?;
|
||||
change_result?;
|
||||
conn.flush_prepared_statement_cache();
|
||||
// Force a complete reparse with writable_schema OFF. Malformed schema must
|
||||
// fail here rather than leave an apparently successful migration receipt.
|
||||
for table in PRINCIPAL_TABLES {
|
||||
conn.prepare(&format!("SELECT * FROM {table} LIMIT 0"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_integrity(conn: &Connection) -> Result<()> {
|
||||
let integrity: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
|
||||
if integrity != "ok" {
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Subject assignment migration integrity check failed: {integrity}"
|
||||
)));
|
||||
}
|
||||
let violations: i64 =
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(MigrationError::Contract(
|
||||
"Subject assignment migration foreign-key violation".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Component 14: additive Subject responsibility guards. Historical identities remain readable.
|
||||
|
||||
DROP TRIGGER typed_ticket_assignment_orchestrator_principal_insert;
|
||||
CREATE TRIGGER typed_ticket_assignment_orchestrator_principal_insert
|
||||
BEFORE INSERT ON ticket_worker_assignments
|
||||
WHEN NEW.role='orchestrator' AND NEW.principal_kind!='subject'
|
||||
BEGIN SELECT RAISE(ABORT,'New Orchestrator responsibility requires a Subject principal'); END;
|
||||
|
||||
DROP TRIGGER typed_ticket_assignment_role_principal_insert;
|
||||
CREATE TRIGGER typed_ticket_assignment_role_principal_insert
|
||||
BEFORE INSERT ON ticket_worker_assignments
|
||||
WHEN (NEW.role='owner' AND NEW.principal_kind!='user')
|
||||
OR (NEW.role='worker' AND NEW.principal_kind IN ('workspace_agent','subject'))
|
||||
OR (NEW.principal_kind='workspace_agent' AND NEW.principal_id!='workspace-orchestrator')
|
||||
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment role and principal are incompatible'); END;
|
||||
|
||||
CREATE TRIGGER ticket_subject_current_insert
|
||||
BEFORE INSERT ON ticket_current_worker_assignments
|
||||
WHEN (NEW.role='orchestrator' AND NEW.principal_kind!='subject')
|
||||
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
|
||||
BEGIN SELECT RAISE(ABORT,'Current Orchestrator responsibility requires a Subject principal'); END;
|
||||
CREATE TRIGGER ticket_subject_current_update
|
||||
BEFORE UPDATE OF workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,runtime_id,worker_id
|
||||
ON ticket_current_worker_assignments
|
||||
WHEN (NEW.role='orchestrator' AND NEW.principal_kind!='subject')
|
||||
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
|
||||
BEGIN SELECT RAISE(ABORT,'Current Orchestrator responsibility requires a Subject principal'); END;
|
||||
|
||||
CREATE TRIGGER ticket_subject_operation_insert
|
||||
BEFORE INSERT ON ticket_assignment_operations
|
||||
WHEN (NEW.role='orchestrator' AND NEW.action!='unassign' AND NEW.principal_kind!='subject')
|
||||
OR (NEW.principal_kind='subject' AND (NEW.role!='orchestrator'
|
||||
OR NEW.principal_id IS NULL OR length(trim(NEW.principal_id))=0
|
||||
OR NEW.runtime_id IS NOT NULL OR NEW.worker_id IS NOT NULL))
|
||||
BEGIN SELECT RAISE(ABORT,'New Orchestrator assignment operation requires a Subject principal'); END;
|
||||
CREATE TRIGGER ticket_subject_operation_update
|
||||
BEFORE UPDATE ON ticket_assignment_operations
|
||||
WHEN (NEW.role='orchestrator' AND NEW.action!='unassign' AND NEW.principal_kind!='subject')
|
||||
OR (NEW.principal_kind='subject' AND (NEW.role!='orchestrator'
|
||||
OR NEW.principal_id IS NULL OR length(trim(NEW.principal_id))=0
|
||||
OR NEW.runtime_id IS NOT NULL OR NEW.worker_id IS NOT NULL))
|
||||
BEGIN SELECT RAISE(ABORT,'New Orchestrator assignment operation requires a Subject principal'); END;
|
||||
|
||||
CREATE TRIGGER ticket_legacy_orchestrator_tracking_no_update
|
||||
BEFORE UPDATE OF tracking_json,last_tracking_event_id,tracking_updated_at ON ticket_worker_assignments
|
||||
WHEN OLD.role='orchestrator' AND OLD.principal_kind!='subject'
|
||||
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
|
||||
CREATE TRIGGER ticket_legacy_orchestrator_history_no_insert
|
||||
BEFORE INSERT ON typed_ticket_assignment_tracking_history
|
||||
WHEN EXISTS(SELECT 1 FROM ticket_worker_assignments a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.assignment_id
|
||||
AND a.role='orchestrator' AND a.principal_kind!='subject')
|
||||
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
|
||||
@@ -78,7 +78,17 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(marker, 10);
|
||||
assert_eq!(marker, 7);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT applied_at FROM ticket_schema_migrations WHERE version=7",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"legacy"
|
||||
);
|
||||
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
|
||||
assert_eq!(after_events, historical_events);
|
||||
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
|
||||
@@ -442,7 +452,7 @@ fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receip
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(
|
||||
rows(&connection, "ticket_schema_migrations")[0][0],
|
||||
rusqlite::types::Value::Integer(10)
|
||||
rusqlite::types::Value::Integer(8)
|
||||
);
|
||||
for (table, expected) in preserved.iter().zip(&before) {
|
||||
assert_eq!(&rows(&connection, table), expected, "changed {table}");
|
||||
@@ -852,3 +862,218 @@ fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket
|
||||
.unwrap();
|
||||
assert_eq!(failures, 0);
|
||||
}
|
||||
|
||||
fn compose_historical(connection: &Connection, target: HistoricalTicketSchemaTarget) {
|
||||
connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
migrate_historical_sqlite_ticket_schema_in_transaction(connection, target).unwrap();
|
||||
assert!(!connection.is_autocommit());
|
||||
connection.execute_batch("COMMIT").unwrap();
|
||||
}
|
||||
|
||||
fn max_component(connection: &Connection) -> i64 {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_composition_stops_at_each_explicit_boundary_and_preserves_applied_receipts() {
|
||||
use HistoricalTicketSchemaTarget::{Cases10, Content13, Posts12, Tracking11};
|
||||
// Both empty stores and real old schema7 preimages must stop at the chosen
|
||||
// contract, rather than installing the latest Subject schema prematurely.
|
||||
for retained in [false, true] {
|
||||
for (target, version) in [
|
||||
(Cases10, 10),
|
||||
(Tracking11, 11),
|
||||
(Posts12, 12),
|
||||
(Content13, 13),
|
||||
] {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
if retained {
|
||||
schema7(&conn);
|
||||
}
|
||||
assert!(migrate_historical_sqlite_ticket_schema_in_transaction(&conn, target).is_err());
|
||||
compose_historical(&conn, target);
|
||||
assert_eq!(max_component(&conn), version);
|
||||
let exists = |kind: &str, name: &str| -> bool {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)",
|
||||
params![kind, name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
exists("table", "typed_ticket_assignment_tracking_history"),
|
||||
version >= 11
|
||||
);
|
||||
assert_eq!(exists("index", "typed_ticket_events_ref"), version >= 12);
|
||||
assert_eq!(
|
||||
exists("table", "typed_ticket_content_changes"),
|
||||
version >= 13
|
||||
);
|
||||
assert!(!exists("trigger", "ticket_subject_current_insert"));
|
||||
if retained {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT name,applied_at FROM ticket_schema_migrations WHERE version=7",
|
||||
[],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
||||
)
|
||||
.unwrap(),
|
||||
("ticket schema baseline".into(), "legacy".into())
|
||||
);
|
||||
}
|
||||
let receipts = rows(&conn, "ticket_schema_migrations");
|
||||
compose_historical(&conn, target);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
// Older host hooks verify the actual newer historical generation;
|
||||
// they neither replay older steps nor change its receipts.
|
||||
compose_historical(&conn, Cases10);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
assert!(verify_sqlite_ticket_schema(&conn).is_err());
|
||||
}
|
||||
}
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
schema7(&conn);
|
||||
let mut prior = rows(&conn, "ticket_schema_migrations");
|
||||
for target in [Cases10, Tracking11, Posts12, Content13] {
|
||||
compose_historical(&conn, target);
|
||||
let after = rows(&conn, "ticket_schema_migrations");
|
||||
assert_eq!(&after[..prior.len()], prior.as_slice());
|
||||
prior = after;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_composition_verifies_actual_current_contract_without_downgrade_or_repair() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
let receipts = rows(&conn, "ticket_schema_migrations");
|
||||
for target in [
|
||||
HistoricalTicketSchemaTarget::Cases10,
|
||||
HistoricalTicketSchemaTarget::Tracking11,
|
||||
HistoricalTicketSchemaTarget::Posts12,
|
||||
HistoricalTicketSchemaTarget::Content13,
|
||||
] {
|
||||
compose_historical(&conn, target);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
// A lower requested target cannot hide a weakened actual14 guard.
|
||||
conn.execute_batch("DROP TRIGGER ticket_subject_current_insert; CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&conn,
|
||||
HistoricalTicketSchemaTarget::Cases10
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!conn.is_autocommit());
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
|
||||
// A claimed13 marker over actual10 must also be rejected, not backfilled.
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Cases10);
|
||||
conn.execute("UPDATE ticket_schema_migrations SET version=13", [])
|
||||
.unwrap();
|
||||
let before = rows(&conn, "ticket_schema_migrations");
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&conn,
|
||||
HistoricalTicketSchemaTarget::Cases10
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), before);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_content13_reopens_after_failed_subject_upgrade_then_migrates_atomically_to14() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("retained-content13.db");
|
||||
let (receipts, history, old_table);
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
|
||||
// Actual retained13 accepts its historical stable-agent contract. It
|
||||
// has not been created at14 and then merely relabelled as old.
|
||||
conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('workspace','t','t','Title','open','task','P2','open',1);
|
||||
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('workspace','t',-1,'t:-1','comment','Body');
|
||||
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('workspace','t','old','orchestrator','workspace_agent','workspace-orchestrator','old-author','old-time');
|
||||
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('workspace','t','old','orchestrator','workspace_agent','workspace-orchestrator','old-time');
|
||||
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id) VALUES('workspace','t','orchestrator','old-assigned','assigned','old','old-author','old-time','old-op');
|
||||
CREATE TRIGGER host_reject_subject_migration BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments BEGIN SELECT RAISE(ABORT,'forced post-widen failure'); END;").unwrap();
|
||||
receipts = rows(&conn, "ticket_schema_migrations");
|
||||
history = rows(&conn, "typed_ticket_assignment_tracking_history");
|
||||
old_table = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE; CREATE TABLE host_work(value TEXT); INSERT INTO host_work VALUES('keep');").unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
|
||||
assert!(!conn.is_autocommit());
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
assert_eq!(
|
||||
rows(&conn, "typed_ticket_assignment_tracking_history"),
|
||||
history
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
old_table
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.pragma_query_value(None, "writable_schema", |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
conn.execute_batch("COMMIT").unwrap();
|
||||
}
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
|
||||
assert_eq!(max_component(&conn), 13);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT value FROM host_work", [], |r| r.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"keep"
|
||||
);
|
||||
conn.execute_batch("DROP TRIGGER host_reject_subject_migration")
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(max_component(&conn), 14);
|
||||
assert_eq!(
|
||||
&rows(&conn, "ticket_schema_migrations")[..receipts.len()],
|
||||
receipts.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "typed_ticket_assignment_tracking_history"),
|
||||
history
|
||||
);
|
||||
drop(conn);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
let applied14 = rows(&conn, "ticket_schema_migrations");
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), applied14);
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ fn schema12_migrates_body_authors_archived_history_and_stable_references_then_re
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
14
|
||||
);
|
||||
backend
|
||||
.add_event("known".into(), comment("Next", "author"))
|
||||
|
||||
@@ -597,9 +597,9 @@ fn verifier_rejects_weakened_fact_constraints_trigger_and_migration_history() {
|
||||
assert!(migrate_sqlite_ticket_schema(&f.conn).is_err(), "{from}");
|
||||
}
|
||||
for mutation in [
|
||||
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=13",
|
||||
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=14",
|
||||
"INSERT INTO ticket_schema_migrations VALUES(12,'Canonical Ticket posts','invalid extra history')",
|
||||
"UPDATE ticket_schema_migrations SET version=14 WHERE version=13",
|
||||
"UPDATE ticket_schema_migrations SET version=15 WHERE version=14",
|
||||
] {
|
||||
let f = Fixture::new();
|
||||
f.conn.execute_batch(mutation).unwrap();
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::feature::{
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Assigned Subject starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OrchestrationFeature;
|
||||
@@ -84,7 +84,7 @@ struct SpawnTicketWorkerWorkdirInput {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnTicketWorkerInput {
|
||||
ticket_id: String,
|
||||
/// Observed current delegation identity. Required by Backend for an Orchestrator start; manual callers omit it.
|
||||
/// Observed current delegation identity. Required by Backend for an assigned Subject start; manual callers omit it.
|
||||
#[serde(default)]
|
||||
orchestrator_assignment_id: Option<String>,
|
||||
runtime_id: String,
|
||||
@@ -103,6 +103,10 @@ struct SpawnTicketWorkerInput {
|
||||
/// Workdirs. Backend derives effective capabilities; callers cannot request them.
|
||||
#[serde(default)]
|
||||
workdir_attachments: Vec<SpawnTicketWorkerWorkdirInput>,
|
||||
/// Existing general delegation contract. Backend validates the live Subject
|
||||
/// ceiling and requested Profile; Ticket assignment grants no capabilities.
|
||||
#[serde(default)]
|
||||
execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
|
||||
}
|
||||
|
||||
// Serde's ordinary Option would collapse explicit null and an absent field.
|
||||
@@ -170,7 +174,7 @@ impl Tool for SpawnTicketWorkerTool {
|
||||
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
|
||||
display_name: format!("Worker · {}", ticket.resource_key),
|
||||
initial_submit,
|
||||
execution_grant: None,
|
||||
execution_grant: input.execution_grant,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
@@ -339,6 +343,12 @@ mod tests {
|
||||
let mut input = input();
|
||||
input["flow"] = serde_json::json!("project:investigation");
|
||||
input["orchestrator_assignment_id"] = serde_json::json!("delegation-742");
|
||||
input["execution_grant"] = serde_json::json!({
|
||||
"permissions": [{"operation":"ticket.read","resource":{"kind":"ticket","workspace_id":"workspace-1","ticket_id":"T-719"}}],
|
||||
"redelegation": []
|
||||
});
|
||||
let expected_grant: server_api::authorization::WorkerDelegationRequest =
|
||||
serde_json::from_value(input["execution_grant"].clone()).unwrap();
|
||||
input["workdir_attachments"] = serde_json::json!([
|
||||
{
|
||||
"alias": "checkout",
|
||||
@@ -359,6 +369,7 @@ mod tests {
|
||||
assert_eq!(requests.len(), 1);
|
||||
let request = &requests[0];
|
||||
assert_eq!(request.runtime_id, "runtime-1");
|
||||
assert_eq!(request.execution_grant.as_ref(), Some(&expected_grant));
|
||||
assert_eq!(
|
||||
request.orchestrator_assignment_id.as_deref(),
|
||||
Some("delegation-742")
|
||||
|
||||
@@ -145,13 +145,13 @@ impl PublicAssignmentKind {
|
||||
pub(super) fn description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"TicketOrchestratorAssign" => {
|
||||
"Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
|
||||
"Explicitly delegate or reissue this Ticket to a target Subject principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
|
||||
}
|
||||
"TicketOrchestratorClear" => {
|
||||
"Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
|
||||
"Release the observed current Subject delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
|
||||
}
|
||||
"TicketAssignmentsQuery" => {
|
||||
"Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
|
||||
"Read a bounded page of retained Subject assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
|
||||
}
|
||||
"TicketAssignmentDiagnostics" => {
|
||||
"Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state."
|
||||
@@ -343,19 +343,8 @@ async fn execute_value(
|
||||
identity("operation_id", &input.operation_id)?;
|
||||
optional_identity("expected_assignment_id", &input.expected_assignment_id)?;
|
||||
match &input.principal {
|
||||
server_api::TicketAssignmentPrincipal::User { account_id } => {
|
||||
identity("account_id", account_id)?
|
||||
}
|
||||
server_api::TicketAssignmentPrincipal::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => {
|
||||
identity("runtime_id", runtime_id)?;
|
||||
identity("worker_id", worker_id)?;
|
||||
}
|
||||
server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
identity("agent_key", agent_key)?
|
||||
}
|
||||
server_api::TicketAssignmentPrincipal::Subject { subject_id } => identity("subject_id", subject_id)?,
|
||||
_ => return Err(ToolError::InvalidArgument("New Ticket responsibility requires an explicit Subject principal; legacy principals are audit-only".into())),
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentSet {
|
||||
ticket_id: input.ticket,
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(super) fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
|
||||
vec![
|
||||
(
|
||||
PublicAssignmentKind::Assign,
|
||||
json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
|
||||
json!({"ticket":"T-743", "principal":{"kind":"subject", "subject_id":"subject-a"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
|
||||
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
|
||||
),
|
||||
(
|
||||
@@ -164,6 +164,26 @@ async fn public_assignment_schemas_require_observations_and_exclude_model_actor_
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn new_assignment_rejects_retained_legacy_principals_before_transport() {
|
||||
for principal in [
|
||||
json!({"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}),
|
||||
json!({"kind":"user", "account_id":"owner"}),
|
||||
json!({"kind":"worker", "runtime_id":"r", "worker_id":"old-body"}),
|
||||
] {
|
||||
let client = transport(200, json!({}));
|
||||
let (_, tool) = definition(client.clone(), PublicAssignmentKind::Assign)();
|
||||
let input = json!({"ticket":"T-743", "principal":principal,
|
||||
"expected_assignment_id":null, "operation_id":"legacy-is-not-new-authority"});
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() {
|
||||
for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| {
|
||||
|
||||
@@ -79,7 +79,7 @@ pub(super) fn description(name: &str) -> &'static str {
|
||||
"Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request."
|
||||
}
|
||||
"TicketAssignmentTrackingUpdate" => {
|
||||
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
|
||||
"Record the current assigned Subject's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current Subject body; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
|
||||
}
|
||||
_ => unreachable!("unknown assignment tracking tool"),
|
||||
}
|
||||
|
||||
@@ -99,6 +99,10 @@ fn principal() -> Shape {
|
||||
union(
|
||||
"kind",
|
||||
vec![
|
||||
case(
|
||||
"subject",
|
||||
Some(record(vec![p("subject_id", true, Shape::String)])),
|
||||
),
|
||||
case(
|
||||
"user",
|
||||
Some(record(vec![p("account_id", true, Shape::String)])),
|
||||
@@ -117,6 +121,15 @@ fn principal() -> Shape {
|
||||
],
|
||||
)
|
||||
}
|
||||
fn assignment_principal() -> Shape {
|
||||
union(
|
||||
"kind",
|
||||
vec![case(
|
||||
"subject",
|
||||
Some(record(vec![p("subject_id", true, Shape::String)])),
|
||||
)],
|
||||
)
|
||||
}
|
||||
fn reason() -> Shape {
|
||||
// An externally tagged domain enum is encoded by this single native union definition.
|
||||
Shape::ExternalUnion {
|
||||
@@ -387,7 +400,7 @@ pub(super) fn contract(op: &str) -> Contract {
|
||||
"orchestrator_assign",
|
||||
"Delegate or explicitly reissue responsibility; no Worker starts. Observed null means unassigned, not unknown. Exact retries retain operation identity and full input.",
|
||||
vec![
|
||||
p("principal", true, principal()),
|
||||
p("principal", true, assignment_principal()),
|
||||
group(
|
||||
"observed",
|
||||
true,
|
||||
|
||||
@@ -198,12 +198,32 @@ fn native_entry_inputs_use_allocated_namespace_and_never_subject_argument_ids()
|
||||
|
||||
#[test]
|
||||
fn assignment_principal_and_cas_are_declared_compounds_not_opaque_json() {
|
||||
let value = json!({"principal":{"$case":"worker","value":{"runtime_id":"r","worker_id":"w"}},"observed":{"assignment_id":{"$case":"null"}},"operation_id":"assign-1"});
|
||||
let value = json!({"principal":{"$case":"subject","value":{"subject_id":"subject-a"}},"observed":{"assignment_id":{"$case":"null"}},"operation_id":"assign-1"});
|
||||
let command = converted("orchestrator_assign", value.clone(), Some("T-42"));
|
||||
assert_eq!(
|
||||
command,
|
||||
json!({"ticket":"T-42","principal":{"kind":"worker","runtime_id":"r","worker_id":"w"},"expected_assignment_id":null,"operation_id":"assign-1"})
|
||||
json!({"ticket":"T-42","principal":{"kind":"subject","subject_id":"subject-a"},"expected_assignment_id":null,"operation_id":"assign-1"})
|
||||
);
|
||||
for legacy in [
|
||||
json!({"$case":"worker","value":{"runtime_id":"r","worker_id":"w"}}),
|
||||
json!({"$case":"user","value":{"account_id":"a"}}),
|
||||
json!({"$case":"workspace_agent","value":{"agent_key":"workspace-orchestrator"}}),
|
||||
] {
|
||||
let mut assignment = value.clone();
|
||||
assignment["principal"] = legacy;
|
||||
assert!(
|
||||
input(
|
||||
"orchestrator_assign",
|
||||
&arguments(assignment),
|
||||
Some("T-42"),
|
||||
Some("ticket"),
|
||||
"/cases",
|
||||
"/changes",
|
||||
"/goals"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for invalid in [
|
||||
{
|
||||
let mut v = value.clone();
|
||||
|
||||
@@ -606,7 +606,7 @@ mod tests {
|
||||
"only `open`",
|
||||
"stable tag IDs, not display names",
|
||||
"no ready step or separate Queue is required",
|
||||
"Orchestrator's self-reported handling",
|
||||
"assigned Subject's self-reported handling",
|
||||
"Answers during waiting or ongoing handling continue the same assignment row",
|
||||
"explicit reissue creates a new row/assignment_id",
|
||||
"Respect an explicit user approval condition",
|
||||
@@ -684,9 +684,9 @@ mod tests {
|
||||
)));
|
||||
for (policy, name, input) in [
|
||||
(
|
||||
"Use `TicketOrchestratorAssign` for explicit initial delegation or reissue",
|
||||
"Use `TicketOrchestratorAssign` for explicit initial assignment, reassignment or reissue to the selected Subject principal",
|
||||
"TicketOrchestratorAssign",
|
||||
serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
|
||||
serde_json::json!({"ticket":"T-743","principal":{"kind":"subject","subject_id":"subject-a"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
|
||||
),
|
||||
(
|
||||
"research returned to a human can be `open` + handling `done`",
|
||||
|
||||
@@ -1007,6 +1007,9 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
||||
}),
|
||||
feature: manifest.feature.clone().into(),
|
||||
mcp: manifest.mcp.clone(),
|
||||
// Reusable selection is not a plan or delegation authority. Backend launch
|
||||
// must resolve it again within the child's own Workspace/delegation bound.
|
||||
plugins: Some(manifest.plugins.clone()),
|
||||
compaction: manifest
|
||||
.compaction
|
||||
.as_ref()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Retained migration SQL establishes origins only for reconciled assignment-specific legacy
|
||||
//! Queue fences; migration does not reinterpret historical comments as new requests.
|
||||
//!
|
||||
//! Reserve and prepare recheck delegation, singleton and live binding under
|
||||
//! Reserve and prepare recheck delegation, Subject body and live binding under
|
||||
//! IMMEDIATE ownership. ACK instead verifies the target authorized before I/O;
|
||||
//! a later responsibility change must not erase Runtime acceptance evidence.
|
||||
//! Persist the entire rendered request before Runtime I/O, then reuse
|
||||
@@ -141,16 +141,23 @@ pub(super) fn list_in_tx(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
subject_id: Option<&str>,
|
||||
) -> Result<Vec<TicketAssignmentDelivery>> {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(Error::InvalidInput("Delivery limit must be 1..100".into()));
|
||||
}
|
||||
notification_routing::pending_sources(conn, workspace_id, "orchestrator", limit)?
|
||||
.into_iter()
|
||||
.map(|(ticket, assignment, input, id)| {
|
||||
delivery(conn, workspace_id, ticket, assignment, input, id)
|
||||
})
|
||||
.collect()
|
||||
notification_routing::pending_sources_for_subject(
|
||||
conn,
|
||||
workspace_id,
|
||||
"orchestrator",
|
||||
limit,
|
||||
subject_id,
|
||||
)?
|
||||
.into_iter()
|
||||
.map(|(ticket, assignment, input, id)| {
|
||||
delivery(conn, workspace_id, ticket, assignment, input, id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn delivery(
|
||||
conn: &Connection,
|
||||
@@ -199,7 +206,7 @@ fn source_guard(
|
||||
workspace_id,
|
||||
&source.runtime_id,
|
||||
&source.worker_id,
|
||||
&source.agent_key,
|
||||
&source.subject_id,
|
||||
&source.authentication_binding_id,
|
||||
)
|
||||
}
|
||||
@@ -226,7 +233,31 @@ pub(super) fn require_current(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Call only after source_guard and require_current in the reservation/preparation
|
||||
// Runtime source authentication alone is insufficient: two live Subject bodies can
|
||||
// coexist in one Workspace. Fence the recipient's Subject before any receipt replay
|
||||
// or target authorization, in the same write transaction as the delivery/start.
|
||||
fn require_assignment_target(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
subject_id: &str,
|
||||
) -> Result<()> {
|
||||
require_current(conn, workspace_id, ticket_id, assignment_id)?;
|
||||
let matches: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND role='orchestrator' AND principal_kind='subject' AND principal_id=?4)",
|
||||
params![workspace_id, ticket_id, assignment_id, subject_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !matches {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Delivery/start target Subject differs from the current assignment".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Call only after source_guard and require_assignment_target in the reservation/preparation
|
||||
// transaction. A replacement target gets its own evidence for the same delivery,
|
||||
// not a new request or authority to revive a completed/released assignment.
|
||||
fn save_authorized_target(
|
||||
@@ -242,13 +273,30 @@ fn save_authorized_target(
|
||||
delivery,
|
||||
source.runtime_id,
|
||||
source.worker_id,
|
||||
source.agent_key,
|
||||
source.subject_id,
|
||||
source.authentication_binding_id
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_acknowledged(conn: &Connection, workspace: &str, delivery: &str) -> Result<bool> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2)",
|
||||
params![workspace, delivery], |row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
fn require_unacknowledged(conn: &Connection, workspace: &str, delivery: &str) -> Result<()> {
|
||||
if is_acknowledged(conn, workspace, delivery)? {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Assignment delivery already has Runtime acceptance; new send admission is closed"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn reserve(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
@@ -260,9 +308,9 @@ pub(super) fn reserve(
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
source_guard(&tx,workspace_id,source)?;
|
||||
let ticket_id:String=tx.query_row("SELECT ticket_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,assignment_id],|r|r.get(0))?;
|
||||
require_current(&tx,workspace_id,&ticket_id,assignment_id)?;
|
||||
require_assignment_target(&tx,workspace_id,&ticket_id,assignment_id,&source.subject_id)?;
|
||||
let saved:Option<String>=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND assignment_id=?2 AND input_event_id=?3",params![workspace_id,assignment_id,input_event_id],|r|r.get(0)).optional()?;
|
||||
if let Some(saved)=saved {let result=decode_payload(&saved)?;save_authorized_target(&tx,workspace_id,&result.delivery_id,source)?;tx.commit()?;return Ok(result);}
|
||||
if let Some(saved)=saved {let result=decode_payload(&saved)?;require_unacknowledged(&tx,workspace_id,&result.delivery_id)?;save_authorized_target(&tx,workspace_id,&result.delivery_id,source)?;tx.commit()?;return Ok(result);}
|
||||
// A caller cannot fabricate an input. Restrict the same common query by
|
||||
// exact assignment/input identity rather than trusting a scan snapshot.
|
||||
let candidates=inputs_in_tx(&tx,workspace_id,1,Some(assignment_id),Some(input_event_id))?;
|
||||
@@ -295,7 +343,8 @@ pub(super) fn prepare_content(
|
||||
source_guard(&tx,workspace_id,source)?;
|
||||
let payload:String=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0))?;
|
||||
let delivery=decode_payload(&payload)?;
|
||||
require_current(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id)?;
|
||||
require_assignment_target(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id,&source.subject_id)?;
|
||||
require_unacknowledged(&tx,workspace_id,delivery_id)?;
|
||||
save_authorized_target(&tx,workspace_id,delivery_id,source)?;
|
||||
let saved:Option<String>=tx.query_row("SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?;
|
||||
let result=if let Some(saved)=saved {saved} else {
|
||||
@@ -307,6 +356,46 @@ pub(super) fn prepare_content(
|
||||
})
|
||||
}
|
||||
|
||||
/// Final send admission is distinct from reading a receipt or replaying an ACK.
|
||||
/// Recheck the current Subject/assignment and prepared target, then return only
|
||||
/// the pinned bytes. A known ACK closes admission; an unknown outcome keeps the
|
||||
/// same request eligible. The caller still authorizes live policy immediately
|
||||
/// before Runtime I/O; the transaction does not serialize external acceptance.
|
||||
pub(super) fn admit_send(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<Option<String>> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
source_guard(&tx, workspace_id, source)?;
|
||||
let payload: String = tx.query_row(
|
||||
"SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
params![workspace_id, delivery_id], |row| row.get(0),
|
||||
)?;
|
||||
let delivery = decode_payload(&payload)?;
|
||||
require_assignment_target(&tx, workspace_id, &delivery.ticket_id, &delivery.assignment_id, &source.subject_id)?;
|
||||
let authorized: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",
|
||||
params![workspace_id, delivery_id, source.runtime_id, source.worker_id, source.subject_id, source.authentication_binding_id], |row| row.get(0),
|
||||
)?;
|
||||
if !authorized {
|
||||
return Err(Error::WorkspacePermissionDenied("Assignment send target was not prepared or reserved for this delivery".into()));
|
||||
}
|
||||
let content = if is_acknowledged(&tx, workspace_id, delivery_id)? {
|
||||
None
|
||||
} else {
|
||||
Some(tx.query_row(
|
||||
"SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
params![workspace_id, delivery_id], |row| row.get(0),
|
||||
).optional()?.ok_or_else(|| Error::TicketAssignmentConflict("Assignment send requires a persisted request".into()))?)
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(content)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn ack(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
@@ -319,7 +408,7 @@ pub(super) fn ack(
|
||||
// authenticated target identity fields against evidence saved while current.
|
||||
// Actor is audit attribution; it is not target authority. Existing ACK bytes
|
||||
// still have to match on replay and are never replaced.
|
||||
let authorized:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",params![workspace_id,delivery_id,source.runtime_id,source.worker_id,source.agent_key,source.authentication_binding_id],|r|r.get(0))?;
|
||||
let authorized:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",params![workspace_id,delivery_id,source.runtime_id,source.worker_id,source.subject_id,source.authentication_binding_id],|r|r.get(0))?;
|
||||
if !authorized {return Err(Error::WorkspacePermissionDenied("Acknowledgement target was not authorized for this delivery".into()));}
|
||||
let source_json=serde_json::to_string(source).map_err(|e|Error::Store(e.to_string()))?;
|
||||
let saved:Option<String>=tx.query_row("SELECT source_json FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?;
|
||||
@@ -339,16 +428,16 @@ pub(super) fn operation_guard(
|
||||
operation_id: &str,
|
||||
) -> Result<()> {
|
||||
let fence=conn.query_row("SELECT o.ticket_id,f.orchestrator_assignment_id,f.runtime_id,f.worker_id,f.agent_key,f.authentication_binding_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?))).optional()?;
|
||||
if let Some((ticket_id, assignment_id, runtime_id, worker_id, agent_key, binding_id)) = fence {
|
||||
if let Some((ticket_id, assignment_id, runtime_id, worker_id, subject_id, binding_id)) = fence {
|
||||
assignment_tracking::require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
&runtime_id,
|
||||
&worker_id,
|
||||
&agent_key,
|
||||
&subject_id,
|
||||
&binding_id,
|
||||
)?;
|
||||
require_current(conn, workspace_id, &ticket_id, &assignment_id)?;
|
||||
require_assignment_target(conn, workspace_id, &ticket_id, &assignment_id, &subject_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -363,7 +452,13 @@ pub(super) fn save_operation_fence(
|
||||
existing: bool,
|
||||
) -> Result<()> {
|
||||
source_guard(conn, workspace_id, source)?;
|
||||
require_current(conn, workspace_id, ticket_id, assignment_id)?;
|
||||
require_assignment_target(
|
||||
conn,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
assignment_id,
|
||||
&source.subject_id,
|
||||
)?;
|
||||
let source_json = serde_json::to_string(source).map_err(|e| Error::Store(e.to_string()))?;
|
||||
let saved:Option<(String,String)>=conn.query_row("SELECT orchestrator_assignment_id,source_json FROM ticket_assignment_operation_fences WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
|
||||
if let Some(saved) = saved {
|
||||
@@ -386,7 +481,7 @@ pub(super) fn save_operation_fence(
|
||||
assignment_id,
|
||||
source.runtime_id,
|
||||
source.worker_id,
|
||||
source.agent_key,
|
||||
source.subject_id,
|
||||
source.authentication_binding_id,
|
||||
source_json
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ pub enum TicketOrchestratorAssignmentSourceContext {
|
||||
Runtime {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
#[serde(rename = "subject_id")]
|
||||
agent_key: String,
|
||||
authentication_binding_id: String,
|
||||
},
|
||||
@@ -46,9 +47,25 @@ pub(super) fn require_runtime_source(
|
||||
agent_key: &str,
|
||||
binding_id: &str,
|
||||
) -> Result<()> {
|
||||
let current_source:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM worker_singleton_owners singleton JOIN workspace_runtime_bindings binding ON binding.workspace_id=singleton.workspace_id AND binding.runtime_id=singleton.runtime_id WHERE singleton.workspace_id=?1 AND singleton.singleton_key=?2 AND singleton.runtime_id=?3 AND singleton.worker_id=?4 AND binding.binding_id=?5 AND binding.revoked_at IS NULL)",params![workspace_id,crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,runtime_id,worker_id,binding_id],|row|row.get(0))?;
|
||||
if agent_key != "workspace-orchestrator" || binding_id.trim().is_empty() || !current_source {
|
||||
return Err(Error::WorkspacePermissionDenied("Assignment source is not the current registered Orchestrator with its authenticated Runtime binding".into()));
|
||||
// The legacy field name is retained in immutable source receipts, but only a
|
||||
// Subject identity can authenticate this self-report/delivery boundary.
|
||||
// In particular, never reinterpret the old workspace-agent identity.
|
||||
if agent_key == "workspace-orchestrator" || agent_key.trim().is_empty() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Legacy assignment sources are audit-only".into(),
|
||||
));
|
||||
}
|
||||
let key = crate::subjektiv::subject_worker_singleton_key(agent_key)
|
||||
.map_err(|error| Error::WorkspacePermissionDenied(error.to_string()))?;
|
||||
let current_source: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM worker_singleton_owners s
|
||||
JOIN worker_create_reservations r ON r.workspace_id=s.workspace_id AND r.runtime_id=s.runtime_id AND r.worker_id=s.worker_id AND r.singleton_key=s.singleton_key AND r.singleton_generation=s.generation AND r.state!='removed'
|
||||
JOIN workspace_runtime_bindings b ON b.workspace_id=s.workspace_id AND b.runtime_id=s.runtime_id
|
||||
WHERE s.workspace_id=?1 AND s.singleton_key=?2 AND s.runtime_id=?3 AND s.worker_id=?4 AND b.binding_id=?5 AND b.revoked_at IS NULL
|
||||
AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.workspace_id=s.workspace_id AND d.subject_id=?6))",
|
||||
params![workspace_id, key, runtime_id, worker_id, binding_id, agent_key], |row| row.get(0))?;
|
||||
if binding_id.trim().is_empty() || !current_source {
|
||||
return Err(Error::WorkspacePermissionDenied("Assignment source is not the current Subject body with its authenticated Runtime binding and generation".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,14 +81,25 @@ pub(super) fn require_assignment_source(
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
} => require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
),
|
||||
} => {
|
||||
let live: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_runtime_bindings b JOIN worker_registry w ON w.workspace_id=b.workspace_id AND w.runtime_id=b.runtime_id WHERE b.workspace_id=?1 AND b.runtime_id=?2 AND b.binding_id=?3 AND b.revoked_at IS NULL AND w.worker_id=?4)",params![workspace_id,runtime_id,authentication_binding_id,worker_id], |r|r.get(0))?;
|
||||
if !live || agent_key == "workspace-orchestrator" {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Assignment source binding is no longer live".into(),
|
||||
));
|
||||
}
|
||||
if !agent_key.is_empty() {
|
||||
require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
TicketOrchestratorAssignmentSourceContext::Account {
|
||||
account_id,
|
||||
user_id,
|
||||
@@ -110,7 +138,7 @@ pub(super) fn update(
|
||||
workspace_id,
|
||||
&source.runtime_id,
|
||||
&source.worker_id,
|
||||
&source.agent_key,
|
||||
&source.subject_id,
|
||||
&source.authentication_binding_id,
|
||||
)?;
|
||||
let result = ticket::sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
|
||||
@@ -4,7 +4,7 @@ fn tracking_store_source() -> TicketAssignmentTrackingSourceContext {
|
||||
actor: "authenticated-worker:r/worker".into(),
|
||||
runtime_id: "r".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
subject_id: "tracking-subject".into(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ async fn tracking_store_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
|
||||
seed_manual_start_ticket(&store, "tracking", "open");
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('w','r','Runtime','https://runtime.invalid','verified-fixture-key','verified-fixture-fingerprint','tracking-binding','workspace_identity','1','1')",[])?;
|
||||
conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",[crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY])?;
|
||||
conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",["subjektiv:tracking-subject"])?;
|
||||
conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','replacement','Replacement','normal','1','1')",[])?;
|
||||
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at,singleton_key,singleton_generation) VALUES('w','tracking-body','worker','r','tracking-body','created','1','1','subjektiv:tracking-subject',1)",[])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
(directory, store)
|
||||
@@ -29,8 +30,8 @@ fn tracking_store_assign(
|
||||
let assignment = TicketRoleAssignmentRecord {
|
||||
assignment_id: id.into(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
},
|
||||
..manual_worker_assignment("tracking", id)
|
||||
};
|
||||
@@ -62,7 +63,15 @@ fn tracking_store_input(id: &str, event: &str, key: &str) -> TicketAssignmentTra
|
||||
#[tokio::test]
|
||||
async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_binding_under_write_lock()
|
||||
{
|
||||
for change in ["replacement", "revoke", "binding-change", "remove"] {
|
||||
for change in [
|
||||
"replacement",
|
||||
"revoke",
|
||||
"binding-change",
|
||||
"generation",
|
||||
"reservation-removed",
|
||||
"subject-deleted",
|
||||
"remove",
|
||||
] {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
let source = tracking_store_source();
|
||||
@@ -86,6 +95,9 @@ async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_
|
||||
"replacement" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;},
|
||||
"revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;},
|
||||
"binding-change" => {tx.execute("UPDATE workspace_runtime_bindings SET binding_id='new-binding' WHERE workspace_id='w'",[])?;},
|
||||
"generation" => {tx.execute("UPDATE worker_create_reservations SET singleton_generation=2 WHERE workspace_id='w'",[])?;},
|
||||
"reservation-removed" => {tx.execute("UPDATE worker_create_reservations SET state='removed' WHERE workspace_id='w'",[])?;},
|
||||
"subject-deleted" => {tx.execute("INSERT INTO subject_deletions(workspace_id,subject_id,operation_id) VALUES('w','tracking-subject','delete-subject')",[])?;},
|
||||
_ => {tx.execute("DELETE FROM worker_singleton_owners WHERE workspace_id='w'",[])?;},
|
||||
}
|
||||
tx.commit()?;Ok(())
|
||||
@@ -470,17 +482,41 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
95,
|
||||
"Common notification routing and durable Worker delivery".into()
|
||||
),
|
||||
(96,"Backend Job common notification routing".into()),
|
||||
(97, "MR notifications and authenticated implementation results".into()),
|
||||
(96, "Backend Job common notification routing".into()),
|
||||
(
|
||||
97,
|
||||
"MR notifications and authenticated implementation results".into()
|
||||
),
|
||||
(98, "Assignment-independent implementation reports".into()),
|
||||
(99, "Explicit hosting administrators and self-registration policy".into()),
|
||||
(100, "Provisional users and scoped passkey activation".into()),
|
||||
(
|
||||
99,
|
||||
"Explicit hosting administrators and self-registration policy".into()
|
||||
),
|
||||
(
|
||||
100,
|
||||
"Provisional users and scoped passkey activation".into()
|
||||
),
|
||||
(101, "Durable explicit Subject body release".into()),
|
||||
(102, "Durable failed Subject creation recovery".into()),
|
||||
(103, "Passkey metadata and atomic addition results".into()),
|
||||
(104, "Converge Passkey results and Job input retention".into()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into())
|
||||
(
|
||||
104,
|
||||
"Converge Passkey results and Job input retention".into()
|
||||
),
|
||||
(
|
||||
105,
|
||||
"Durable Subject deletion snapshots and Job admission fences".into()
|
||||
),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
|
||||
(
|
||||
107,
|
||||
"Immutable Worker Plugin plans and retained pins".into()
|
||||
),
|
||||
(
|
||||
108,
|
||||
"Subject Ticket responsibility and current-body notification routing"
|
||||
.into()
|
||||
)
|
||||
]
|
||||
);
|
||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||
@@ -554,10 +590,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first_self_report() {
|
||||
fn tracking_store_schema91_closed_unknown_assignment_retains_assign_audit_but_legacy_cannot_self_report()
|
||||
{
|
||||
use ticket::TicketBackend;
|
||||
|
||||
for report_after_reopen in [false, true] {
|
||||
for reopen_before_report in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("closed-unknown91.db");
|
||||
let conn = tracking_schema91_fixture(&path);
|
||||
@@ -568,27 +604,8 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
|
||||
let grants_before = stored_fixture_rows(&conn, "worker_control_grants");
|
||||
let releases_before = stored_fixture_rows(&conn, "ticket_assignment_work_releases");
|
||||
drop(conn);
|
||||
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap();
|
||||
let reopen = || {
|
||||
let observed = backend.show("historical-done".into()).unwrap();
|
||||
backend
|
||||
.update_case(
|
||||
"historical-done",
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(&observed),
|
||||
expected_state: ticket::TicketState::Closed,
|
||||
expected_closure_operation_key: observed.closure_operation_key.clone(),
|
||||
operation_key: "explicit-reopen".into(),
|
||||
action: ticket::TicketCaseAction::Reopen,
|
||||
explanation: "Reopen without redelegation or restoring old work".into(),
|
||||
references: vec![],
|
||||
author: Some("account-author".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
let unknown = store
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
@@ -602,11 +619,59 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
|
||||
Some("assigned-historical-done")
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_current_ticket_assignment_tracking("w", "historical-done")
|
||||
.unwrap(),
|
||||
vec![unknown.clone()]
|
||||
unknown.principal,
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into()
|
||||
}
|
||||
);
|
||||
if reopen_before_report {
|
||||
let observed = backend.show("historical-done".into()).unwrap();
|
||||
backend
|
||||
.update_case(
|
||||
"historical-done",
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(&observed),
|
||||
expected_state: ticket::TicketState::Closed,
|
||||
expected_closure_operation_key: observed.closure_operation_key,
|
||||
operation_key: "explicit-reopen".into(),
|
||||
action: ticket::TicketCaseAction::Reopen,
|
||||
explanation: "Reopen is not Subject assignment or Worker restore".into(),
|
||||
references: vec![],
|
||||
author: Some("account-author".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut legacy = tracking_store_source();
|
||||
legacy.subject_id = "workspace-orchestrator".into();
|
||||
let input = tracking_store_input(
|
||||
"a-historical-done",
|
||||
unknown.last_event_id.as_deref().unwrap(),
|
||||
"legacy-result",
|
||||
);
|
||||
// Both the old identity and an unrelated Subject must fail even when
|
||||
// they possess the authentic historical Assign observation.
|
||||
let second = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
for source in [&legacy, &tracking_store_source()] {
|
||||
for observer in [&store, &second] {
|
||||
assert!(matches!(
|
||||
observer.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"historical-done",
|
||||
&input,
|
||||
source
|
||||
),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
assert_eq!(
|
||||
observer
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.list_ticket_assignment_tracking_history(
|
||||
@@ -617,129 +682,6 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 13);
|
||||
assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='historical-done'", [], |row| row.get::<_,String>(0))?, "closed");
|
||||
let stored: (Option<String>, Option<String>) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
|
||||
assert_eq!(stored, (None, None), "read baseline must not manufacture tracking storage");
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
if report_after_reopen {
|
||||
reopen();
|
||||
}
|
||||
let source = tracking_store_source();
|
||||
let input = tracking_store_input(
|
||||
"a-historical-done",
|
||||
unknown.last_event_id.as_deref().unwrap(),
|
||||
"first-explicit-result",
|
||||
);
|
||||
let mut wrong_source = source.clone();
|
||||
wrong_source.worker_id = "not-current-orchestrator".into();
|
||||
assert!(matches!(
|
||||
store.update_ticket_assignment_tracking("w", "historical-done", &input, &wrong_source),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
for (workspace, ticket) in [("other-workspace", "historical-done"), ("w", "pending")] {
|
||||
assert!(
|
||||
store
|
||||
.update_ticket_assignment_tracking(workspace, ticket, &input, &source)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"replaced",
|
||||
&tracking_store_input(
|
||||
"a-replaced",
|
||||
"assigned-replaced",
|
||||
"wrong-old-assignment"
|
||||
),
|
||||
&source
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
// A second observer holds the same Assign baseline. Only the first
|
||||
// explicit writer may consume it; BEGIN IMMEDIATE serializes the CAS.
|
||||
let second = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert_eq!(
|
||||
second
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
unknown
|
||||
);
|
||||
let done = store
|
||||
.update_ticket_assignment_tracking("w", "historical-done", &input, &source)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
done.tracking.as_ref().unwrap().status,
|
||||
TicketAssignmentTrackingStatus::Done
|
||||
);
|
||||
assert_eq!(done.assignment_id, unknown.assignment_id);
|
||||
assert_ne!(done.last_event_id, unknown.last_event_id);
|
||||
assert_eq!(
|
||||
store
|
||||
.update_ticket_assignment_tracking("w", "historical-done", &input, &source)
|
||||
.unwrap(),
|
||||
done
|
||||
);
|
||||
assert!(matches!(
|
||||
second.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"historical-done",
|
||||
&tracking_store_input(
|
||||
"a-historical-done",
|
||||
"assigned-historical-done",
|
||||
"stale-second-observer"
|
||||
),
|
||||
&source
|
||||
),
|
||||
Err(Error::TicketAssignmentConflict(_))
|
||||
));
|
||||
assert_eq!(
|
||||
backend
|
||||
.show("historical-done".into())
|
||||
.unwrap()
|
||||
.meta
|
||||
.workflow_state,
|
||||
if report_after_reopen {
|
||||
ticket::TicketState::Open
|
||||
} else {
|
||||
ticket::TicketState::Closed
|
||||
}
|
||||
);
|
||||
if !report_after_reopen {
|
||||
reopen();
|
||||
}
|
||||
let after_reopen = store
|
||||
.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"historical-done",
|
||||
&tracking_store_input(
|
||||
"a-historical-done",
|
||||
done.last_event_id.as_deref().unwrap(),
|
||||
"explicit-result-after-reopen",
|
||||
),
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let history = store
|
||||
.list_ticket_assignment_tracking_history("w", "historical-done", "a-historical-done")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
history.len(),
|
||||
2,
|
||||
"only explicit self-reports create handling events"
|
||||
);
|
||||
assert_eq!(history[0].event_id, done.last_event_id.clone().unwrap());
|
||||
assert_eq!(
|
||||
history[1].event_id,
|
||||
after_reopen.last_event_id.clone().unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_active_ticket_role_assignment(
|
||||
@@ -751,56 +693,40 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
|
||||
.is_none()
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id='w' AND ticket_id='historical-done'", [], |row| row.get::<_,i64>(0))?, 1);
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before);
|
||||
assert_eq!(stored_fixture_rows(conn, "worker_control_grants"), grants_before);
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_work_releases"), releases_before);
|
||||
let stored: (Option<String>, Option<String>) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
|
||||
assert_eq!(stored, (None, None), "Assign audit baseline must not manufacture handling or authority");
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| row.get::<_,i64>(0))?, 0);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
backend
|
||||
.show("historical-done".into())
|
||||
.unwrap()
|
||||
.meta
|
||||
.workflow_state,
|
||||
if reopen_before_report {
|
||||
ticket::TicketState::Open
|
||||
} else {
|
||||
ticket::TicketState::Closed
|
||||
}
|
||||
);
|
||||
drop(second);
|
||||
drop(store);
|
||||
let reopened_store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert_eq!(
|
||||
reopened_store
|
||||
reopened
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
after_reopen
|
||||
);
|
||||
assert!(
|
||||
reopened_store
|
||||
.clear_current_ticket_orchestrator_assignment(
|
||||
"w",
|
||||
"historical-done",
|
||||
"a-historical-done",
|
||||
"explicit-release-event",
|
||||
"explicit-release",
|
||||
"human",
|
||||
"3",
|
||||
Some("Return responsibility"),
|
||||
&orchestrator_mutation_runtime_source()
|
||||
)
|
||||
.unwrap()
|
||||
unknown
|
||||
);
|
||||
assert!(matches!(
|
||||
reopened_store.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"historical-done",
|
||||
&tracking_store_input(
|
||||
"a-historical-done",
|
||||
after_reopen.last_event_id.as_deref().unwrap(),
|
||||
"new-report-after-release"
|
||||
),
|
||||
&source
|
||||
),
|
||||
Err(Error::TicketAssignmentConflict(_))
|
||||
reopened.update_ticket_assignment_tracking("w", "historical-done", &input, &legacy),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
let released = reopened_store
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!released.current);
|
||||
assert_eq!(released.tracking, after_reopen.tracking);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,7 +784,7 @@ fn orchestrator_mutation_runtime_source() -> TicketOrchestratorAssignmentSourceC
|
||||
TicketOrchestratorAssignmentSourceContext::Runtime {
|
||||
runtime_id: "r".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
agent_key: "tracking-subject".into(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
}
|
||||
}
|
||||
@@ -866,8 +792,8 @@ fn orchestrator_mutation_record(id: &str) -> TicketRoleAssignmentRecord {
|
||||
TicketRoleAssignmentRecord {
|
||||
assignment_id: id.into(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
},
|
||||
assigned_by: "readable Backend attribution".into(),
|
||||
..manual_worker_assignment("tracking", id)
|
||||
@@ -984,7 +910,7 @@ async fn orchestrator_assignment_receipts_bind_verified_source_even_when_readabl
|
||||
authentication_binding_id: "replacement-binding".into(),
|
||||
runtime_id: "r".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
agent_key: "tracking-subject".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
store.set_current_ticket_orchestrator_assignment(
|
||||
@@ -1349,8 +1275,8 @@ async fn tracking_history_pages_are_append_ordered_exact_scope_cursors_and_bound
|
||||
seed_manual_start_ticket(&store, "another-ticket", "open");
|
||||
let other_ticket = TicketRoleAssignmentRecord {
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
},
|
||||
..manual_worker_assignment("another-ticket", "other-ticket-assignment")
|
||||
};
|
||||
@@ -1521,7 +1447,7 @@ fn assignment_answer_source() -> TicketOrchestratorAssignmentSourceContext {
|
||||
TicketOrchestratorAssignmentSourceContext::Runtime {
|
||||
runtime_id: "r".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
agent_key: "tracking-subject".into(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
}
|
||||
}
|
||||
@@ -1711,8 +1637,8 @@ async fn assignment_answer_new_input_rejects_replaced_or_changed_handling_but_ex
|
||||
);
|
||||
let mut replacement = manual_worker_assignment("tracking", "answer-b");
|
||||
replacement.role = TicketAssignmentRole::Orchestrator;
|
||||
replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
replacement.principal = TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
};
|
||||
store
|
||||
.set_current_ticket_role_assignment(
|
||||
@@ -1775,8 +1701,8 @@ async fn assignment_identity_pages_keep_release_and_last_status_separate_and_rej
|
||||
tracking_store_assign(&store, "z-first", "first");
|
||||
let mut replacement = manual_worker_assignment("tracking", "a-second");
|
||||
replacement.role = TicketAssignmentRole::Orchestrator;
|
||||
replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
replacement.principal = TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
};
|
||||
store
|
||||
.set_current_ticket_role_assignment(
|
||||
|
||||
@@ -458,6 +458,12 @@ impl SqliteWorkspaceAuthority {
|
||||
];
|
||||
if let Some(principal) = &query.assigned_principal {
|
||||
match principal {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Subject { subject_id } => {
|
||||
let value = bind(SqlValue::Text(subject_id.clone()));
|
||||
assignment.push(format!(
|
||||
"a.principal_kind='subject' AND a.principal_id={value}"
|
||||
));
|
||||
}
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
|
||||
let value = bind(SqlValue::Text(agent_key.clone()));
|
||||
assignment.push(format!(
|
||||
@@ -854,6 +860,9 @@ impl SqliteWorkspaceAuthority {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
},
|
||||
TicketAssignmentPrincipal::Subject { subject_id } => {
|
||||
TicketAssignmentPrincipalSummary::Subject { subject_id }
|
||||
}
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
TicketAssignmentPrincipalSummary::WorkspaceAgent { agent_key }
|
||||
}
|
||||
@@ -2136,6 +2145,7 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
}
|
||||
if let Some(principal) = &query.assigned_principal {
|
||||
let ids: Vec<&str> = match principal {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Subject { subject_id } => vec![subject_id],
|
||||
ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => vec![account_id],
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
|
||||
vec![agent_key]
|
||||
@@ -3614,8 +3624,8 @@ mod tests {
|
||||
let orchestrator = crate::store::TicketRoleAssignmentRecord {
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
assignment_id: "orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "eligibility-subject".into(),
|
||||
},
|
||||
..record.clone()
|
||||
};
|
||||
|
||||
@@ -206,7 +206,18 @@ impl SqliteWorkspaceStore {
|
||||
let state = match load_state(&tx, workspace_id)? {
|
||||
Some(state) => state,
|
||||
None => {
|
||||
let state = initial_state_with_schema(schema_bundle.clone())?;
|
||||
let mut state = initial_state_with_schema(schema_bundle.clone())?;
|
||||
if has_plugin_provider(&schema_bundle) {
|
||||
let (contract, evaluation, _) =
|
||||
crate::plugin_settings::evaluate_catalog_config(
|
||||
&tx,
|
||||
workspace_id,
|
||||
&state.snapshot,
|
||||
schema_bundle.clone(),
|
||||
)?;
|
||||
state.contract = contract;
|
||||
state.projection_digest = evaluation.projection_digest;
|
||||
}
|
||||
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
|
||||
state
|
||||
}
|
||||
@@ -215,6 +226,10 @@ impl SqliteWorkspaceStore {
|
||||
tx.commit()?;
|
||||
Ok((state, requires_toolchain_refresh, base_identity))
|
||||
})?;
|
||||
let desired_schema = crate::plugin_settings::materialization_schema(
|
||||
desired_schema,
|
||||
&state.contract.schema_bundle,
|
||||
)?;
|
||||
let main_needs_normalization = state
|
||||
.snapshot
|
||||
.get(&main_config_path())
|
||||
@@ -223,7 +238,17 @@ impl SqliteWorkspaceStore {
|
||||
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|
||||
|| main_needs_normalization
|
||||
{
|
||||
let candidate = evaluate_candidate(state, base_identity, &[], desired_schema)?;
|
||||
let candidate = self.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
evaluate_candidate_in_transaction(
|
||||
&tx,
|
||||
workspace_id,
|
||||
state,
|
||||
base_identity,
|
||||
&[],
|
||||
desired_schema,
|
||||
)
|
||||
})?;
|
||||
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
|
||||
}
|
||||
Ok(state)
|
||||
@@ -277,18 +302,23 @@ impl SqliteWorkspaceStore {
|
||||
request: &ConfigCommitRequest,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
let (current, base_identity) = self.with_conn(|conn| {
|
||||
self.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?;
|
||||
let identity = load_evaluation_identity(&tx, workspace_id)?;
|
||||
tx.commit()?;
|
||||
Ok((current, identity))
|
||||
})?;
|
||||
validate_entrypoint_request(&request.entrypoints)?;
|
||||
if current.snapshot.digest != request.base_digest {
|
||||
return Err(config_conflict("base content digest mismatch"));
|
||||
}
|
||||
evaluate_candidate(current, base_identity, &request.changes, schema_bundle)
|
||||
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
|
||||
validate_entrypoint_request(&request.entrypoints)?;
|
||||
if current.snapshot.digest != request.base_digest {
|
||||
return Err(config_conflict("base content digest mismatch"));
|
||||
}
|
||||
evaluate_candidate_in_transaction(
|
||||
&tx,
|
||||
workspace_id,
|
||||
current,
|
||||
base_identity,
|
||||
&request.changes,
|
||||
schema_bundle,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn evaluate_workspace_config_candidate(
|
||||
@@ -328,8 +358,28 @@ impl SqliteWorkspaceStore {
|
||||
{
|
||||
return Err(config_conflict("base evaluation identity mismatch"));
|
||||
}
|
||||
if has_plugin_provider(¤t.contract.schema_bundle)
|
||||
&& !has_plugin_provider(&candidate.contract.schema_bundle)
|
||||
{
|
||||
return Err(config_conflict("Plugin schema authority cannot be removed"));
|
||||
}
|
||||
let snapshot = candidate.snapshot.clone();
|
||||
snapshot.validate().map_err(config_error)?;
|
||||
if has_plugin_provider(&candidate.contract.schema_bundle) {
|
||||
let (contract, evaluation, resolved) =
|
||||
crate::plugin_settings::evaluate_catalog_config(
|
||||
&tx,
|
||||
workspace_id,
|
||||
&snapshot,
|
||||
candidate.contract.schema_bundle.clone(),
|
||||
)?;
|
||||
if contract != candidate.contract || evaluation != candidate.evaluation {
|
||||
return Err(config_conflict(
|
||||
"Plugin catalog/schema evaluation changed since Preview",
|
||||
));
|
||||
}
|
||||
crate::plugin_settings::sync_config_references(&tx, workspace_id, &resolved)?;
|
||||
}
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
tx.execute(
|
||||
"DELETE FROM workspace_config_entries WHERE workspace_id = ?1",
|
||||
@@ -452,6 +502,40 @@ fn load_evaluation_identity(
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool {
|
||||
bundle
|
||||
.contributions
|
||||
.iter()
|
||||
.any(|provider| provider.provider_id == "builtin:plugins")
|
||||
}
|
||||
|
||||
fn evaluate_candidate_in_transaction(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
workspace_id: &str,
|
||||
current: WorkspaceConfigState,
|
||||
base_identity: (String, String),
|
||||
changes: &[ConfigTreeChange],
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
if !has_plugin_provider(&schema_bundle) {
|
||||
return evaluate_candidate(current, base_identity, changes, schema_bundle);
|
||||
}
|
||||
let mut providers = schema_bundle.contributions.clone();
|
||||
providers.retain(|provider| provider.provider_id != "builtin:plugins");
|
||||
providers.push(crate::plugin_settings::PluginConfigSchemaProvider.contribution()?);
|
||||
let bootstrap = WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?;
|
||||
let mut candidate = evaluate_candidate(current, base_identity, changes, bootstrap)?;
|
||||
let (contract, evaluation, _) = crate::plugin_settings::evaluate_catalog_config(
|
||||
tx,
|
||||
workspace_id,
|
||||
&candidate.snapshot,
|
||||
schema_bundle,
|
||||
)?;
|
||||
candidate.contract = contract;
|
||||
candidate.evaluation = evaluation;
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
fn evaluate_candidate(
|
||||
current: WorkspaceConfigState,
|
||||
base_identity: (String, String),
|
||||
|
||||
@@ -9,7 +9,9 @@ fn implementation_result_input() -> server_api::TicketImplementationResultReques
|
||||
}
|
||||
}
|
||||
|
||||
fn implementation_result_business_state(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
fn implementation_result_business_state(
|
||||
conn: &Connection,
|
||||
) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
[
|
||||
"typed_tickets",
|
||||
"typed_ticket_events",
|
||||
@@ -63,21 +65,50 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
|
||||
let receipt = store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
assert_eq!(store.with_conn(|conn| Ok(implementation_result_business_state(conn))).unwrap(), before);
|
||||
assert_eq!(store.implementation_result("w", "tracking", &receipt.result_id).unwrap(), receipt);
|
||||
assert_eq!(
|
||||
store
|
||||
.with_conn(|conn| Ok(implementation_result_business_state(conn)))
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.implementation_result("w", "tracking", &receipt.result_id)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
// A second, independently authenticated Worker cannot reuse this Workspace operation key.
|
||||
let other = RuntimeWorkerRef::new("r", "worker");
|
||||
assert!(store.record_implementation_result("w", "tracking", &input, &other, "tracking-binding").is_err());
|
||||
assert!(
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &input, &other, "tracking-binding")
|
||||
.is_err()
|
||||
);
|
||||
let mut other_input = input.clone();
|
||||
other_input.operation_key = "other-worker-result".into();
|
||||
store.record_implementation_result("w", "tracking", &other_input, &other, "tracking-binding").unwrap();
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &other_input, &other, "tracking-binding")
|
||||
.unwrap();
|
||||
seed_manual_start_ticket(&store, "other-ticket", "open");
|
||||
assert!(store.record_implementation_result("w", "other-ticket", &input, &worker, "tracking-binding").is_err());
|
||||
assert!(
|
||||
store
|
||||
.record_implementation_result("w", "other-ticket", &input, &worker, "tracking-binding")
|
||||
.is_err()
|
||||
);
|
||||
let mut missing = input.clone();
|
||||
missing.operation_key = "missing-ticket-result".into();
|
||||
assert!(store.record_implementation_result("w", "missing", &missing, &worker, "tracking-binding").is_err());
|
||||
assert!(!store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().iter()
|
||||
.any(|p| p.input_event_id.starts_with("implementation-result:")));
|
||||
assert!(
|
||||
store
|
||||
.record_implementation_result("w", "missing", &missing, &worker, "tracking-binding")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id.starts_with("implementation-result:"))
|
||||
);
|
||||
let evidence = store.with_conn(|conn| {
|
||||
let source: (String, String, String) = conn.query_row(
|
||||
"SELECT s.assignment_id,d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1",
|
||||
@@ -90,9 +121,19 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
|
||||
}).unwrap();
|
||||
// A later Orchestrator must not acquire previously recipient-less reports.
|
||||
tracking_store_assign(&store, "late-delegation", "assign-late");
|
||||
assert_eq!(store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
|
||||
assert!(!store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().iter()
|
||||
.any(|p| p.input_event_id.starts_with("implementation-result:")));
|
||||
assert_eq!(
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id.starts_with("implementation-result:"))
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 2);
|
||||
// Assignment creation has its own evidence; old report evidence stays byte-for-byte intact.
|
||||
@@ -101,22 +142,46 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
|
||||
assert_eq!(reopened.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
|
||||
assert_eq!(reopened.implementation_result("w", "tracking", &receipt.result_id).unwrap(), receipt);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.implementation_result("w", "tracking", &receipt.result_id)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn implementation_result_requires_live_binding_and_registered_worker_even_for_replay() {
|
||||
for change in ["revoked", "binding-changed", "unregistered", "foreign-worker", "foreign-binding"] {
|
||||
for change in [
|
||||
"revoked",
|
||||
"binding-changed",
|
||||
"unregistered",
|
||||
"foreign-worker",
|
||||
"foreign-binding",
|
||||
] {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
store.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "other".into(), owner_account_id: "owner-account".into(),
|
||||
display_name: "Other".into(), state: "active".into(),
|
||||
created_at: "1".into(), updated_at: "1".into(),
|
||||
}).await.unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "other".into(),
|
||||
owner_account_id: "owner-account".into(),
|
||||
display_name: "Other".into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let worker = RuntimeWorkerRef::new("r", "replacement");
|
||||
let input = implementation_result_input();
|
||||
store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap();
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
match change {
|
||||
"revoked" => { conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2' WHERE workspace_id='w'", [])?; }
|
||||
@@ -131,8 +196,19 @@ async fn implementation_result_requires_live_binding_and_registered_worker_even_
|
||||
let mut fresh = input.clone();
|
||||
fresh.operation_key = "fresh-after-auth-change".into();
|
||||
for request in [&input, &fresh] {
|
||||
assert!(matches!(store.record_implementation_result("w", "tracking", request, &worker, "tracking-binding"),
|
||||
Err(Error::WorkspacePermissionDenied(_))), "{change}: {request:?}");
|
||||
assert!(
|
||||
matches!(
|
||||
store.record_implementation_result(
|
||||
"w",
|
||||
"tracking",
|
||||
request,
|
||||
&worker,
|
||||
"tracking-binding"
|
||||
),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
),
|
||||
"{change}: {request:?}"
|
||||
);
|
||||
}
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_implementation_results", [], |r| r.get::<_, i64>(0))?, 1);
|
||||
@@ -144,7 +220,14 @@ async fn implementation_result_requires_live_binding_and_registered_worker_even_
|
||||
|
||||
#[tokio::test]
|
||||
async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_handling_state() {
|
||||
for situation in ["never-assigned", "released", "reassigned", "closed", "no-orchestrator", "assigned-before-orchestrator"] {
|
||||
for situation in [
|
||||
"never-assigned",
|
||||
"released",
|
||||
"reassigned",
|
||||
"closed",
|
||||
"no-orchestrator",
|
||||
"assigned-before-orchestrator",
|
||||
] {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
if situation != "assigned-before-orchestrator" {
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
@@ -157,12 +240,32 @@ async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_h
|
||||
match situation {
|
||||
"never-assigned" => {}
|
||||
"released" => {
|
||||
assert!(store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Worker,
|
||||
"implementation", "release-event", "release-op", "human", "3", Some("Released")).unwrap());
|
||||
assert!(
|
||||
store
|
||||
.clear_current_ticket_role_assignment(
|
||||
"w",
|
||||
"tracking",
|
||||
TicketAssignmentRole::Worker,
|
||||
"implementation",
|
||||
"release-event",
|
||||
"release-op",
|
||||
"human",
|
||||
"3",
|
||||
Some("Released")
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
"reassigned" => {
|
||||
store.set_current_ticket_role_assignment(&manual_worker_assignment("tracking", "replacement-implementation"),
|
||||
Some("implementation"), "reassign-event", "reassign-op", true).unwrap();
|
||||
store
|
||||
.set_current_ticket_role_assignment(
|
||||
&manual_worker_assignment("tracking", "replacement-implementation"),
|
||||
Some("implementation"),
|
||||
"reassign-event",
|
||||
"reassign-op",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"closed" => {
|
||||
store.with_conn(|conn| {
|
||||
@@ -171,16 +274,40 @@ async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_h
|
||||
}).unwrap();
|
||||
}
|
||||
"no-orchestrator" => {
|
||||
assert!(store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator,
|
||||
"delegation", "release-orchestrator-event", "release-orchestrator-op", "human", "3", Some("Returned to human")).unwrap());
|
||||
assert!(
|
||||
store
|
||||
.clear_current_ticket_role_assignment(
|
||||
"w",
|
||||
"tracking",
|
||||
TicketAssignmentRole::Orchestrator,
|
||||
"delegation",
|
||||
"release-orchestrator-event",
|
||||
"release-orchestrator-op",
|
||||
"human",
|
||||
"3",
|
||||
Some("Returned to human")
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
"assigned-before-orchestrator" => {
|
||||
tracking_store_assign(&store, "late-delegation", "assign-late");
|
||||
}
|
||||
"assigned-before-orchestrator" => { tracking_store_assign(&store, "late-delegation", "assign-late"); }
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let before = store.with_conn(|conn| Ok(implementation_result_business_state(conn))).unwrap();
|
||||
let before = store
|
||||
.with_conn(|conn| Ok(implementation_result_business_state(conn)))
|
||||
.unwrap();
|
||||
let input = implementation_result_input();
|
||||
let receipt = store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap();
|
||||
assert_eq!(store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
|
||||
let receipt = store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(implementation_result_business_state(conn), before, "{situation}");
|
||||
let expected = match situation {
|
||||
@@ -239,9 +366,17 @@ async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
|
||||
);
|
||||
let mut changed_kind = input.clone();
|
||||
changed_kind.kind = server_api::TicketImplementationResultKind::Blocker;
|
||||
assert!(store
|
||||
.record_implementation_result("w", "tracking", &changed_kind, &worker, "tracking-binding")
|
||||
.is_err());
|
||||
assert!(
|
||||
store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"tracking",
|
||||
&changed_kind,
|
||||
&worker,
|
||||
"tracking-binding"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for (workspace, ticket, source, binding) in [
|
||||
("other", "tracking", worker.clone(), "tracking-binding"),
|
||||
("w", "other", worker.clone(), "tracking-binding"),
|
||||
@@ -322,11 +457,13 @@ async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id == format!("implementation-result:{}", late.result_id)));
|
||||
assert!(
|
||||
store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id == format!("implementation-result:{}", late.result_id))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -367,131 +504,120 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Preserve seeded assignment evidence while rebuilding the actual pre-business
|
||||
// capture composition. Changing only a receipt leaves current admission fences
|
||||
// and erasure guards in an otherwise legacy fixture.
|
||||
fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> {
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_operations;
|
||||
DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations;
|
||||
UPDATE __yoi_schema_migrations SET version=105 WHERE version=106;")?;
|
||||
// Historical migration fixtures start from the retained Workspace96/component13
|
||||
// composition. Wrapping the actual connection is test-only SQL access, not a
|
||||
// current-schema open or any claim of authenticated legacy execution authority.
|
||||
fn implementation_legacy96_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("retained96.db");
|
||||
let path = directory.path().join("workspace.db");
|
||||
prepare_retained_schema(&path, 96);
|
||||
let retained = Connection::open(path)?;
|
||||
for trigger in [
|
||||
"notification_sources_no_delete",
|
||||
"notification_decisions_no_delete",
|
||||
"notification_requests_no_delete",
|
||||
"notification_acknowledgements_no_delete",
|
||||
"notification_failures_no_delete",
|
||||
"notification_job_targets_no_delete",
|
||||
"notification_job_legacy_no_delete",
|
||||
"notification_source_preparation_failures_no_delete",
|
||||
] {
|
||||
let definition: String = retained.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE type='trigger' AND name=?1",
|
||||
[trigger],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
conn.execute_batch(&format!("DROP TRIGGER {trigger}; {definition};"))?;
|
||||
}
|
||||
for trigger in [
|
||||
"subject_deleted_job_insert",
|
||||
"subject_deleted_job_result",
|
||||
"subject_deleted_attempt_insert",
|
||||
"subject_deleted_attempt_result",
|
||||
"subject_deleted_worker_reservation",
|
||||
"subject_deleted_notification_source",
|
||||
"subject_deleted_notification_payload",
|
||||
"subject_deleted_notification_send",
|
||||
] {
|
||||
conn.execute_batch(&format!("DROP TRIGGER {trigger};"))?;
|
||||
}
|
||||
// Children before parents; dropping the tables also drops snapshot guards.
|
||||
conn.execute_batch(
|
||||
"DROP TABLE subject_deletion_removals;
|
||||
DROP TABLE subject_deletion_allocations;
|
||||
DROP TABLE subject_deletion_jobs;
|
||||
DROP TABLE subject_deletion_workers;
|
||||
DROP TABLE subject_deletions;
|
||||
UPDATE __yoi_schema_migrations SET version=104 WHERE version=105;",
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 96);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT max(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
);
|
||||
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('legacy-owner','user','legacy-owner','Legacy owner','1','1');
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('w','legacy-owner','Legacy workspace','active','1','1');
|
||||
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','worker','Legacy body','normal','1','1'),('w','r','replacement','Implementation','normal','1','1');
|
||||
INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('w','r','Runtime','https://runtime.invalid','retained-key','retained-fingerprint','tracking-binding','workspace_identity','1','1');
|
||||
INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w','workspace-orchestrator','r','worker',1,'1','1');
|
||||
INSERT INTO repositories(workspace_id,repository_id,repository_key,kind,provider,uri,created_at,updated_at,source_kind,source_uri,source_fingerprint) VALUES('w','main','main','git','git','file:///main','1','1','file','file:///main','sha256:fixture');").unwrap();
|
||||
let store = SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
db_executor: crate::db_executor::DbExecutor::new(
|
||||
crate::db_executor::DbExecutorLimits::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
};
|
||||
seed_manual_start_ticket(&store, "tracking", "open");
|
||||
store.with_conn(|conn| {
|
||||
// The old principal and normal assignment triggers are genuine retained
|
||||
// data. Current assignment APIs intentionally cannot create this identity.
|
||||
conn.execute_batch("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','tracking','delegation','orchestrator','workspace_agent','workspace-orchestrator','legacy-owner','2');
|
||||
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','tracking','delegation','orchestrator','workspace_agent','workspace-orchestrator','2');
|
||||
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id) VALUES('w','tracking','orchestrator','legacy-assigned-event','assigned','delegation','legacy-owner','2','legacy-assign');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
(directory, store)
|
||||
}
|
||||
|
||||
// Seed accepted PRE-upgrade notification evidence as historical SQL data. The
|
||||
// current router must never authorize workspace-orchestrator to recreate it.
|
||||
fn implementation_seed_legacy_result_delivery(conn: &Connection, index: usize) -> Result<()> {
|
||||
let event = format!("implementation-result:legacy-result-{index}");
|
||||
let delivery_id = format!("legacy-result-delivery-{index}");
|
||||
let input = crate::store::TicketAssignmentDelivery {
|
||||
ticket_id: "tracking".into(),
|
||||
assignment_id: "delegation".into(),
|
||||
input_event_id: event.clone(),
|
||||
delivery_id: delivery_id.clone(),
|
||||
request_id: delivery_id.clone(),
|
||||
waiting_reason: None,
|
||||
next_action: None,
|
||||
};
|
||||
let source = serde_json::json!({"actor":"backend:assignment-delivery", "runtime_id":"r", "worker_id":"worker", "agent_key":"workspace-orchestrator", "authentication_binding_id":"tracking-binding"}).to_string();
|
||||
conn.execute("INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) SELECT sequence,'w',?1,'deliver','result','result' FROM notification_sources WHERE input_event_id=?2 AND role='orchestrator'", params![delivery_id,event])?;
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_deliveries VALUES('w','delegation',?1,?2,?3,?4)",
|
||||
params![
|
||||
event,
|
||||
delivery_id,
|
||||
serde_json::to_string(&input).unwrap(),
|
||||
source
|
||||
],
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"DROP TRIGGER notification_mr_event;
|
||||
DROP TRIGGER notification_mr_source;
|
||||
DROP TRIGGER notification_implementation_result;
|
||||
DROP TRIGGER IF EXISTS implementation_delegation_capture;
|
||||
DROP TABLE ticket_implementation_results;
|
||||
DROP TABLE ticket_implementation_delegations;
|
||||
DROP TABLE merge_request_source_observations;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
DROP TRIGGER backend_job_input_retirement;
|
||||
DROP TRIGGER backend_job_identity_immutable;
|
||||
ALTER TABLE backend_jobs DROP COLUMN input_disposition;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=104;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
|
||||
DROP TABLE subject_worker_releases;
|
||||
UPDATE __yoi_schema_migrations SET version=100 WHERE version=101;
|
||||
DROP TABLE user_activations;
|
||||
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
|
||||
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
|
||||
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
|
||||
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;",
|
||||
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w',?1,'Retained legacy report notification')", [&delivery_id])?;
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w',?1,?2)",
|
||||
params![delivery_id, source],
|
||||
)?;
|
||||
assert_eq!(current_schema_version(conn)?, 96);
|
||||
assert_eq!(conn.query_row(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE name LIKE 'subject_delet%' OR sql LIKE '%subject_deletion%'",
|
||||
[], |row| row.get::<_, i64>(0),
|
||||
)?, 0);
|
||||
for table in [
|
||||
"notification_sources",
|
||||
"notification_decisions",
|
||||
"notification_requests",
|
||||
"notification_acknowledgements",
|
||||
"notification_failures",
|
||||
"notification_job_targets",
|
||||
"notification_job_legacy_receipts",
|
||||
"notification_source_preparation_failures",
|
||||
"backend_jobs",
|
||||
"backend_job_attempts",
|
||||
"worker_create_reservations",
|
||||
] {
|
||||
assert_eq!(
|
||||
state_contract_schema_signature(conn, table),
|
||||
state_contract_schema_signature(&retained, table),
|
||||
"schema96 {table}"
|
||||
);
|
||||
}
|
||||
conn.execute("INSERT INTO notification_assignment_targets VALUES('w',?1,'r','worker','workspace-orchestrator','tracking-binding')", [&delivery_id])?;
|
||||
conn.execute(
|
||||
"INSERT INTO notification_requests VALUES('w',?1,'Retained legacy report notification',?2)",
|
||||
params![delivery_id, source],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO notification_acknowledgements VALUES('w',?1,?2)",
|
||||
params![delivery_id, source],
|
||||
)?;
|
||||
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w',?1,'runtime_rejected','4')", [&delivery_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_notification_evidence() {
|
||||
let (dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_notification_evidence()
|
||||
{
|
||||
let (dir, store) = implementation_legacy96_fixture();
|
||||
let worker = routing_worker_assign(&store, "implementation").worker;
|
||||
// Reconstruct schema97 from a fresh current-schema fixture, including its historical capture.
|
||||
store.with_conn(|conn| {
|
||||
reconstruct_implementation_schema96(conn)?;
|
||||
migrate_business_notifications_v96_to_v97(conn)?;
|
||||
assert_eq!(current_schema_version(conn)?, 97);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
// Apply the real forward 96 -> 97 migration before seeding schema97 reports.
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
migrate_business_notifications_v96_to_v97(conn)?;
|
||||
assert_eq!(current_schema_version(conn)?, 97);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let input = implementation_result_input();
|
||||
let mut second = input.clone();
|
||||
second.operation_key = "legacy-second-operation".into();
|
||||
second.body = "A second retained report".into();
|
||||
let requests = [input.clone(), second];
|
||||
let raw_requests = requests.iter().map(|request| {
|
||||
let mut legacy = serde_json::to_value(request).unwrap();
|
||||
legacy["assignment_id"] = serde_json::json!("implementation");
|
||||
legacy["orchestrator_assignment_id"] = serde_json::json!("delegation");
|
||||
format!(" \n{}\n ", serde_json::to_string_pretty(&legacy).unwrap())
|
||||
}).collect::<Vec<_>>();
|
||||
let raw_requests = requests
|
||||
.iter()
|
||||
.map(|request| {
|
||||
let mut legacy = serde_json::to_value(request).unwrap();
|
||||
legacy["assignment_id"] = serde_json::json!("implementation");
|
||||
legacy["orchestrator_assignment_id"] = serde_json::json!("delegation");
|
||||
format!(" \n{}\n ", serde_json::to_string_pretty(&legacy).unwrap())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
store.with_conn(|conn| {
|
||||
for (index, request) in requests.iter().enumerate() {
|
||||
conn.execute("INSERT INTO ticket_implementation_results(workspace_id,ticket_id,operation_key,result_id,assignment_id,orchestrator_assignment_id,runtime_id,worker_id,request_json,created_at)
|
||||
@@ -500,23 +626,16 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
// Populate persisted request, target, ACK and failure evidence, not just source rows.
|
||||
let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap();
|
||||
for index in 0..requests.len() {
|
||||
let input_event = format!("implementation-result:legacy-result-{index}");
|
||||
assert!(pending.iter().any(|p| p.input_event_id == input_event));
|
||||
let reserved = store.reserve_ticket_assignment_delivery("w", "delegation", &input_event, &tracking_store_source()).unwrap();
|
||||
store.prepare_ticket_assignment_delivery_content("w", &reserved.delivery_id, "Retained legacy report notification", &tracking_store_source()).unwrap();
|
||||
store.ack_ticket_assignment_delivery("w", &reserved.delivery_id, &tracking_store_source()).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w',?1,'legacy-failure','4')", [&reserved.delivery_id])?;
|
||||
// Persist real historical row shapes without calling current Subject-only
|
||||
// send/reserve APIs as though the legacy identity were live authority.
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
for index in 0..requests.len() {
|
||||
implementation_seed_legacy_result_delivery(conn, index)?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
routing_ack_worker_pending(&store, &TicketWorkerNotificationTarget {
|
||||
worker: worker.clone(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
let (results, business, evidence) = store.with_conn(|conn| {
|
||||
Ok((
|
||||
fixture_query_rows(conn, "SELECT workspace_id,ticket_id,operation_key,result_id,runtime_id,worker_id,request_json,created_at FROM ticket_implementation_results ORDER BY rowid"),
|
||||
@@ -524,7 +643,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
implementation_result_notification_evidence(conn),
|
||||
))
|
||||
}).unwrap();
|
||||
assert!(evidence.iter().all(|rows| !rows.is_empty()), "fixture must retain nonempty source, decision, request, target, ACK and failure evidence");
|
||||
assert!(
|
||||
evidence.iter().all(|rows| !rows.is_empty()),
|
||||
"fixture must retain nonempty source, decision, request, target, ACK and failure evidence"
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
// The migration must roll back its trigger drops and leave every byte replayable.
|
||||
conn.execute_batch("CREATE TABLE ticket_implementation_results_v98(collision INTEGER);")?;
|
||||
@@ -557,52 +679,114 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
|
||||
for (index, request) in requests.iter().enumerate() {
|
||||
let expected = server_api::TicketImplementationResultReceipt {
|
||||
result_id: format!("legacy-result-{index}"), ticket_id: "tracking".into(),
|
||||
request: request.clone(), created_at: "4".into(),
|
||||
result_id: format!("legacy-result-{index}"),
|
||||
ticket_id: "tracking".into(),
|
||||
request: request.clone(),
|
||||
created_at: "4".into(),
|
||||
};
|
||||
assert_eq!(reopened.implementation_result("w", "tracking", &expected.result_id).unwrap(), expected);
|
||||
assert_eq!(reopened.record_implementation_result("w", "tracking", request, &worker, "tracking-binding").unwrap(), expected);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.implementation_result("w", "tracking", &expected.result_id)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.record_implementation_result("w", "tracking", request, &worker, "tracking-binding")
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
reopened.with_conn(|conn| {
|
||||
assert_eq!(fixture_query_rows(conn, "SELECT * FROM ticket_implementation_results ORDER BY rowid"), results);
|
||||
assert_eq!(implementation_result_notification_evidence(conn), evidence, "reading and replaying legacy JSON must never recapture notifications");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
reopened
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
fixture_query_rows(
|
||||
conn,
|
||||
"SELECT * FROM ticket_implementation_results ORDER BY rowid"
|
||||
),
|
||||
results
|
||||
);
|
||||
assert_eq!(
|
||||
implementation_result_notification_evidence(conn),
|
||||
evidence,
|
||||
"reading and replaying legacy JSON must never recapture notifications"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
tracking_store_assign(&reopened, "new-delegation", "assign-new");
|
||||
routing_worker_assign(&reopened, "new-implementation");
|
||||
reopened.with_conn(|conn| {
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_implementation_delegations"), business[9], "historical delegation evidence must not grow");
|
||||
for index in 0..requests.len() {
|
||||
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:legacy-result-{index}")], |r| r.get::<_, String>(0))?, "delegation");
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
reopened
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
stored_fixture_rows(conn, "ticket_implementation_delegations"),
|
||||
business[9],
|
||||
"historical delegation evidence must not grow"
|
||||
);
|
||||
for index in 0..requests.len() {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
|
||||
[format!("implementation-result:legacy-result-{index}")],
|
||||
|r| r.get::<_, String>(0)
|
||||
)?,
|
||||
"delegation"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for request in &requests {
|
||||
reopened.record_implementation_result("w", "tracking", request, &worker, "tracking-binding").unwrap();
|
||||
reopened
|
||||
.record_implementation_result("w", "tracking", request, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
}
|
||||
let mut fresh = input;
|
||||
fresh.operation_key = "new-schema98-report".into();
|
||||
let receipt = reopened.record_implementation_result("w", "tracking", &fresh, &worker, "tracking-binding").unwrap();
|
||||
reopened.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:{}", receipt.result_id)], |r| r.get::<_, String>(0))?, "new-delegation");
|
||||
for index in 0..requests.len() {
|
||||
assert_eq!(conn.query_row("SELECT request_json FROM ticket_implementation_results WHERE result_id=?1", [format!("legacy-result-{index}")], |r| r.get::<_, String>(0))?, raw_requests[index]);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:legacy-result-{index}")], |r| r.get::<_, i64>(0))?, 1);
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let receipt = reopened
|
||||
.record_implementation_result("w", "tracking", &fresh, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
reopened
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
|
||||
[format!("implementation-result:{}", receipt.result_id)],
|
||||
|r| r.get::<_, String>(0)
|
||||
)?,
|
||||
"new-delegation"
|
||||
);
|
||||
for index in 0..requests.len() {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT request_json FROM ticket_implementation_results WHERE result_id=?1",
|
||||
[format!("legacy-result-{index}")],
|
||||
|r| r.get::<_, String>(0)
|
||||
)?,
|
||||
raw_requests[index]
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM notification_sources WHERE input_event_id=?1",
|
||||
[format!("implementation-result:legacy-result-{index}")],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
1
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknown_legacy_results() {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
let (_dir, store) = implementation_legacy96_fixture();
|
||||
routing_worker_assign(&store, "implementation");
|
||||
routing_worker_assign(&store, "known-implementation");
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO ticket_assignment_operation_fences VALUES('w','operation-known-implementation','delegation','r','worker','workspace-orchestrator','tracking-binding',?1)",[serde_json::to_string(&tracking_store_source()).unwrap()])?;
|
||||
// Reconstruct the prior composition before its new business capture existed.
|
||||
reconstruct_implementation_schema96(conn)?;
|
||||
let legacy_source = serde_json::json!({"actor":"authenticated-worker:r/worker", "runtime_id":"r", "worker_id":"worker", "agent_key":"workspace-orchestrator", "authentication_binding_id":"tracking-binding"});
|
||||
conn.execute("INSERT INTO ticket_assignment_operation_fences VALUES('w','operation-known-implementation','delegation','r','worker','workspace-orchestrator','tracking-binding',?1)",[legacy_source.to_string()])?;
|
||||
conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3');
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3');
|
||||
INSERT INTO merge_request_thread_events VALUES('w','historical-mr','approved-event',1,'review','{\"subject_ref\":\"source-a\",\"decision\":\"approve\"}',NULL,'3');
|
||||
@@ -629,8 +813,14 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
|
||||
assert!(
|
||||
inputs
|
||||
.iter()
|
||||
.any(|i| i.input_event_id == "mr-event:historical-mr:approved-event")
|
||||
.all(|i| i.input_event_id != "mr-event:historical-mr:approved-event"),
|
||||
"retained legacy capture must not become current Subject delivery authority"
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
let decision: (String, String) = conn.query_row("SELECT d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id='mr-event:historical-mr:approved-event' AND s.role='orchestrator'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
|
||||
assert_eq!(decision, ("suppress".into(),"unsupported_recipient".into()));
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
for _ in 0..2 {
|
||||
store
|
||||
.observe_merge_request_source("w", "main", "refs/heads/work", "source-b")
|
||||
@@ -681,7 +871,14 @@ async fn implementation_result_notification_failure_rolls_back_and_concurrent_re
|
||||
0
|
||||
);
|
||||
assert_eq!(implementation_result_business_state(conn), before);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 0);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM notification_sources WHERE kind='implementation_result'",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
0
|
||||
);
|
||||
conn.execute_batch("DROP TRIGGER reject_result_notification;")?;
|
||||
Ok(())
|
||||
})
|
||||
@@ -742,20 +939,37 @@ async fn implementation_result_notification_failure_rolls_back_and_concurrent_re
|
||||
#[tokio::test]
|
||||
async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
for historical in [false, true] {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
let (_dir, store) = if historical {
|
||||
implementation_legacy96_fixture()
|
||||
} else {
|
||||
let fixture = tracking_store_fixture().await;
|
||||
tracking_store_assign(&fixture.1, "delegation", "assign");
|
||||
fixture
|
||||
};
|
||||
routing_worker_assign(&store, "implementation");
|
||||
let events = [
|
||||
("review_requested", Some("requested_by"), Some("replacement"), Some("worker")),
|
||||
(
|
||||
"review_requested",
|
||||
Some("requested_by"),
|
||||
Some("replacement"),
|
||||
Some("worker"),
|
||||
),
|
||||
("review", Some("reviewer"), Some("reviewer-child"), None),
|
||||
("review_revoked", Some("revoked_by"), Some("replacement"), Some("worker")),
|
||||
("merge", Some("merged_by"), Some("worker"), Some("orchestrator")),
|
||||
(
|
||||
"review_revoked",
|
||||
Some("revoked_by"),
|
||||
Some("replacement"),
|
||||
Some("worker"),
|
||||
),
|
||||
(
|
||||
"merge",
|
||||
Some("merged_by"),
|
||||
Some("worker"),
|
||||
Some("orchestrator"),
|
||||
),
|
||||
("review_cancelled", None, None, None),
|
||||
];
|
||||
store.with_conn(|conn| {
|
||||
if historical {
|
||||
reconstruct_implementation_schema96(conn)?;
|
||||
}
|
||||
conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3');
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?;
|
||||
for (index, (kind, field, worker, _)) in events.iter().enumerate() {
|
||||
@@ -777,7 +991,10 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
"SELECT s.actor,d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1 AND s.role=?2",
|
||||
params![format!("mr-event:actor-mr:{kind}"),role],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
|
||||
assert_eq!(actor,worker.map(|w|format!("worker:r/{w}")),"{kind}, historical={historical}");
|
||||
if suppressed_role == Some(role) {
|
||||
if historical && role == "orchestrator" {
|
||||
assert_eq!((disposition.as_str(),reason.as_str()),("suppress","unsupported_recipient"),
|
||||
"legacy capture preserves actors but never restores Subject routing authority");
|
||||
} else if suppressed_role == Some(role) {
|
||||
assert_eq!((disposition.as_str(),reason.as_str()),("suppress","self_notification"),"{kind}, historical={historical}");
|
||||
} else {
|
||||
assert_eq!((disposition.as_str(),reason.as_str()),("deliver","attention"),"{kind}, {role}, historical={historical}");
|
||||
@@ -792,9 +1009,9 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
#[tokio::test]
|
||||
async fn implementation_result_accepts_only_observations_with_matching_mr_ticket_workspace() {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store,"delegation","assign");
|
||||
let worker = routing_worker_assign(&store,"implementation").worker;
|
||||
seed_manual_start_ticket(&store,"unrelated","open");
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
let worker = routing_worker_assign(&store, "implementation").worker;
|
||||
seed_manual_start_ticket(&store, "unrelated", "open");
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("INSERT INTO merge_requests VALUES('w','observed-mr','main','open','work','main','3','3');
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','observed-mr','tracking','implements','3');
|
||||
@@ -802,23 +1019,56 @@ async fn implementation_result_accepts_only_observations_with_matching_mr_ticket
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','other-mr','unrelated','implements','3');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
store.observe_merge_request_source("w","main","work","source-b").unwrap();
|
||||
store.observe_merge_request_source("w","main","other","other-source").unwrap();
|
||||
for (workspace,mr,subject,accepted) in [
|
||||
("w","observed-mr","forged",false),
|
||||
("w","observed-mr","other-source",false),
|
||||
("w","other-mr","other-source",false),
|
||||
("other","observed-mr","source-b",false),
|
||||
("w","observed-mr","source-b",true),
|
||||
store
|
||||
.observe_merge_request_source("w", "main", "work", "source-b")
|
||||
.unwrap();
|
||||
store
|
||||
.observe_merge_request_source("w", "main", "other", "other-source")
|
||||
.unwrap();
|
||||
for (workspace, mr, subject, accepted) in [
|
||||
("w", "observed-mr", "forged", false),
|
||||
("w", "observed-mr", "other-source", false),
|
||||
("w", "other-mr", "other-source", false),
|
||||
("other", "observed-mr", "source-b", false),
|
||||
("w", "observed-mr", "source-b", true),
|
||||
] {
|
||||
let mut input = implementation_result_input();
|
||||
input.merge_request = Some(server_api::MergeRequestReviewSubject {merge_request_id:mr.into(),subject_ref:subject.into()});
|
||||
let result = store.record_implementation_result(workspace,"tracking",&input,&worker,"tracking-binding");
|
||||
assert_eq!(result.is_ok(),accepted,"{workspace}/{mr}/{subject}: {result:?}");
|
||||
input.merge_request = Some(server_api::MergeRequestReviewSubject {
|
||||
merge_request_id: mr.into(),
|
||||
subject_ref: subject.into(),
|
||||
});
|
||||
let result = store.record_implementation_result(
|
||||
workspace,
|
||||
"tracking",
|
||||
&input,
|
||||
&worker,
|
||||
"tracking-binding",
|
||||
);
|
||||
assert_eq!(
|
||||
result.is_ok(),
|
||||
accepted,
|
||||
"{workspace}/{mr}/{subject}: {result:?}"
|
||||
);
|
||||
}
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM merge_request_thread_events",[],|r|r.get::<_,i64>(0))?,0);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_implementation_results",[],|r|r.get::<_,i64>(0))?,1);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM merge_request_thread_events",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM ticket_implementation_results",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ pub mod hosts;
|
||||
pub mod memory_backend;
|
||||
pub mod memory_staging;
|
||||
pub mod observation;
|
||||
pub mod plugin_binding;
|
||||
pub mod plugin_catalog;
|
||||
pub mod plugin_plan;
|
||||
pub mod plugin_settings;
|
||||
pub mod profile_settings;
|
||||
pub mod prompt_settings;
|
||||
pub mod records;
|
||||
|
||||
@@ -154,6 +154,23 @@ fn fixed_worker_target(
|
||||
})
|
||||
}
|
||||
|
||||
fn authorize_worker_attention(
|
||||
api: &WorkspaceApi,
|
||||
input: &TicketWorkerNotification,
|
||||
) -> ApiResult<()> {
|
||||
subject_authorization::authorize(
|
||||
api,
|
||||
&input.worker,
|
||||
&server_api::authorization::OperationAuthorizationRequest {
|
||||
operation: "ticket.read".into(),
|
||||
resource: server_api::authorization::PermissionResource::Ticket {
|
||||
workspace_id: api.workspace_id().into(),
|
||||
ticket_id: input.ticket_id.clone(),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
|
||||
let Some(_permit) = api.notification_delivery_lifecycle.begin() else {
|
||||
return;
|
||||
@@ -182,6 +199,10 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if authorize_worker_attention(api, &input).is_err() {
|
||||
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
|
||||
continue;
|
||||
}
|
||||
let resource_key = match api.store.resource_key(
|
||||
&api.config.workspace_id,
|
||||
WorkspaceResourceKind::Ticket,
|
||||
@@ -199,13 +220,17 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
|
||||
"Ticket {resource_key} has committed activity (input {}). Reread the current Ticket and assignment before acting. This notification does not grant or extend execution authority.",
|
||||
input.input_event_id,
|
||||
);
|
||||
let content = match api.store.prepare_ticket_worker_notification_content(
|
||||
if authorize_worker_attention(api, &input).is_err() {
|
||||
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
|
||||
continue;
|
||||
}
|
||||
match api.store.prepare_ticket_worker_notification_content(
|
||||
&api.config.workspace_id,
|
||||
&input.delivery_id,
|
||||
&content,
|
||||
&target,
|
||||
) {
|
||||
Ok(content) => content,
|
||||
Ok(_) => (),
|
||||
Err(error) => {
|
||||
let code = match error {
|
||||
Error::TicketAssignmentConflict(_) => {
|
||||
@@ -217,6 +242,22 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let content = match api.store.admit_ticket_worker_notification_send(
|
||||
api.workspace_id(),
|
||||
&input.delivery_id,
|
||||
&target,
|
||||
) {
|
||||
Ok(Some(content)) => content,
|
||||
Ok(None) => continue,
|
||||
Err(_) => {
|
||||
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if authorize_worker_attention(api, &input).is_err() {
|
||||
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
|
||||
continue;
|
||||
}
|
||||
let result = api.runtime.send_input(
|
||||
&input.worker,
|
||||
WorkerInputRequest {
|
||||
@@ -275,26 +316,35 @@ pub(super) fn recover_ticket_notifications(api: &WorkspaceApi) {
|
||||
};
|
||||
recover_job_notifications(api);
|
||||
dispatch_ticket_worker_notifications(api);
|
||||
if let Some(orchestrator) = find_workspace_orchestrator(api) {
|
||||
dispatch_orchestrator_assignment_attention(api, &orchestrator.worker);
|
||||
} else {
|
||||
// Still reconcile committed sources and explain target absence even when
|
||||
// there is no Runtime Worker. Do not start or restore one as a side effect.
|
||||
match api
|
||||
.store
|
||||
.list_pending_ticket_assignment_deliveries(&api.config.workspace_id, 20)
|
||||
{
|
||||
Ok(inputs) => {
|
||||
for input in inputs {
|
||||
record_notification_failure(
|
||||
api,
|
||||
&assignment_delivery_key(&input),
|
||||
NotificationFailureCode::TargetUnavailable,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, "assignment notification scan failed"),
|
||||
// Resolve each current assignment independently. A missing body is not an implicit restore.
|
||||
let inputs = match api
|
||||
.store
|
||||
.list_pending_ticket_assignment_deliveries(api.workspace_id(), 100)
|
||||
{
|
||||
Ok(inputs) => inputs,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "assignment notification scan failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut recipients = std::collections::BTreeSet::new();
|
||||
for input in inputs {
|
||||
match assigned_subject_body(api, &input.ticket_id, &input.assignment_id) {
|
||||
Ok(Some(worker)) => {
|
||||
recipients.insert((worker.runtime_id, worker.worker_id));
|
||||
}
|
||||
_ => record_notification_failure(
|
||||
api,
|
||||
&assignment_delivery_key(&input),
|
||||
NotificationFailureCode::TargetUnavailable,
|
||||
),
|
||||
}
|
||||
}
|
||||
for (runtime_id, worker_id) in recipients {
|
||||
dispatch_orchestrator_assignment_attention(
|
||||
api,
|
||||
&RuntimeWorkerRef::new(runtime_id, worker_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,13 @@ async fn notification_diagnostics_preserves_unknown_acceptance_and_marks_replace
|
||||
assert_eq!(read().stage, S::AcceptanceUnknown);
|
||||
assert!(read().acceptance_outcome_unknown);
|
||||
assert_eq!(read().last_failure_code.as_deref(), Some("target_stopped"));
|
||||
store.with_conn(|conn|{conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'",[])?;Ok(())}).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("UPDATE worker_create_reservations SET state='removed' WHERE workspace_id='w' AND singleton_key='subjektiv:tracking-subject';
|
||||
UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w' AND singleton_key='subjektiv:tracking-subject';
|
||||
INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at,singleton_key,singleton_generation)
|
||||
VALUES('w','replacement-body','replacement','r','replacement-body','created','2','2','subjektiv:tracking-subject',2);")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let replacement = TicketAssignmentTrackingSourceContext {
|
||||
worker_id: "replacement".into(),
|
||||
..source
|
||||
@@ -182,8 +188,8 @@ async fn notification_diagnostics_detects_unconnected_origin_and_retains_history
|
||||
#[tokio::test]
|
||||
async fn notification_diagnostics_is_readonly_paginated_and_workspace_scoped() {
|
||||
use server_api::{NotificationDiagnosticStage as S, NotificationDiagnosticsQuery as Q};
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
let (_dir, store) = delivery_tracking_fixture().await;
|
||||
delivery_tracking_assign(&store, "a", "assign-a");
|
||||
delivery_store_answer(&store, "answer-a");
|
||||
let before = store
|
||||
.with_conn(|conn| {
|
||||
@@ -377,8 +383,18 @@ async fn notification_source_metadata_resolves_hashed_and_legacy_identities_with
|
||||
use server_api::authorization::PermissionResource;
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
let (_, source) = job_notice_fixture(&store, "metadata-only", false);
|
||||
let job = store.get_backend_job("w", "metadata-only").unwrap().unwrap();
|
||||
let retired = store.reclaim_backend_job_input("w", "metadata-only", &job.input_digest, crate::backend_job::JobInputRetirementReason::ExplicitErasure).unwrap();
|
||||
let job = store
|
||||
.get_backend_job("w", "metadata-only")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let retired = store
|
||||
.reclaim_backend_job_input(
|
||||
"w",
|
||||
"metadata-only",
|
||||
&job.input_digest,
|
||||
crate::backend_job::JobInputRetirementReason::ExplicitErasure,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(retired.load_executable_request().is_err());
|
||||
store.with_conn(|conn| {
|
||||
// Retired protected input cannot execute, but notification ownership
|
||||
@@ -402,7 +418,13 @@ async fn notification_source_metadata_resolves_hashed_and_legacy_identities_with
|
||||
);
|
||||
let other = store.notification_source_resource("other", event).unwrap();
|
||||
if event == "mr-event:legacy-job:e" {
|
||||
assert_eq!(other, Some(PermissionResource::MergeRequest { workspace_id: "other".into(), merge_request_id: "legacy-job".into() }));
|
||||
assert_eq!(
|
||||
other,
|
||||
Some(PermissionResource::MergeRequest {
|
||||
workspace_id: "other".into(),
|
||||
merge_request_id: "legacy-job".into()
|
||||
})
|
||||
);
|
||||
} else {
|
||||
assert!(other.is_none());
|
||||
}
|
||||
|
||||
@@ -179,8 +179,11 @@ fn policy(
|
||||
return ("suppress", R::NoRecipient);
|
||||
}
|
||||
let supported = if role == "orchestrator" {
|
||||
facts.principal_kind.as_deref() == Some("workspace_agent")
|
||||
&& facts.principal_id.as_deref() == Some("workspace-orchestrator")
|
||||
facts.principal_kind.as_deref() == Some("subject")
|
||||
&& facts
|
||||
.principal_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| !id.trim().is_empty())
|
||||
} else {
|
||||
role == "worker" && facts.principal_kind.as_deref() == Some("worker")
|
||||
};
|
||||
@@ -231,6 +234,56 @@ fn policy(
|
||||
|
||||
const FACTS_SQL: &str = "json_object('principal_kind',a.principal_kind,'principal_id',a.principal_id,'runtime_id',COALESCE(a.runtime_id,(SELECT runtime_id FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key=a.principal_id AND a.principal_kind='workspace_agent')),'worker_id',COALESCE(a.worker_id,(SELECT worker_id FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key=a.principal_id AND a.principal_kind='workspace_agent')),'status',json_extract(a.tracking_json,'$.status'),'case_state',t.workflow_state,'released',json(CASE WHEN EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id) THEN 'true' ELSE 'false' END))";
|
||||
|
||||
// FACTS_SQL above is retained verbatim for applied schema95/97/98 migrations.
|
||||
// Current capture resolves the existing Subject singleton owner, never a second registry.
|
||||
fn subject_facts_sql() -> String {
|
||||
let prefix = crate::subjektiv::SUBJECT_WORKER_SINGLETON_PREFIX.replace('\'', "''");
|
||||
let owner = |column: &str| {
|
||||
format!(
|
||||
"CASE WHEN a.principal_kind='subject' THEN (SELECT {column} FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key='{prefix}' || a.principal_id) ELSE a.{column} END"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"json_object('principal_kind',a.principal_kind,'principal_id',a.principal_id,'runtime_id',{},'worker_id',{},'status',json_extract(a.tracking_json,'$.status'),'case_state',t.workflow_state,'released',json(CASE WHEN EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id) THEN 'true' ELSE 'false' END))",
|
||||
owner("runtime_id"),
|
||||
owner("worker_id")
|
||||
)
|
||||
}
|
||||
|
||||
/// Additive upgrade of future capture only. Old recipient snapshots and receipts remain
|
||||
/// evidence; an unresolved legacy principal is not mapped to an arbitrary Subject.
|
||||
/// The caller owns the Workspace schema transaction and runs this after assignment migration.
|
||||
pub(super) fn install_subject_recipients(conn: &Connection) -> Result<()> {
|
||||
let facts = subject_facts_sql();
|
||||
for trigger in [
|
||||
"notification_ticket_event",
|
||||
"notification_assignment_history",
|
||||
"notification_assignment_change",
|
||||
"notification_assignment_release",
|
||||
"notification_content_input",
|
||||
"notification_unassigned_content",
|
||||
"notification_mr_event",
|
||||
"notification_mr_source",
|
||||
"notification_implementation_result",
|
||||
] {
|
||||
let sql: String = conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE type='trigger' AND name=?1",
|
||||
[trigger],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !sql.contains(FACTS_SQL) {
|
||||
return Err(Error::Store(format!(
|
||||
"Notification capture {trigger} does not match the prior schema"
|
||||
)));
|
||||
}
|
||||
conn.execute_batch(&format!(
|
||||
"DROP TRIGGER {trigger}; {}",
|
||||
sql.replace(FACTS_SQL, &facts)
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture(input: &str, kind: &str, actor: &str, event_index: &str) -> String {
|
||||
format!("INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,actor,event_index,facts_json)
|
||||
SELECT NEW.workspace_id,NEW.ticket_id,{input},roles.role,COALESCE(a.assignment_id,''),{kind},{actor},{event_index},{FACTS_SQL}
|
||||
@@ -694,7 +747,8 @@ pub(super) fn require_current(
|
||||
assignment: &str,
|
||||
role: &str,
|
||||
) -> Result<()> {
|
||||
let facts:Option<String>=conn.query_row(&format!("SELECT {FACTS_SQL} FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.assignment_id=?3 AND c.role=?4"),params![workspace,ticket,assignment,role],|r|r.get(0)).optional()?;
|
||||
let facts_sql = subject_facts_sql();
|
||||
let facts:Option<String>=conn.query_row(&format!("SELECT {facts_sql} FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.assignment_id=?3 AND c.role=?4"),params![workspace,ticket,assignment,role],|r|r.get(0)).optional()?;
|
||||
if let Some(facts) = facts {
|
||||
let facts: Facts = decode(&facts)?;
|
||||
if policy(
|
||||
@@ -718,18 +772,33 @@ pub(super) fn pending_sources(
|
||||
workspace: &str,
|
||||
role: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, String, String, String)>> {
|
||||
pending_sources_for_subject(conn, workspace, role, limit, None)
|
||||
}
|
||||
|
||||
// Scope before LIMIT and offer-order mutation: one Subject must not consume
|
||||
// another Subject's batch while the Host resolves their current bodies separately.
|
||||
pub(super) fn pending_sources_for_subject(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
role: &str,
|
||||
limit: usize,
|
||||
subject_id: Option<&str>,
|
||||
) -> Result<Vec<(String, String, String, String)>> {
|
||||
route_pending(conn, workspace)?;
|
||||
let mut stmt=conn.prepare("SELECT s.ticket_id,s.assignment_id,s.input_event_id,d.delivery_id FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.workspace_id=?1 AND s.role=?2 AND d.disposition='deliver' AND NOT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements a WHERE a.workspace_id=?1 AND a.delivery_id=d.delivery_id) AND NOT EXISTS(SELECT 1 FROM notification_acknowledgements a WHERE a.workspace_id=?1 AND a.delivery_id=d.delivery_id) ORDER BY d.last_offered,s.sequence LIMIT ?3")?;
|
||||
let mut stmt=conn.prepare("SELECT s.ticket_id,s.assignment_id,s.input_event_id,d.delivery_id FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.workspace_id=?1 AND s.role=?2 AND d.disposition='deliver' AND (?4 IS NULL OR EXISTS(SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=s.workspace_id AND a.ticket_id=s.ticket_id AND a.assignment_id=s.assignment_id AND a.role=s.role AND a.principal_kind='subject' AND a.principal_id=?4)) AND NOT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements a WHERE a.workspace_id=?1 AND a.delivery_id=d.delivery_id) AND NOT EXISTS(SELECT 1 FROM notification_acknowledgements a WHERE a.workspace_id=?1 AND a.delivery_id=d.delivery_id) ORDER BY d.last_offered,s.sequence LIMIT ?3")?;
|
||||
let candidates = stmt
|
||||
.query_map(params![workspace, role, limit.clamp(1, 100) as i64], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
})?
|
||||
.query_map(
|
||||
params![workspace, role, limit.clamp(1, 100) as i64, subject_id],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let tick: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(last_offered),0)+1 FROM notification_decisions WHERE workspace_id=?1",
|
||||
@@ -819,6 +888,13 @@ fn worker_guard(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn worker_acknowledged(conn: &Connection, workspace: &str, id: &str) -> Result<bool> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM notification_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2)",
|
||||
params![workspace, id], |row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_worker(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace: &str,
|
||||
@@ -833,6 +909,7 @@ pub(super) fn prepare_worker(
|
||||
}
|
||||
store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
worker_guard(&tx,workspace,id,target)?;
|
||||
if worker_acknowledged(&tx,workspace,id)? { return Err(Error::TicketAssignmentConflict("Worker notification already has Runtime acceptance; new send admission is closed".into())); }
|
||||
let saved:Option<(String,String)>=tx.query_row("SELECT content,target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
|
||||
let result=if let Some((saved,binding))=saved {
|
||||
if binding!=json(target)? { return Err(Error::TicketAssignmentConflict("Prepared notification target binding changed".into())); }
|
||||
@@ -841,6 +918,31 @@ pub(super) fn prepare_worker(
|
||||
tx.commit()?;Ok(result)
|
||||
})
|
||||
}
|
||||
/// Authorize a new Runtime send, not a historical receipt read. The Host must
|
||||
/// additionally check live recipient policy before supply and immediately before
|
||||
/// I/O. An ACK closes admission without erasing the pinned request or receipt.
|
||||
pub(super) fn admit_worker_send(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace: &str,
|
||||
id: &str,
|
||||
target: &TicketWorkerNotificationTarget,
|
||||
) -> Result<Option<String>> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
worker_guard(&tx, workspace, id, target)?;
|
||||
let (content, saved_target): (String, String) = tx.query_row(
|
||||
"SELECT content,target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
params![workspace, id], |row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).optional()?.ok_or_else(|| Error::TicketAssignmentConflict("Worker notification send requires a persisted request".into()))?;
|
||||
if saved_target != json(target)? {
|
||||
return Err(Error::TicketAssignmentConflict("Worker notification send target differs from prepared request".into()));
|
||||
}
|
||||
let content = if worker_acknowledged(&tx, workspace, id)? { None } else { Some(content) };
|
||||
tx.commit()?;
|
||||
Ok(content)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn ack_worker(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace: &str,
|
||||
@@ -866,8 +968,8 @@ mod tests {
|
||||
#[test]
|
||||
fn notification_routing_policy_distinguishes_requests_attention_results_and_suppression() {
|
||||
let mut facts = Facts {
|
||||
principal_kind: Some("workspace_agent".into()),
|
||||
principal_id: Some("workspace-orchestrator".into()),
|
||||
principal_kind: Some("subject".into()),
|
||||
principal_id: Some("subject-a".into()),
|
||||
runtime_id: None,
|
||||
worker_id: None,
|
||||
status: Some("waiting".into()),
|
||||
|
||||
@@ -49,7 +49,7 @@ fn routing_ack_worker_pending(
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_diagnostics_preview_unrouted_requests_readonly_beyond_global_batch() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
seed_manual_start_ticket(&store, "other-ticket", "open");
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction()?;
|
||||
@@ -59,7 +59,7 @@ async fn notification_routing_diagnostics_preview_unrouted_requests_readonly_bey
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
delivery_tracking_assign(&store, "a", "assign-a");
|
||||
delivery_store_answer(&store, "preview-answer");
|
||||
// An answer without the assignment fence must be suppressed by the same Rust policy.
|
||||
delivery_store_external_event(&store, 1, "assignment_answer", None);
|
||||
@@ -183,9 +183,9 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
|
||||
"worker-replaced",
|
||||
] {
|
||||
for prepare in [false, true] {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
let source = tracking_store_source();
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
delivery_tracking_assign(&store, "a", "assign-a");
|
||||
let source = delivery_tracking_source();
|
||||
let delivery = delivery_store_reserve_initial(&store, &source);
|
||||
if prepare {
|
||||
store
|
||||
@@ -200,7 +200,7 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
|
||||
match change {
|
||||
"done" => { store.update_ticket_assignment_tracking("w", "tracking", &tracking_store_input("a", "event-assign-a", "done-a"), &source).unwrap(); }
|
||||
"release" => { store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator, "a", "clear-a", "clear-a", "human", "now", None).unwrap(); }
|
||||
"reassign" => { tracking_store_assign(&store, "b", "assign-b"); }
|
||||
"reassign" => { delivery_tracking_assign(&store, "b", "assign-b"); }
|
||||
_ => store.with_conn(|conn| {
|
||||
match change {
|
||||
"revoked" => conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'", [])?,
|
||||
@@ -215,7 +215,7 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
|
||||
match field {
|
||||
"runtime" => forged.runtime_id = "forged".into(),
|
||||
"worker" => forged.worker_id = "replacement".into(),
|
||||
"agent" => forged.agent_key = "forged".into(),
|
||||
"agent" => forged.subject_id = "forged".into(),
|
||||
_ => forged.authentication_binding_id = "replacement-binding".into(),
|
||||
}
|
||||
assert!(
|
||||
@@ -280,9 +280,9 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
|
||||
#[tokio::test]
|
||||
async fn notification_routing_replacement_target_requires_explicit_authorized_reserve_or_prepare() {
|
||||
for prepare in [false, true] {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
let source = tracking_store_source();
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
delivery_tracking_assign(&store, "a", "assign-a");
|
||||
let source = delivery_tracking_source();
|
||||
let delivery = delivery_store_reserve_initial(&store, &source);
|
||||
store
|
||||
.prepare_ticket_assignment_delivery_content(
|
||||
@@ -292,10 +292,7 @@ async fn notification_routing_replacement_target_requires_explicit_authorized_re
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'", [])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
delivery_tracking_replace_body(&store);
|
||||
let replacement = TicketAssignmentTrackingSourceContext {
|
||||
worker_id: "replacement".into(),
|
||||
..source.clone()
|
||||
@@ -376,7 +373,7 @@ async fn notification_routing_replacement_target_requires_explicit_authorized_re
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_commit_without_callback_recovers_and_rollback_has_no_source() {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
store.with_conn_mut(|conn| {
|
||||
@@ -408,7 +405,7 @@ async fn notification_routing_commit_without_callback_recovers_and_rollback_has_
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_parallel_scan_and_render_reuse_request_after_response_loss() {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
delivery_store_external_event(&store, 0, "comment", None);
|
||||
@@ -497,7 +494,7 @@ async fn notification_routing_parallel_scan_and_render_reuse_request_after_respo
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_assignment_change_between_commit_and_scan_never_transfers_input() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
delivery_store_external_event(&store, 0, "comment", None);
|
||||
@@ -517,7 +514,7 @@ async fn notification_routing_assignment_change_between_commit_and_scan_never_tr
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_ack_retains_acceptance_after_responsibility_changes() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
delivery_store_external_event(&store, 0, "comment", None);
|
||||
@@ -557,7 +554,7 @@ async fn notification_routing_ack_retains_acceptance_after_responsibility_change
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_editor_is_not_original_author_and_unknown_events_are_deferred() {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
use ticket::TicketBackend;
|
||||
@@ -625,7 +622,7 @@ async fn notification_routing_editor_is_not_original_author_and_unknown_events_a
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_failures_do_not_starve_other_inputs_or_replace_requests() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
delivery_store_external_event(&store, 0, "comment", None);
|
||||
@@ -674,7 +671,7 @@ async fn notification_routing_failures_do_not_starve_other_inputs_or_replace_req
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_target_binding_and_workspace_are_rechecked_before_prepare() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
let input = store
|
||||
.list_pending_ticket_worker_notifications("w", 100)
|
||||
@@ -733,23 +730,27 @@ fn notification_routing_migration_preserves_receipts_does_not_replay_comments_an
|
||||
waiting_reason: None,
|
||||
next_action: None,
|
||||
};
|
||||
// Retained94 stores an agent-key context, not today's Subject context.
|
||||
let reserved_source_json = r#"{"actor":"authenticated-worker:r/worker","runtime_id":"r","worker_id":"worker","agent_key":"workspace-orchestrator","authentication_binding_id":"tracking-binding"}"#;
|
||||
let acknowledged_source_json = r#"{"actor":"authenticated-worker:r/worker","runtime_id":"r","worker_id":"old-replacement","agent_key":"workspace-orchestrator","authentication_binding_id":"tracking-binding"}"#;
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_deliveries VALUES('w','a-pending',?1,'preserved-id',?2,?3)",
|
||||
params![
|
||||
payload.input_event_id,
|
||||
serde_json::to_string(&payload).unwrap(),
|
||||
serde_json::to_string(&tracking_store_source()).unwrap()
|
||||
reserved_source_json
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w','preserved-id','immutable request')",[]).unwrap();
|
||||
let acknowledged_target = TicketAssignmentTrackingSourceContext {
|
||||
worker_id: "old-replacement".into(),
|
||||
..tracking_store_source()
|
||||
subject_id: "workspace-orchestrator".into(),
|
||||
..delivery_tracking_source()
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','preserved-id',?1)",
|
||||
[serde_json::to_string(&acknowledged_target).unwrap()],
|
||||
[acknowledged_source_json],
|
||||
)
|
||||
.unwrap();
|
||||
let requests_before = stored_fixture_rows(&conn, "ticket_assignment_delivery_requests");
|
||||
@@ -806,9 +807,18 @@ fn notification_routing_migration_preserves_receipts_does_not_replay_comments_an
|
||||
!pending.iter().any(|p| p.assignment_id == "a-pending"),
|
||||
"saved ACK remains authoritative even with non-derived legacy delivery ID"
|
||||
);
|
||||
store
|
||||
.ack_ticket_assignment_delivery("w", "preserved-id", &acknowledged_target)
|
||||
.unwrap();
|
||||
// A new Subject-shaped context must not reinterpret the immutable agent ACK.
|
||||
assert!(matches!(
|
||||
store.ack_ticket_assignment_delivery("w", "preserved-id", &acknowledged_target),
|
||||
Err(Error::TicketAssignmentConflict(_))
|
||||
));
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_delivery_requests"), requests_before);
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_delivery_acknowledgements"), acks_before);
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_deliveries"), before);
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_assignment_targets WHERE delivery_id='preserved-id' AND agent_key='workspace-orchestrator'", [], |row| row.get::<_, i64>(0))?, 2);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -817,7 +827,7 @@ async fn notification_routing_missing_authorized_target_guard_refuses_startup()
|
||||
"notification_assignment_targets_no_update",
|
||||
"notification_assignment_targets_no_delete",
|
||||
] {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch(&format!("DROP TRIGGER {guard}"))?;
|
||||
@@ -859,7 +869,7 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
|
||||
Ok(snapshot(conn))
|
||||
})
|
||||
.unwrap();
|
||||
let (fresh_directory, fresh) = tracking_store_fixture().await;
|
||||
let (fresh_directory, fresh) = delivery_tracking_fixture().await;
|
||||
fresh
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(snapshot(conn), upgraded);
|
||||
@@ -883,7 +893,7 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_content_without_recipient_records_reason_and_domain_reference() {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
use ticket::TicketBackend;
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w")
|
||||
@@ -912,7 +922,7 @@ async fn notification_routing_content_without_recipient_records_reason_and_domai
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_clear_retains_original_assignment_and_suppression_reason() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
store
|
||||
@@ -939,3 +949,297 @@ async fn notification_routing_clear_retains_original_assignment_and_suppression_
|
||||
assert_eq!(row,("worker-a".into(),"historical_only".into()));Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_notification_capture_uses_each_subject_current_body_and_results_never_retarget() {
|
||||
let (_directory, store) = subject_notification_store().await;
|
||||
subject_notification_assign(&store, "subject-ticket-a", "assigned-a", "subject-a");
|
||||
subject_notification_assign(&store, "subject-ticket-b", "assigned-b", "subject-b");
|
||||
let report = |operation: &str| server_api::TicketImplementationResultRequest {
|
||||
operation_key: operation.into(),
|
||||
kind: server_api::TicketImplementationResultKind::Result,
|
||||
body: "Saved background result".into(),
|
||||
merge_request: None,
|
||||
};
|
||||
// A Subject body's own result is not sent back to itself. The same Worker can
|
||||
// report to another Subject without a global singleton stealing the recipient.
|
||||
let body_a = RuntimeWorkerRef::new("r", "worker");
|
||||
let own = store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"subject-ticket-a",
|
||||
&report("own-a"),
|
||||
&body_a,
|
||||
"subject-binding",
|
||||
)
|
||||
.unwrap();
|
||||
let other = store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"subject-ticket-b",
|
||||
&report("a-to-b"),
|
||||
&body_a,
|
||||
"subject-binding",
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
let own_reason: String = conn.query_row("SELECT d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", own.result_id)], |row| row.get(0))?;
|
||||
assert_eq!(own_reason, "self_notification");
|
||||
let recipient: (String, String, String, String) = conn.query_row("SELECT s.assignment_id,json_extract(s.facts_json,'$.principal_id'),json_extract(s.facts_json,'$.worker_id'),d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", other.result_id)], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?;
|
||||
assert_eq!(recipient, ("assigned-b".into(), "subject-b".into(), "body-b".into(), "result".into()));
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
subject_notification_replace_body(&store, "subject-b");
|
||||
let replacement = RuntimeWorkerRef::new("r", "body-replacement");
|
||||
let self_after_swap = store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"subject-ticket-b",
|
||||
&report("new-body-self"),
|
||||
&replacement,
|
||||
"subject-binding",
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
let self_reason: String = conn.query_row("SELECT d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", self_after_swap.result_id)], |row| row.get(0))?;
|
||||
assert_eq!(self_reason, "self_notification");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
subject_notification_assign(&store, "subject-ticket-b", "new-a-assignment", "subject-a");
|
||||
assert!(
|
||||
!store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|input| input.input_event_id
|
||||
== format!("implementation-result:{}", other.result_id))
|
||||
);
|
||||
// Replaying the saved report is still evidence about the original assignment.
|
||||
assert_eq!(
|
||||
store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"subject-ticket-b",
|
||||
&report("a-to-b"),
|
||||
&body_a,
|
||||
"subject-binding"
|
||||
)
|
||||
.unwrap(),
|
||||
other
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
let recipient: String = conn.query_row(
|
||||
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
|
||||
[format!("implementation-result:{}", other.result_id)],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(recipient, "assigned-b");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_notification_upgrade_preserves_legacy_sources_requests_and_receipts_and_rolls_back() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let conn = tracking_schema91_fixture(&directory.path().join("workspace.db"));
|
||||
migrate_ticket_tracking_v91_to_v92(&conn).unwrap();
|
||||
migrate_assignment_delivery_v92_to_v93(&conn).unwrap();
|
||||
migrate_ticket_posts_v93_to_v94(&conn).unwrap();
|
||||
migrate_notification_routing_v94_to_v95(&conn).unwrap();
|
||||
migrate_job_notifications_v95_to_v96(&conn).unwrap();
|
||||
migrate_business_notifications_v96_to_v97(&conn).unwrap();
|
||||
migrate_result_reports_v97_to_v98(&conn).unwrap();
|
||||
let legacy_source = TicketAssignmentTrackingSourceContext {
|
||||
actor: "legacy-delivery".into(),
|
||||
runtime_id: "old-runtime".into(),
|
||||
worker_id: "old-worker".into(),
|
||||
subject_id: "workspace-orchestrator".into(),
|
||||
authentication_binding_id: "old-binding".into(),
|
||||
};
|
||||
let delivery = TicketAssignmentDelivery {
|
||||
ticket_id: "pending".into(),
|
||||
assignment_id: "a-pending".into(),
|
||||
input_event_id: "assignment-event:migration-tracking:a-pending".into(),
|
||||
delivery_id: "legacy-delivery".into(),
|
||||
request_id: "legacy-request".into(),
|
||||
waiting_reason: None,
|
||||
next_action: None,
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_deliveries VALUES('w','a-pending',?1,?2,?3,?4)",
|
||||
params![
|
||||
delivery.input_event_id,
|
||||
delivery.delivery_id,
|
||||
serde_json::to_string(&delivery).unwrap(),
|
||||
serde_json::to_string(&legacy_source).unwrap()
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w','legacy-delivery','legacy bytes')", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','legacy-delivery',?1)",
|
||||
[serde_json::to_string(&legacy_source).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let tables = [
|
||||
"ticket_worker_assignments",
|
||||
"notification_sources",
|
||||
"notification_decisions",
|
||||
"ticket_assignment_deliveries",
|
||||
"ticket_assignment_delivery_requests",
|
||||
"ticket_assignment_delivery_acknowledgements",
|
||||
];
|
||||
let before: Vec<_> = tables
|
||||
.iter()
|
||||
.map(|table| stored_fixture_rows(&conn, table))
|
||||
.collect();
|
||||
let triggers_before = fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT name,sql FROM sqlite_schema WHERE type='trigger' ORDER BY name",
|
||||
);
|
||||
// Fail after some triggers were refreshed. The caller's transaction restores
|
||||
// the entire previous capture schema, not a half-upgraded notification path.
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
tx.execute_batch("DROP TRIGGER notification_mr_event; CREATE TRIGGER notification_mr_event AFTER INSERT ON merge_request_thread_events BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(notification_routing::install_subject_recipients(&tx).is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT name,sql FROM sqlite_schema WHERE type='trigger' ORDER BY name"
|
||||
),
|
||||
triggers_before
|
||||
);
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
notification_routing::install_subject_recipients(&tx).unwrap();
|
||||
tx.commit().unwrap();
|
||||
let after: Vec<_> = tables
|
||||
.iter()
|
||||
.map(|table| stored_fixture_rows(&conn, table))
|
||||
.collect();
|
||||
assert_eq!(after, before, "migration updates future capture only");
|
||||
for trigger in [
|
||||
"notification_ticket_event",
|
||||
"notification_assignment_history",
|
||||
"notification_assignment_change",
|
||||
"notification_assignment_release",
|
||||
"notification_content_input",
|
||||
"notification_unassigned_content",
|
||||
"notification_mr_event",
|
||||
"notification_mr_source",
|
||||
"notification_implementation_result",
|
||||
] {
|
||||
let sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[trigger],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sql.contains("a.principal_kind='subject'"), "{trigger}");
|
||||
assert!(
|
||||
!sql.contains("a.principal_kind='workspace_agent'"),
|
||||
"{trigger}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
notification_routing::pending_sources(&conn, "w", "orchestrator", 100)
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"old principals and receipts are evidence, not new Subject requests"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_cached_worker_attention_stops_after_ack_not_after_unknown_acceptance()
|
||||
{
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
let cached = store
|
||||
.list_pending_ticket_worker_notifications("w", 100)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert!(
|
||||
notification_routing::admit_worker_send(&store, "w", &cached.delivery_id, &target).is_err()
|
||||
);
|
||||
store
|
||||
.prepare_ticket_worker_notification_content(
|
||||
"w",
|
||||
&cached.delivery_id,
|
||||
"pinned attention",
|
||||
&target,
|
||||
)
|
||||
.unwrap();
|
||||
let mut sends = Vec::new();
|
||||
let mut dispatch_cached = |store: &SqliteWorkspaceStore| {
|
||||
if let Some(content) =
|
||||
notification_routing::admit_worker_send(store, "w", &cached.delivery_id, &target)
|
||||
.unwrap()
|
||||
{
|
||||
sends.push((cached.request_id.clone(), content));
|
||||
}
|
||||
};
|
||||
dispatch_cached(&store);
|
||||
store
|
||||
.record_notification_delivery_failure(
|
||||
"w",
|
||||
&NotificationDeliveryKey::WorkerNotification {
|
||||
delivery_id: cached.delivery_id.clone(),
|
||||
},
|
||||
NotificationFailureCode::AcceptanceUnknown,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.prepare_ticket_worker_notification_content(
|
||||
"w",
|
||||
&cached.delivery_id,
|
||||
"different rendering",
|
||||
&target
|
||||
)
|
||||
.unwrap(),
|
||||
"pinned attention"
|
||||
);
|
||||
dispatch_cached(&store);
|
||||
let other = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
|
||||
other
|
||||
.ack_ticket_worker_notification("w", &cached.delivery_id, &target)
|
||||
.unwrap();
|
||||
dispatch_cached(&store);
|
||||
assert_eq!(
|
||||
sends,
|
||||
vec![(cached.request_id.clone(), "pinned attention".into()); 2]
|
||||
);
|
||||
assert!(matches!(
|
||||
store.prepare_ticket_worker_notification_content(
|
||||
"w",
|
||||
&cached.delivery_id,
|
||||
"late render",
|
||||
&target
|
||||
),
|
||||
Err(Error::TicketAssignmentConflict(_))
|
||||
));
|
||||
store
|
||||
.ack_ticket_worker_notification("w", &cached.delivery_id, &target)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.list_pending_ticket_worker_notifications("w", 100)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
let content: String = conn.query_row("SELECT content FROM notification_requests WHERE workspace_id='w' AND delivery_id=?1", [&cached.delivery_id], |row| row.get(0))?;
|
||||
assert_eq!(content, "pinned attention", "historical request bytes remain readable");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Static Workspace HTTPS policy, not network admission. Runtime must recheck live
|
||||
//! binding state, resolve/check every DNS address, pin the connection, verify TLS,
|
||||
//! inject credentials, and enforce these bounds (including decompressed bytes).
|
||||
//! Plans retain the binding identity and digest, never this destination policy.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use server_api::PluginHttpMethod;
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::{Host, Url};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginNetworkPolicy {
|
||||
/// DNS names are provisional until all resolved addresses pass live checks.
|
||||
/// Private-network exceptions are deliberately not supported by this policy.
|
||||
#[default]
|
||||
PublicInternet,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginConnectionLimits {
|
||||
pub timeout_ms: u32,
|
||||
pub max_request_bytes: u32,
|
||||
pub max_response_bytes: u32,
|
||||
pub max_header_bytes: u32,
|
||||
}
|
||||
|
||||
/// Trusted server operational ceiling, supplied separately from Workspace input.
|
||||
/// Deserializing a binding cannot set or expand this ceiling.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginConnectionPolicyCeiling {
|
||||
pub limits: PluginConnectionLimits,
|
||||
}
|
||||
|
||||
impl Default for PluginConnectionPolicyCeiling {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limits: PluginConnectionLimits {
|
||||
timeout_ms: 15_000,
|
||||
max_request_bytes: 1024 * 1024,
|
||||
max_response_bytes: 1024 * 1024,
|
||||
max_header_bytes: 16 * 1024,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginConnectionBinding {
|
||||
/// Canonical origin only: no trailing slash, path, query, credentials or fragment.
|
||||
pub origin: String,
|
||||
#[serde(default)]
|
||||
pub network: PluginNetworkPolicy,
|
||||
pub methods: Vec<PluginHttpMethod>,
|
||||
pub path_prefixes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub request_headers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub response_headers: Vec<String>,
|
||||
/// Empty means no credential. Otherwise an opaque Workspace-local secret ID;
|
||||
/// never a value, token, provider URL, or filesystem reference.
|
||||
#[serde(default)]
|
||||
pub secret_ref: String,
|
||||
pub limits: PluginConnectionLimits,
|
||||
}
|
||||
|
||||
/// Safe diagnostic categories contain no caller-supplied URLs, headers or secrets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PluginBindingError {
|
||||
#[error("invalid plugin binding identity")]
|
||||
InvalidIdentity,
|
||||
#[error("plugin connection requires an exact canonical HTTPS origin")]
|
||||
InvalidOrigin,
|
||||
#[error("plugin connection destination is not public internet")]
|
||||
NonPublicDestination,
|
||||
#[error("plugin connection methods must be nonempty and unique")]
|
||||
InvalidMethods,
|
||||
#[error("plugin connection paths must be nonempty, unique canonical segment prefixes")]
|
||||
InvalidPaths,
|
||||
#[error("plugin connection header allowlist is invalid or contains a reserved header")]
|
||||
InvalidHeaders,
|
||||
#[error("plugin connection secret must be an opaque reference")]
|
||||
InvalidSecretReference,
|
||||
#[error("plugin connection limits must be positive and within the server ceiling")]
|
||||
InvalidLimits,
|
||||
#[error("too many plugin bindings")]
|
||||
TooManyBindings,
|
||||
}
|
||||
|
||||
impl PluginConnectionBinding {
|
||||
pub fn validate(
|
||||
&self,
|
||||
ceiling: &PluginConnectionPolicyCeiling,
|
||||
) -> Result<(), PluginBindingError> {
|
||||
validate_origin(&self.origin)?;
|
||||
if self.methods.is_empty() || self.methods.len() > 7 {
|
||||
return Err(PluginBindingError::InvalidMethods);
|
||||
}
|
||||
let mut methods = BTreeSet::new();
|
||||
if self
|
||||
.methods
|
||||
.iter()
|
||||
.any(|method| !methods.insert(method_name(method)))
|
||||
{
|
||||
return Err(PluginBindingError::InvalidMethods);
|
||||
}
|
||||
if self.path_prefixes.is_empty()
|
||||
|| self.path_prefixes.len() > 64
|
||||
|| !unique_strings(&self.path_prefixes)
|
||||
|| self.path_prefixes.iter().any(|path| !canonical_path(path))
|
||||
{
|
||||
return Err(PluginBindingError::InvalidPaths);
|
||||
}
|
||||
for headers in [&self.request_headers, &self.response_headers] {
|
||||
if headers.len() > 64
|
||||
|| !unique_strings(headers)
|
||||
|| headers.iter().any(|header| !allowed_header(header))
|
||||
{
|
||||
return Err(PluginBindingError::InvalidHeaders);
|
||||
}
|
||||
}
|
||||
if !self.secret_ref.is_empty() && !opaque_reference(&self.secret_ref) {
|
||||
return Err(PluginBindingError::InvalidSecretReference);
|
||||
}
|
||||
for (value, maximum) in [
|
||||
(self.limits.timeout_ms, ceiling.limits.timeout_ms),
|
||||
(
|
||||
self.limits.max_request_bytes,
|
||||
ceiling.limits.max_request_bytes,
|
||||
),
|
||||
(
|
||||
self.limits.max_response_bytes,
|
||||
ceiling.limits.max_response_bytes,
|
||||
),
|
||||
(
|
||||
self.limits.max_header_bytes,
|
||||
ceiling.limits.max_header_bytes,
|
||||
),
|
||||
] {
|
||||
if value == 0 || maximum == 0 || value > maximum {
|
||||
return Err(PluginBindingError::InvalidLimits);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Versioned SHA-256 of policy content. Allowlists have set semantics, so their
|
||||
/// order does not affect identity. Call `validate` before accepting a digest.
|
||||
/// Binding map identity is separate and must also be pinned by the caller.
|
||||
pub fn digest(&self) -> String {
|
||||
let mut canonical = self.clone();
|
||||
canonical.methods.sort_by_key(method_name);
|
||||
canonical.path_prefixes.sort();
|
||||
canonical.request_headers.sort();
|
||||
canonical.response_headers.sort();
|
||||
let bytes = serde_json::to_vec(&canonical).expect("binding contains only JSON primitives");
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"yoi-plugin-connection-policy-v1\0");
|
||||
hash.update(bytes);
|
||||
let hex: String = hash
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
format!("sha256:{hex}")
|
||||
}
|
||||
|
||||
/// Static segment-boundary matching only; this is not runtime authorization.
|
||||
/// Conservative paths exclude percent encodings to avoid decoder disagreement.
|
||||
pub fn allows_path(&self, path: &str) -> bool {
|
||||
self.path_prefixes
|
||||
.iter()
|
||||
.any(|prefix| path_within(prefix, path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the Workspace-owned map without copying any destination into a plan.
|
||||
/// The map key is the binding identity; pin that key and `binding.digest()` only.
|
||||
pub fn validate_bindings(
|
||||
bindings: &BTreeMap<String, PluginConnectionBinding>,
|
||||
ceiling: &PluginConnectionPolicyCeiling,
|
||||
) -> Result<(), PluginBindingError> {
|
||||
if bindings.len() > 256 {
|
||||
return Err(PluginBindingError::TooManyBindings);
|
||||
}
|
||||
for (identity, binding) in bindings {
|
||||
if !opaque_reference(identity) {
|
||||
return Err(PluginBindingError::InvalidIdentity);
|
||||
}
|
||||
binding.validate(ceiling)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Conservative canonical path containment on segment boundaries, not a raw
|
||||
/// string prefix. Runtime must use the same canonical path for checking/sending.
|
||||
pub fn path_within(prefix: &str, path: &str) -> bool {
|
||||
canonical_path(prefix)
|
||||
&& canonical_path(path)
|
||||
&& (prefix == "/"
|
||||
|| path == prefix
|
||||
|| path
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|rest| rest.starts_with('/')))
|
||||
}
|
||||
|
||||
fn method_name(method: &PluginHttpMethod) -> &'static str {
|
||||
match method {
|
||||
PluginHttpMethod::Get => "GET",
|
||||
PluginHttpMethod::Head => "HEAD",
|
||||
PluginHttpMethod::Post => "POST",
|
||||
PluginHttpMethod::Put => "PUT",
|
||||
PluginHttpMethod::Patch => "PATCH",
|
||||
PluginHttpMethod::Delete => "DELETE",
|
||||
PluginHttpMethod::Options => "OPTIONS",
|
||||
}
|
||||
}
|
||||
|
||||
fn opaque_reference(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
|
||||
}
|
||||
|
||||
fn unique_strings(values: &[String]) -> bool {
|
||||
values.iter().collect::<BTreeSet<_>>().len() == values.len()
|
||||
}
|
||||
|
||||
fn canonical_path(path: &str) -> bool {
|
||||
if path == "/" {
|
||||
return true;
|
||||
}
|
||||
path.len() <= 2048
|
||||
&& path.starts_with('/')
|
||||
&& path.bytes().all(|b| {
|
||||
b.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
b,
|
||||
b'/' | b'-'
|
||||
| b'_'
|
||||
| b'.'
|
||||
| b'~'
|
||||
| b'!'
|
||||
| b'$'
|
||||
| b'&'
|
||||
| b'\''
|
||||
| b'('
|
||||
| b')'
|
||||
| b'*'
|
||||
| b'+'
|
||||
| b','
|
||||
| b';'
|
||||
| b'='
|
||||
| b':'
|
||||
| b'@'
|
||||
)
|
||||
})
|
||||
&& path[1..]
|
||||
.split('/')
|
||||
.all(|part| !matches!(part, "" | "." | ".."))
|
||||
}
|
||||
|
||||
fn allowed_header(header: &str) -> bool {
|
||||
!header.is_empty()
|
||||
&& header.len() <= 128
|
||||
&& header
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& !header.starts_with("proxy-")
|
||||
&& !header.starts_with("sec-")
|
||||
&& !matches!(
|
||||
header,
|
||||
"authorization"
|
||||
| "www-authenticate"
|
||||
| "cookie"
|
||||
| "set-cookie"
|
||||
| "host"
|
||||
| "connection"
|
||||
| "keep-alive"
|
||||
| "transfer-encoding"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "upgrade"
|
||||
| "content-length"
|
||||
| "expect"
|
||||
| "forwarded"
|
||||
| "via"
|
||||
| "x-forwarded-for"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-proto"
|
||||
| "x-api-key"
|
||||
| "api-key"
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_origin(origin: &str) -> Result<(), PluginBindingError> {
|
||||
if origin.len() > 512 || !origin.is_ascii() {
|
||||
return Err(PluginBindingError::InvalidOrigin);
|
||||
}
|
||||
let url = Url::parse(origin).map_err(|_| PluginBindingError::InvalidOrigin)?;
|
||||
if url.scheme() != "https" || url.origin().ascii_serialization() != origin {
|
||||
return Err(PluginBindingError::InvalidOrigin);
|
||||
}
|
||||
let public = match url.host().ok_or(PluginBindingError::InvalidOrigin)? {
|
||||
Host::Domain(host) => {
|
||||
let labels: Vec<_> = host.split('.').collect();
|
||||
labels.len() >= 2
|
||||
&& host.len() <= 253
|
||||
&& labels.iter().all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|
||||
&& label
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
})
|
||||
&& ![
|
||||
"localhost",
|
||||
"local",
|
||||
"internal",
|
||||
"home",
|
||||
"lan",
|
||||
"test",
|
||||
"invalid",
|
||||
"onion",
|
||||
"arpa",
|
||||
]
|
||||
.iter()
|
||||
.any(|suffix| host == *suffix || host.ends_with(&format!(".{suffix}")))
|
||||
}
|
||||
Host::Ipv4(ip) => public_ipv4(ip),
|
||||
Host::Ipv6(ip) => public_ipv6(ip),
|
||||
};
|
||||
if !public {
|
||||
return Err(PluginBindingError::NonPublicDestination);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn public_ipv4(ip: Ipv4Addr) -> bool {
|
||||
let [a, b, c, _] = ip.octets();
|
||||
// Conservative special-purpose exclusion; no Workspace-configurable bypass.
|
||||
!matches!(a, 0 | 10 | 127 | 224..=255)
|
||||
&& !(a == 100 && (64..=127).contains(&b))
|
||||
&& !(a == 169 && b == 254)
|
||||
&& !(a == 172 && (16..=31).contains(&b))
|
||||
&& !(a == 192 && (b == 0 || b == 168 || (b == 88 && c == 99)))
|
||||
&& !(a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100)))
|
||||
&& !(a == 203 && b == 0 && c == 113)
|
||||
}
|
||||
|
||||
fn public_ipv6(ip: Ipv6Addr) -> bool {
|
||||
let [a, b, ..] = ip.segments();
|
||||
// Only current global-unicast space; reject transition/special-purpose and
|
||||
// documentation ranges as well as mapped IPv4, local, multicast and ULA.
|
||||
(0x2000..=0x3fff).contains(&a)
|
||||
&& !(a == 0x2001 && (b <= 0x01ff || b == 0x0db8))
|
||||
&& a != 0x2002
|
||||
&& !(a == 0x3fff && b <= 0x0fff)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,529 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn input() -> Value {
|
||||
json!({
|
||||
"origin": "https://api.example.com",
|
||||
"methods": ["GET", "POST"],
|
||||
"path_prefixes": ["/api", "/v2"],
|
||||
"request_headers": ["accept", "content-type"],
|
||||
"response_headers": ["content-type", "etag"],
|
||||
"secret_ref": "secret-123",
|
||||
"limits": {
|
||||
"timeout_ms": 1000,
|
||||
"max_request_bytes": 1024,
|
||||
"max_response_bytes": 4096,
|
||||
"max_header_bytes": 512
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn binding(value: Value) -> PluginConnectionBinding {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_input_table_requires_canonical_exact_https_and_public_destination() {
|
||||
for (origin, expected) in [
|
||||
("https://api.example.com", Ok(())),
|
||||
("https://api.example.com:8443", Ok(())),
|
||||
("https://8.8.8.8", Ok(())),
|
||||
("https://[2606:4700:4700::1111]", Ok(())),
|
||||
(
|
||||
"http://api.example.com",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://api.example.com/",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://api.example.com/api",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://api.example.com?token=value",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://api.example.com#fragment",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://user:password@api.example.com",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://@api.example.com",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://api.example.com:443",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://API.example.com",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://api.example.com\\evil",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://api.example.com\n",
|
||||
Err(PluginBindingError::InvalidOrigin),
|
||||
),
|
||||
(
|
||||
"https://*.example.com",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://localhost",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://metadata.google.internal",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://service.local",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://example.com.",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://127.0.0.1",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://10.0.0.1",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://172.16.0.1",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://192.168.1.1",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://169.254.169.254",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://100.64.0.1",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://192.0.2.1",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://198.51.100.1",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://203.0.113.1",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://224.1.2.3",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://0.0.0.0",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
("https://2130706433", Err(PluginBindingError::InvalidOrigin)),
|
||||
("https://0177.0.0.1", Err(PluginBindingError::InvalidOrigin)),
|
||||
(
|
||||
"https://[::1]",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://[::ffff:7f00:1]",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://[fd00::1]",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://[fe80::1]",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://[2001:db8::1]",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
(
|
||||
"https://[2002:7f00:1::]",
|
||||
Err(PluginBindingError::NonPublicDestination),
|
||||
),
|
||||
] {
|
||||
let mut value = input();
|
||||
value["origin"] = json!(origin);
|
||||
assert_eq!(
|
||||
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
|
||||
expected,
|
||||
"{origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_input_table_rejects_traversal_and_preserves_segment_boundaries() {
|
||||
for (path, accepted) in [
|
||||
("/", true),
|
||||
("/api", true),
|
||||
("/api/v1", true),
|
||||
("api", false),
|
||||
("//evil.example", false),
|
||||
("/api/", false),
|
||||
("/api//v1", false),
|
||||
("/api/../admin", false),
|
||||
("/./api", false),
|
||||
("/api\\admin", false),
|
||||
("/api?x=1", false),
|
||||
("/api#x", false),
|
||||
("/api/%2e%2e/admin", false),
|
||||
("/api%2fadmin", false),
|
||||
("/api/%252e%252e", false),
|
||||
("/api/%5Cadmin", false),
|
||||
("https://evil.example/api", false),
|
||||
("/api/\n", false),
|
||||
] {
|
||||
let mut value = input();
|
||||
value["path_prefixes"] = json!([path]);
|
||||
assert_eq!(
|
||||
binding(value)
|
||||
.validate(&PluginConnectionPolicyCeiling::default())
|
||||
.is_ok(),
|
||||
accepted,
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
let value = binding(input());
|
||||
for (path, allowed) in [
|
||||
("/api", true),
|
||||
("/api/v1", true),
|
||||
("/apix", false),
|
||||
("/admin", false),
|
||||
("/api/../admin", false),
|
||||
] {
|
||||
assert_eq!(value.allows_path(path), allowed, "{path}");
|
||||
}
|
||||
for paths in [json!([]), json!(["/api", "/api"])] {
|
||||
let mut value = input();
|
||||
value["path_prefixes"] = paths;
|
||||
assert_eq!(
|
||||
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
|
||||
Err(PluginBindingError::InvalidPaths)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_input_table_rejects_guest_credentials_and_transport_controls() {
|
||||
for field in ["request_headers", "response_headers"] {
|
||||
for (name, accepted) in [
|
||||
("accept", true),
|
||||
("content-type", true),
|
||||
("x-request-id", true),
|
||||
("authorization", false),
|
||||
("cookie", false),
|
||||
("set-cookie", false),
|
||||
("host", false),
|
||||
("proxy-authorization", false),
|
||||
("connection", false),
|
||||
("content-length", false),
|
||||
("transfer-encoding", false),
|
||||
("upgrade", false),
|
||||
("te", false),
|
||||
("trailer", false),
|
||||
("keep-alive", false),
|
||||
("sec-websocket-key", false),
|
||||
("x-api-key", false),
|
||||
("Content-Type", false),
|
||||
("x-a\r\n", false),
|
||||
("x-a: value", false),
|
||||
("", false),
|
||||
] {
|
||||
let mut value = input();
|
||||
value[field] = json!([name]);
|
||||
assert_eq!(
|
||||
binding(value)
|
||||
.validate(&PluginConnectionPolicyCeiling::default())
|
||||
.is_ok(),
|
||||
accepted,
|
||||
"{field}:{name}"
|
||||
);
|
||||
}
|
||||
let mut value = input();
|
||||
value[field] = json!(["accept", "accept"]);
|
||||
assert_eq!(
|
||||
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
|
||||
Err(PluginBindingError::InvalidHeaders)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_input_table_requires_positive_finite_values_within_each_server_bound() {
|
||||
let ceiling = PluginConnectionPolicyCeiling::default();
|
||||
for (field, max) in [
|
||||
("timeout_ms", ceiling.limits.timeout_ms),
|
||||
("max_request_bytes", ceiling.limits.max_request_bytes),
|
||||
("max_response_bytes", ceiling.limits.max_response_bytes),
|
||||
("max_header_bytes", ceiling.limits.max_header_bytes),
|
||||
] {
|
||||
for (amount, accepted) in [(0, false), (1, true), (max, true), (max + 1, false)] {
|
||||
let mut value = input();
|
||||
value["limits"][field] = json!(amount);
|
||||
assert_eq!(
|
||||
binding(value).validate(&ceiling).is_ok(),
|
||||
accepted,
|
||||
"{field}:{amount}"
|
||||
);
|
||||
}
|
||||
for invalid in [
|
||||
json!(-1),
|
||||
json!(1.5),
|
||||
json!(4294967296u64),
|
||||
json!("unlimited"),
|
||||
Value::Null,
|
||||
] {
|
||||
let mut value = input();
|
||||
value["limits"][field] = invalid;
|
||||
assert!(
|
||||
serde_json::from_value::<PluginConnectionBinding>(value).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut zero_ceiling = ceiling;
|
||||
zero_ceiling.limits.timeout_ms = 0;
|
||||
assert_eq!(
|
||||
binding(input()).validate(&zero_ceiling),
|
||||
Err(PluginBindingError::InvalidLimits)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_and_identity_inputs_accept_only_opaque_workspace_references() {
|
||||
for (reference, accepted) in [
|
||||
("", true),
|
||||
("secret_123-a", true),
|
||||
("/etc/secret", false),
|
||||
("file:///etc/secret", false),
|
||||
("https://secret.example/token", false),
|
||||
("Bearer token", false),
|
||||
("env:API_KEY", false),
|
||||
("../secret", false),
|
||||
] {
|
||||
let mut value = input();
|
||||
value["secret_ref"] = json!(reference);
|
||||
assert_eq!(
|
||||
binding(value)
|
||||
.validate(&PluginConnectionPolicyCeiling::default())
|
||||
.is_ok(),
|
||||
accepted,
|
||||
"{reference}"
|
||||
);
|
||||
}
|
||||
for (identity, accepted) in [
|
||||
("api-binding", true),
|
||||
("", false),
|
||||
("../binding", false),
|
||||
("https://example.com", false),
|
||||
] {
|
||||
let bindings = BTreeMap::from([(identity.to_string(), binding(input()))]);
|
||||
assert_eq!(
|
||||
validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).is_ok(),
|
||||
accepted
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_boundary_is_closed_and_methods_network_are_not_ambient_authority() {
|
||||
for (field, value) in [
|
||||
("secret_value", json!("password")),
|
||||
("token", json!("token")),
|
||||
("proxy", json!("https://proxy.example")),
|
||||
("host_path", json!("/tmp")),
|
||||
("tls_verify", json!(false)),
|
||||
("allow_private", json!(true)),
|
||||
("network", json!("private")),
|
||||
("methods", json!(["CONNECT"])),
|
||||
] {
|
||||
let mut source = input();
|
||||
source[field] = value;
|
||||
assert!(
|
||||
serde_json::from_value::<PluginConnectionBinding>(source).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
for methods in [json!([]), json!(["GET", "GET"])] {
|
||||
let mut source = input();
|
||||
source["methods"] = methods;
|
||||
assert_eq!(
|
||||
binding(source).validate(&PluginConnectionPolicyCeiling::default()),
|
||||
Err(PluginBindingError::InvalidMethods)
|
||||
);
|
||||
}
|
||||
let mut source = input();
|
||||
source["limits"]["unlimited"] = json!(true);
|
||||
assert!(serde_json::from_value::<PluginConnectionBinding>(source).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_digest_is_order_independent_and_covers_every_policy_field() {
|
||||
let original = binding(input());
|
||||
let mut reordered = original.clone();
|
||||
reordered.methods.reverse();
|
||||
reordered.path_prefixes.reverse();
|
||||
reordered.request_headers.reverse();
|
||||
reordered.response_headers.reverse();
|
||||
assert_eq!(original.digest(), reordered.digest());
|
||||
assert_eq!(
|
||||
original.digest(),
|
||||
"sha256:df6bd126eb6e825349a3c2e40f48f9d18732969a0775fe6f1b7f11b1667869c9"
|
||||
);
|
||||
assert_eq!(original.digest().len(), 71);
|
||||
assert!(original.digest().starts_with("sha256:"));
|
||||
for (field, value) in [
|
||||
("origin", json!("https://other.example.com")),
|
||||
("methods", json!(["GET"])),
|
||||
("path_prefixes", json!(["/v2"])),
|
||||
("request_headers", json!([])),
|
||||
("response_headers", json!([])),
|
||||
("secret_ref", json!("other-secret")),
|
||||
] {
|
||||
let mut source = input();
|
||||
source[field] = value;
|
||||
assert_ne!(original.digest(), binding(source).digest(), "{field}");
|
||||
}
|
||||
for field in [
|
||||
"timeout_ms",
|
||||
"max_request_bytes",
|
||||
"max_response_bytes",
|
||||
"max_header_bytes",
|
||||
] {
|
||||
let mut source = input();
|
||||
source["limits"][field] = json!(2);
|
||||
assert_ne!(original.digest(), binding(source).digest(), "{field}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_collections_and_references_have_finite_size_bounds() {
|
||||
let ceiling = PluginConnectionPolicyCeiling::default();
|
||||
for (count, accepted) in [(64, true), (65, false)] {
|
||||
for field in ["path_prefixes", "request_headers", "response_headers"] {
|
||||
let mut source = input();
|
||||
source[field] = json!(
|
||||
(0..count)
|
||||
.map(|n| {
|
||||
if field == "path_prefixes" {
|
||||
format!("/path-{n}")
|
||||
} else {
|
||||
format!("x-header-{n}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
binding(source).validate(&ceiling).is_ok(),
|
||||
accepted,
|
||||
"{field}:{count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for (count, accepted) in [(256, true), (257, false)] {
|
||||
let bindings = (0..count)
|
||||
.map(|n| (format!("binding-{n}"), binding(input())))
|
||||
.collect();
|
||||
assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted);
|
||||
}
|
||||
for (size, accepted) in [(128, true), (129, false)] {
|
||||
let mut source = input();
|
||||
source["secret_ref"] = json!("a".repeat(size));
|
||||
assert_eq!(binding(source).validate(&ceiling).is_ok(), accepted);
|
||||
let bindings = BTreeMap::from([("a".repeat(size), binding(input()))]);
|
||||
assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted);
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_schema(source: &str) -> Result<Value, String> {
|
||||
use config_source::{
|
||||
ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeSnapshot,
|
||||
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
||||
};
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
path.clone(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:plugins",
|
||||
"plugins",
|
||||
"1",
|
||||
include_str!("../../../../resources/config-schema/plugins.dcdl"),
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(
|
||||
&ToolchainContract::with_schema_bundle(1, vec![path], 1, bundle),
|
||||
);
|
||||
result
|
||||
.map(|evaluated| evaluated.projections[0].data_json.clone())
|
||||
.map_err(|error| format!("{error:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_schema_defaults_maps_and_projects_typed_binding_to_rust() {
|
||||
assert_eq!(
|
||||
evaluate_schema("{}").unwrap(),
|
||||
json!({"plugins": {"enabled": {}, "bindings": {}}})
|
||||
);
|
||||
let projected = evaluate_schema(r#"{
|
||||
plugins = {
|
||||
enabled = { demo = {
|
||||
installation_id = "plugin-1"; expected_last_operation = "op-1";
|
||||
package_digest = "package"; artifact_digest = "artifact"; tools = ["query"];
|
||||
}; };
|
||||
bindings = { api = {
|
||||
origin = "https://api.example.com"; methods = ["GET"]; path_prefixes = ["/api"];
|
||||
limits = { timeout_ms = 1000; max_request_bytes = 1024; max_response_bytes = 4096; max_header_bytes = 512; };
|
||||
}; };
|
||||
};
|
||||
}"#).unwrap();
|
||||
assert_eq!(projected["plugins"]["enabled"]["demo"]["logging"], false);
|
||||
assert_eq!(
|
||||
projected["plugins"]["enabled"]["demo"]["connections"],
|
||||
json!({})
|
||||
);
|
||||
assert_eq!(projected["plugins"]["enabled"]["demo"]["config"], json!({}));
|
||||
let bindings: BTreeMap<String, PluginConnectionBinding> =
|
||||
serde_json::from_value(projected["plugins"]["bindings"].clone()).unwrap();
|
||||
validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_schema_rejects_unknown_fields_and_wrong_binding_shapes() {
|
||||
for source in [
|
||||
"{ plugins = { token = \"value\"; }; }",
|
||||
"{ plugins = { bindings = { api = { origin = 123; }; }; }; }",
|
||||
"{ plugins = { enabled = { demo = { arbitrary = true; }; }; }; }",
|
||||
"{ plugins = { bindings = { api = \"https://api.example.com\"; }; }; }",
|
||||
] {
|
||||
assert!(evaluate_schema(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,11 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
|
||||
assert_eq!(catalog_schema(c),canonical);
|
||||
let after=workspace_schema_migration_history(c)?;
|
||||
assert!(after.starts_with(&before));
|
||||
assert_eq!(after.last().unwrap(),&(106,crate::plugin_catalog::MIGRATION_NAME.into()));
|
||||
// Plugin catalog remains migration 106 even when startup applies later steps.
|
||||
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
||||
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||
assert_eq!(after.last().unwrap(), &(108, "Subject Ticket responsibility and current-body notification routing".into()));
|
||||
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
||||
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
||||
assert!(c.execute("INSERT INTO plugin_installations VALUES('absent','i','alias','p','operation',0)",[]).is_err());
|
||||
@@ -57,7 +61,18 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
|
||||
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
migrated
|
||||
.with_conn(|c| {
|
||||
assert_eq!(current_schema_version(c)?, 106);
|
||||
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
||||
let mut expected = before.clone();
|
||||
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
|
||||
expected.push((
|
||||
107,
|
||||
"Immutable Worker Plugin plans and retained pins".into(),
|
||||
));
|
||||
expected.push((
|
||||
108,
|
||||
"Subject Ticket responsibility and current-body notification routing".into(),
|
||||
));
|
||||
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
||||
crate::plugin_catalog::verify(c)?;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Immutable, Server-owned Plugin execution snapshots and same-database pins.
|
||||
//!
|
||||
//! The config owner supplies an already validated, non-secret projection; this
|
||||
//! module never reads config, selects a Profile, fetches packages or resolves
|
||||
//! credentials. Host-bound execution identities are trusted inputs, not caller
|
||||
//! authentication. Service entrypoints must authenticate/authorize every call,
|
||||
//! including replay and reads. A saved plan is an upper bound, not live authority.
|
||||
//!
|
||||
//! Owner transaction functions compose with Worker creation in the catalog DB.
|
||||
//! Their local savepoints also roll back partial pin acquisition if an owner
|
||||
//! catches an error and commits other work. There is no cross-store compensation.
|
||||
use crate::{Error, Result, plugin_catalog::PluginCatalogError};
|
||||
use manifest::plugin;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use server_api::{PluginArtifact, PluginGrantMetadata};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
mod store;
|
||||
pub use store::*;
|
||||
|
||||
/// Included by the config owner's ordered database migration, never at runtime.
|
||||
pub const SCHEMA_SQL: &str = include_str!("plugin_plan/schema.sql");
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigProvenance {
|
||||
pub content_digest: String,
|
||||
pub toolchain_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProfileProvenance {
|
||||
pub identity: String,
|
||||
pub projection_digest: String,
|
||||
}
|
||||
|
||||
/// Supplied by the authenticated Host launch/delegation boundary, never by guest
|
||||
/// or model input. These are identities only, not credentials or provider handles.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HostExecutionIdentity {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub principal_id: String,
|
||||
pub delegation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BindingPolicyReference {
|
||||
pub slot: String,
|
||||
pub binding_id: String,
|
||||
pub policy_digest: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginSurface {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// Only capabilities implemented by the initial Tool Host are representable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginGrant {
|
||||
Logging,
|
||||
Https { slot: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginSchemaIdentity {
|
||||
pub identity: String,
|
||||
pub version: String,
|
||||
pub digest: String,
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
/// The upstream config/schema authority must validate config values as non-secret
|
||||
/// and intersect grants/surfaces with Workspace, Profile and delegation bounds.
|
||||
/// Binding policies remain with their owner: only identities and digests travel.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginPinProjection {
|
||||
pub config: Value,
|
||||
pub surfaces: Vec<PluginSurface>,
|
||||
pub tools: Vec<String>,
|
||||
pub grants: Vec<PluginGrant>,
|
||||
pub bindings: Vec<BindingPolicyReference>,
|
||||
}
|
||||
|
||||
/// All fields are private: construct from admitted artifact metadata, not a
|
||||
/// hand-built wire snapshot. Deserialization alone does not establish admission.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerPluginPin {
|
||||
installation_id: String,
|
||||
expected_last_operation: String,
|
||||
package_id: String,
|
||||
package_version: String,
|
||||
package_digest: String,
|
||||
artifact_digest: String,
|
||||
artifact_size: u32,
|
||||
package_schema_version: u32,
|
||||
component_world: String,
|
||||
required_imports: Vec<String>,
|
||||
config_schema: PluginSchemaIdentity,
|
||||
config_digest: String,
|
||||
projection: PluginPinProjection,
|
||||
}
|
||||
|
||||
impl WorkerPluginPin {
|
||||
pub fn from_artifact(
|
||||
installation_id: String,
|
||||
expected_last_operation: String,
|
||||
artifact: &PluginArtifact,
|
||||
mut projection: PluginPinProjection,
|
||||
) -> Result<Self> {
|
||||
if artifact.revoked {
|
||||
return conflict();
|
||||
}
|
||||
if !valid_identity(&installation_id)
|
||||
|| !valid_identity(&expected_last_operation)
|
||||
|| !valid_digest(&artifact.package_digest)
|
||||
|| !valid_digest(&artifact.artifact_digest)
|
||||
|| artifact.artifact_size == 0
|
||||
|| artifact.schema_version != plugin::SUPPORTED_PLUGIN_API_VERSION
|
||||
|| !matches!(
|
||||
artifact.world.as_str(),
|
||||
plugin::PLUGIN_COMPONENT_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
||||
)
|
||||
|| !valid_digest(&artifact.config_schema.digest)
|
||||
|| !valid_identity(&artifact.package_id)
|
||||
|| !valid_identity(&artifact.version)
|
||||
|| !valid_identity(&artifact.config_schema.identity)
|
||||
|| !valid_identity(&artifact.config_schema.version)
|
||||
|| !valid_identity(&artifact.config_schema.namespace)
|
||||
|| !projection.config.is_object()
|
||||
{
|
||||
return invalid();
|
||||
}
|
||||
projection.surfaces.sort();
|
||||
projection.surfaces.dedup();
|
||||
projection.tools.sort();
|
||||
projection.tools.dedup();
|
||||
projection.grants.sort();
|
||||
projection.grants.dedup();
|
||||
projection.bindings.sort();
|
||||
projection.config = canonical_value(&projection.config);
|
||||
if (!projection.tools.is_empty() && !projection.surfaces.contains(&PluginSurface::Tool))
|
||||
|| projection.tools.iter().any(|name| {
|
||||
!valid_identity(name) || !artifact.tools.iter().any(|tool| tool.name == *name)
|
||||
})
|
||||
|| projection
|
||||
.bindings
|
||||
.windows(2)
|
||||
.any(|b| b[0].slot == b[1].slot)
|
||||
|| projection.bindings.iter().any(|binding| {
|
||||
!valid_identity(&binding.slot)
|
||||
|| !valid_identity(&binding.binding_id)
|
||||
|| !valid_digest(&binding.policy_digest)
|
||||
|| !artifact
|
||||
.connections
|
||||
.iter()
|
||||
.any(|slot| slot.name == binding.slot)
|
||||
})
|
||||
|| projection.grants.iter().any(|grant| match grant {
|
||||
PluginGrant::Logging => !artifact
|
||||
.requested_grants
|
||||
.contains(&PluginGrantMetadata::Logging),
|
||||
PluginGrant::Https { slot } => {
|
||||
!valid_identity(slot)
|
||||
|| !artifact
|
||||
.requested_grants
|
||||
.contains(&PluginGrantMetadata::Https { slot: slot.clone() })
|
||||
|| !projection
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.slot == *slot)
|
||||
}
|
||||
})
|
||||
|| projection.bindings.iter().any(|binding| {
|
||||
!projection.grants.contains(&PluginGrant::Https {
|
||||
slot: binding.slot.clone(),
|
||||
})
|
||||
})
|
||||
{
|
||||
return invalid();
|
||||
}
|
||||
let mut required_imports = artifact.required_imports.clone();
|
||||
required_imports.sort();
|
||||
required_imports.dedup();
|
||||
Ok(Self {
|
||||
installation_id,
|
||||
expected_last_operation,
|
||||
package_id: artifact.package_id.clone(),
|
||||
package_version: artifact.version.clone(),
|
||||
package_digest: artifact.package_digest.clone(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
artifact_size: artifact.artifact_size,
|
||||
package_schema_version: artifact.schema_version,
|
||||
component_world: artifact.world.clone(),
|
||||
required_imports,
|
||||
config_schema: PluginSchemaIdentity {
|
||||
identity: artifact.config_schema.identity.clone(),
|
||||
version: artifact.config_schema.version.clone(),
|
||||
digest: artifact.config_schema.digest.clone(),
|
||||
namespace: artifact.config_schema.namespace.clone(),
|
||||
},
|
||||
config_digest: digest_json(&projection.config)?,
|
||||
projection,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn installation_id(&self) -> &str {
|
||||
&self.installation_id
|
||||
}
|
||||
pub fn expected_last_operation(&self) -> &str {
|
||||
&self.expected_last_operation
|
||||
}
|
||||
pub fn package_id(&self) -> &str {
|
||||
&self.package_id
|
||||
}
|
||||
pub fn package_version(&self) -> &str {
|
||||
&self.package_version
|
||||
}
|
||||
pub fn package_schema_version(&self) -> u32 {
|
||||
self.package_schema_version
|
||||
}
|
||||
pub fn component_world(&self) -> &str {
|
||||
&self.component_world
|
||||
}
|
||||
pub fn required_imports(&self) -> &[String] {
|
||||
&self.required_imports
|
||||
}
|
||||
pub fn package_digest(&self) -> &str {
|
||||
&self.package_digest
|
||||
}
|
||||
pub fn artifact_digest(&self) -> &str {
|
||||
&self.artifact_digest
|
||||
}
|
||||
pub fn artifact_size(&self) -> u32 {
|
||||
self.artifact_size
|
||||
}
|
||||
pub fn config_schema(&self) -> &PluginSchemaIdentity {
|
||||
&self.config_schema
|
||||
}
|
||||
pub fn config_digest(&self) -> &str {
|
||||
&self.config_digest
|
||||
}
|
||||
pub fn projection(&self) -> &PluginPinProjection {
|
||||
&self.projection
|
||||
}
|
||||
|
||||
pub(super) fn matches_artifact(&self, artifact: &PluginArtifact) -> Result<bool> {
|
||||
Ok(Self::from_artifact(
|
||||
self.installation_id.clone(),
|
||||
self.expected_last_operation.clone(),
|
||||
artifact,
|
||||
self.projection.clone(),
|
||||
)? == *self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerPluginPlanInput {
|
||||
pub workspace_id: String,
|
||||
pub config: ConfigProvenance,
|
||||
pub profile: ProfileProvenance,
|
||||
pub execution: HostExecutionIdentity,
|
||||
pub plugins: Vec<WorkerPluginPin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerPluginExecutionPlan {
|
||||
format_version: u32,
|
||||
input: WorkerPluginPlanInput,
|
||||
}
|
||||
|
||||
impl WorkerPluginExecutionPlan {
|
||||
pub fn canonicalize(mut input: WorkerPluginPlanInput) -> Result<Self> {
|
||||
if !valid_identity(&input.workspace_id)
|
||||
|| !valid_digest(&input.config.content_digest)
|
||||
|| !valid_identity(&input.config.toolchain_fingerprint)
|
||||
|| !valid_identity(&input.profile.identity)
|
||||
|| !valid_digest(&input.profile.projection_digest)
|
||||
|| !valid_identity(&input.execution.runtime_id)
|
||||
|| !valid_identity(&input.execution.worker_id)
|
||||
|| !valid_identity(&input.execution.principal_id)
|
||||
|| input
|
||||
.execution
|
||||
.delegation_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !valid_identity(id))
|
||||
|| input.plugins.len() > 128
|
||||
{
|
||||
return invalid();
|
||||
}
|
||||
input
|
||||
.plugins
|
||||
.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
|
||||
// One selection per installation; two aliases of one package instead use
|
||||
// distinct installation identities, as required by the catalog contract.
|
||||
if input
|
||||
.plugins
|
||||
.windows(2)
|
||||
.any(|pins| pins[0].installation_id == pins[1].installation_id)
|
||||
{
|
||||
return invalid();
|
||||
}
|
||||
// Public Tool names use immutable installation identities, never aliases.
|
||||
let mut names = BTreeSet::new();
|
||||
for pin in &input.plugins {
|
||||
for tool in &pin.projection.tools {
|
||||
let name = format!("{}__{}", pin.installation_id, tool);
|
||||
if name.len() > 128 || !names.insert(name) {
|
||||
return invalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
format_version: 1,
|
||||
input,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
&self.input.workspace_id
|
||||
}
|
||||
pub fn config(&self) -> &ConfigProvenance {
|
||||
&self.input.config
|
||||
}
|
||||
pub fn profile(&self) -> &ProfileProvenance {
|
||||
&self.input.profile
|
||||
}
|
||||
pub fn execution(&self) -> &HostExecutionIdentity {
|
||||
&self.input.execution
|
||||
}
|
||||
pub fn plugins(&self) -> &[WorkerPluginPin] {
|
||||
&self.input.plugins
|
||||
}
|
||||
pub fn fingerprint(&self) -> Result<String> {
|
||||
digest_json(self)
|
||||
}
|
||||
pub fn canonical_json(&self) -> Result<String> {
|
||||
encode_canonical(self)
|
||||
}
|
||||
|
||||
pub(super) fn validate_canonical(&self) -> Result<()> {
|
||||
if self.format_version != 1 || Self::canonicalize(self.input.clone())? != *self {
|
||||
return invalid();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid<T>() -> Result<T> {
|
||||
Err(Error::PluginCatalog(PluginCatalogError::Invalid))
|
||||
}
|
||||
fn conflict<T>() -> Result<T> {
|
||||
Err(Error::PluginCatalog(PluginCatalogError::Conflict))
|
||||
}
|
||||
fn not_found<T>() -> Result<T> {
|
||||
Err(Error::PluginCatalog(PluginCatalogError::NotFound))
|
||||
}
|
||||
fn valid_identity(value: &str) -> bool {
|
||||
!value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
|
||||
}
|
||||
fn valid_digest(value: &str) -> bool {
|
||||
value.strip_prefix("sha256:").is_some_and(|hex| {
|
||||
hex.len() == 64
|
||||
&& hex
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
})
|
||||
}
|
||||
fn canonical_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
let mut keys: Vec<_> = fields.keys().collect();
|
||||
keys.sort();
|
||||
Value::Object(
|
||||
keys.into_iter()
|
||||
.map(|key| (key.clone(), canonical_value(&fields[key])))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
fn encode_canonical(value: &impl Serialize) -> Result<String> {
|
||||
let value = serde_json::to_value(value)
|
||||
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))?;
|
||||
serde_json::to_string(&canonical_value(&value))
|
||||
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))
|
||||
}
|
||||
fn digest_json(value: &impl Serialize) -> Result<String> {
|
||||
let bytes = encode_canonical(value)?;
|
||||
Ok(hash_bytes(bytes.as_bytes()))
|
||||
}
|
||||
fn hash_bytes(bytes: &[u8]) -> String {
|
||||
let hex: String = Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
format!("sha256:{hex}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Ordered production migration 106 -> 107. A marker failure rolls back new DDL
|
||||
//! without changing the already applied catalog migration or its saved rows.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_matches_fresh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("retained.db");
|
||||
tests::prepare_retained_schema(&path, 106);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
let before = workspace_schema_migration_history(&conn).unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER interrupt_plan_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=107 BEGIN SELECT RAISE(ABORT,'plan marker failure'); END;").unwrap();
|
||||
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap(), before);
|
||||
assert!(!table_exists(&conn, "plugin_execution_plans").unwrap());
|
||||
crate::plugin_catalog::verify(&conn).unwrap();
|
||||
conn.execute_batch("DROP TRIGGER interrupt_plan_migration")
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
let updated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
updated
|
||||
.with_conn(|conn| {
|
||||
let mut expected = before.clone();
|
||||
expected.push((
|
||||
107,
|
||||
"Immutable Worker Plugin plans and retained pins".into(),
|
||||
));
|
||||
expected.push((
|
||||
108,
|
||||
"Subject Ticket responsibility and current-body notification routing".into(),
|
||||
));
|
||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||
subject_assignment_migration::verify(conn)?;
|
||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))?,
|
||||
0
|
||||
);
|
||||
conn.execute_batch("DROP TRIGGER plugin_plan_snapshot_immutable")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(updated);
|
||||
assert!(
|
||||
matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin plan storage schema has drifted"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE plugin_execution_plans (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
plan_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL CHECK(length(actor) BETWEEN 1 AND 512),
|
||||
operation_key TEXT NOT NULL CHECK(length(operation_key) BETWEEN 1 AND 128),
|
||||
fingerprint TEXT NOT NULL CHECK(length(fingerprint)=71 AND substr(fingerprint,1,7)='sha256:' AND substr(fingerprint,8) NOT GLOB '*[^0-9a-f]*'),
|
||||
plan_json TEXT NOT NULL CHECK(json_valid(plan_json)),
|
||||
pin_state TEXT NOT NULL CHECK(pin_state IN ('pending','active','restorable','released')),
|
||||
PRIMARY KEY(workspace_id, plan_id),
|
||||
UNIQUE(workspace_id, actor, operation_key)
|
||||
);
|
||||
CREATE TRIGGER plugin_plan_snapshot_immutable
|
||||
BEFORE UPDATE OF workspace_id,plan_id,actor,operation_key,fingerprint,plan_json ON plugin_execution_plans
|
||||
BEGIN SELECT RAISE(ABORT,'Plugin execution plan is immutable'); END;
|
||||
CREATE TRIGGER plugin_plan_retention_terminal BEFORE UPDATE OF pin_state ON plugin_execution_plans
|
||||
WHEN NOT (
|
||||
OLD.pin_state=NEW.pin_state OR
|
||||
(OLD.pin_state='pending' AND NEW.pin_state IN ('active','released')) OR
|
||||
(OLD.pin_state='active' AND NEW.pin_state IN ('restorable','released')) OR
|
||||
(OLD.pin_state='restorable' AND NEW.pin_state IN ('active','released'))
|
||||
)
|
||||
BEGIN SELECT RAISE(ABORT,'invalid Plugin execution plan pin transition'); END;
|
||||
-- Preserve the immutable create replay after retention release. Workspace deletion
|
||||
-- may cascade; deleting an individual receipt must not make its key reusable.
|
||||
CREATE TRIGGER plugin_plan_replay_retained BEFORE DELETE ON plugin_execution_plans
|
||||
WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id)
|
||||
BEGIN SELECT RAISE(ABORT,'Plugin execution plan replay must be retained'); END;
|
||||
@@ -0,0 +1,331 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
plugin_catalog::{self, PluginReference, PluginReferenceKind},
|
||||
store::SqliteWorkspaceStore,
|
||||
};
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlanPinState {
|
||||
Pending,
|
||||
Active,
|
||||
Restorable,
|
||||
Released,
|
||||
}
|
||||
impl PlanPinState {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Active => "active",
|
||||
Self::Restorable => "restorable",
|
||||
Self::Released => "released",
|
||||
}
|
||||
}
|
||||
fn reference_kind(self) -> Option<PluginReferenceKind> {
|
||||
match self {
|
||||
Self::Pending => Some(PluginReferenceKind::Pending),
|
||||
Self::Active => Some(PluginReferenceKind::Active),
|
||||
Self::Restorable => Some(PluginReferenceKind::Restorable),
|
||||
Self::Released => None,
|
||||
}
|
||||
}
|
||||
fn can_transition(self, target: Self) -> bool {
|
||||
self == target
|
||||
|| matches!(
|
||||
(self, target),
|
||||
(Self::Pending, Self::Active | Self::Released)
|
||||
| (Self::Active, Self::Restorable | Self::Released)
|
||||
| (Self::Restorable, Self::Active | Self::Released)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable creation result. `initial_pin_state` describes creation, never the
|
||||
/// current retention state. Replay after release must not resurrect any pins.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginPlanCreateReceipt {
|
||||
pub plan_id: String,
|
||||
pub fingerprint: String,
|
||||
pub initial_pin_state: PlanPinState,
|
||||
pub plan: WorkerPluginExecutionPlan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StoredPluginPlan {
|
||||
pub plan_id: String,
|
||||
pub fingerprint: String,
|
||||
pub pin_state: PlanPinState,
|
||||
pub plan: WorkerPluginExecutionPlan,
|
||||
}
|
||||
|
||||
// A local savepoint keeps owner functions atomic even if a caller handles their
|
||||
// error without aborting its surrounding Worker-create transaction.
|
||||
fn atomic<T>(tx: &Transaction<'_>, work: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
tx.execute_batch("SAVEPOINT plugin_plan_owner")?;
|
||||
match work() {
|
||||
Ok(value) => {
|
||||
tx.execute_batch("RELEASE plugin_plan_owner")?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => {
|
||||
tx.execute_batch("ROLLBACK TO plugin_plan_owner; RELEASE plugin_plan_owner")?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller owns the authenticated create key and an immediate transaction in
|
||||
/// the *same database* as catalog and Worker creation. Include the returned plan
|
||||
/// fingerprint in the Worker launch/create fingerprint. New creation rechecks
|
||||
/// current config/Profile/binding provenance and admitted metadata in this same
|
||||
/// transaction. Owner-supplied narrowed snapshots cannot exceed that authority.
|
||||
///
|
||||
/// Replay precedes catalog admission: an immutable historical result is returned
|
||||
/// even after revocation, uninstall or release. It is not fresh execution authority.
|
||||
pub fn plugin_plan_create(
|
||||
tx: &Transaction<'_>,
|
||||
actor: &str,
|
||||
operation_key: &str,
|
||||
plan: &WorkerPluginExecutionPlan,
|
||||
) -> Result<PluginPlanCreateReceipt> {
|
||||
if !valid_identity(actor) || !valid_identity(operation_key) || operation_key.len() > 128 {
|
||||
return invalid();
|
||||
}
|
||||
plan.validate_canonical()?;
|
||||
let fingerprint = plan.fingerprint()?;
|
||||
atomic(tx, || {
|
||||
let saved = tx.query_row(
|
||||
"SELECT plan_id,fingerprint FROM plugin_execution_plans WHERE workspace_id=?1 AND actor=?2 AND operation_key=?3",
|
||||
params![plan.workspace_id(), actor, operation_key],
|
||||
|row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?)),
|
||||
).optional()?;
|
||||
if let Some((plan_id, saved_fingerprint)) = saved {
|
||||
if saved_fingerprint != fingerprint {
|
||||
return conflict();
|
||||
}
|
||||
return receipt(plugin_plan_load(tx, plan.workspace_id(), &plan_id)?);
|
||||
}
|
||||
for pin in plan.plugins() {
|
||||
plugin_catalog::plugin_artifact_admit(
|
||||
tx,
|
||||
plan.workspace_id(),
|
||||
&pin.installation_id,
|
||||
&pin.expected_last_operation,
|
||||
&pin.package_digest,
|
||||
&pin.artifact_digest,
|
||||
)?;
|
||||
}
|
||||
crate::plugin_settings::validate_plan_source(tx, plan)?;
|
||||
let plan_id = format!("pep-{}", uuid::Uuid::now_v7());
|
||||
tx.execute(
|
||||
"INSERT INTO plugin_execution_plans(workspace_id,plan_id,actor,operation_key,fingerprint,plan_json,pin_state) VALUES(?1,?2,?3,?4,?5,?6,'pending')",
|
||||
params![plan.workspace_id(), plan_id, actor, operation_key, fingerprint, plan.canonical_json()?],
|
||||
)?;
|
||||
for pin in plan.plugins() {
|
||||
let artifact = plugin_catalog::plugin_artifact_admit(
|
||||
tx,
|
||||
plan.workspace_id(),
|
||||
&pin.installation_id,
|
||||
&pin.expected_last_operation,
|
||||
&pin.package_digest,
|
||||
&pin.artifact_digest,
|
||||
)?;
|
||||
if !pin.matches_artifact(&artifact)? {
|
||||
return conflict();
|
||||
}
|
||||
plugin_catalog::plugin_reference_put(
|
||||
tx,
|
||||
plan.workspace_id(),
|
||||
&PluginReference {
|
||||
kind: PluginReferenceKind::Pending,
|
||||
owner: plan_id.clone(),
|
||||
installation_id: pin.installation_id.clone(),
|
||||
expected_last_operation: pin.expected_last_operation.clone(),
|
||||
package_digest: pin.package_digest.clone(),
|
||||
artifact_digest: pin.artifact_digest.clone(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
receipt(plugin_plan_load(tx, plan.workspace_id(), &plan_id)?)
|
||||
})
|
||||
}
|
||||
fn receipt(saved: StoredPluginPlan) -> Result<PluginPlanCreateReceipt> {
|
||||
Ok(PluginPlanCreateReceipt {
|
||||
plan_id: saved.plan_id,
|
||||
fingerprint: saved.fingerprint,
|
||||
initial_pin_state: PlanPinState::Pending,
|
||||
plan: saved.plan,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads only persisted immutable input, never current config or a latest artifact.
|
||||
/// `Released` snapshots remain readable for audit/replay but cannot be restored.
|
||||
pub fn plugin_plan_load(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
plan_id: &str,
|
||||
) -> Result<StoredPluginPlan> {
|
||||
let row = conn.query_row(
|
||||
"SELECT fingerprint,plan_json,pin_state FROM plugin_execution_plans WHERE workspace_id=?1 AND plan_id=?2",
|
||||
params![workspace, plan_id],
|
||||
|row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?, row.get::<_,String>(2)?)),
|
||||
).optional()?;
|
||||
let Some((fingerprint, json, state)) = row else {
|
||||
return not_found();
|
||||
};
|
||||
let plan: WorkerPluginExecutionPlan = serde_json::from_str(&json)
|
||||
.map_err(|_| Error::Store("invalid persisted Plugin execution plan".into()))?;
|
||||
plan.validate_canonical()?;
|
||||
if plan.workspace_id() != workspace || plan.fingerprint()? != fingerprint {
|
||||
return Err(Error::Store(
|
||||
"Plugin execution plan integrity mismatch".into(),
|
||||
));
|
||||
}
|
||||
let pin_state = match state.as_str() {
|
||||
"pending" => PlanPinState::Pending,
|
||||
"active" => PlanPinState::Active,
|
||||
"restorable" => PlanPinState::Restorable,
|
||||
"released" => PlanPinState::Released,
|
||||
_ => {
|
||||
return Err(Error::Store(
|
||||
"invalid Plugin execution plan pin state".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(StoredPluginPlan {
|
||||
plan_id: plan_id.into(),
|
||||
fingerprint,
|
||||
pin_state,
|
||||
plan,
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves pins atomically with the caller's Worker lifecycle update. State is CAS
|
||||
/// compared; a stale transition is not silently replayed. Releasing is always
|
||||
/// possible after revoke and never re-admits an artifact. Releasing twice with
|
||||
/// expected=Released is a harmless no-op. No transition out of Released is allowed.
|
||||
///
|
||||
/// Acquiring a different retained kind rechecks live catalog admission. It uses
|
||||
/// the installation's current operation (read inside the write transaction), not
|
||||
/// the creation-time observation: unrelated artifact additions do not invalidate
|
||||
/// immutable snapshots. Revoked targets cannot acquire new pins; an unsuccessful
|
||||
/// transition retains all old refs. A revoked Worker may release its pins, but
|
||||
/// cannot become restorable/active. T-793 must still enforce live operation checks.
|
||||
pub fn plugin_plan_transition(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
plan_id: &str,
|
||||
expected: PlanPinState,
|
||||
target: PlanPinState,
|
||||
) -> Result<StoredPluginPlan> {
|
||||
atomic(tx, || {
|
||||
let mut saved = plugin_plan_load(tx, workspace, plan_id)?;
|
||||
if saved.pin_state != expected || !expected.can_transition(target) {
|
||||
return conflict();
|
||||
}
|
||||
if expected == target {
|
||||
return Ok(saved);
|
||||
}
|
||||
if let Some(kind) = target.reference_kind() {
|
||||
for pin in saved.plan.plugins() {
|
||||
let operation: String = tx.query_row(
|
||||
"SELECT last_operation FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2",
|
||||
params![workspace, pin.installation_id], |row| row.get(0),
|
||||
)?;
|
||||
plugin_catalog::plugin_reference_put(
|
||||
tx,
|
||||
workspace,
|
||||
&PluginReference {
|
||||
kind,
|
||||
owner: plan_id.into(),
|
||||
installation_id: pin.installation_id.clone(),
|
||||
expected_last_operation: operation,
|
||||
package_digest: pin.package_digest.clone(),
|
||||
artifact_digest: pin.artifact_digest.clone(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if let Some(kind) = expected.reference_kind() {
|
||||
for pin in saved.plan.plugins() {
|
||||
plugin_catalog::plugin_reference_remove(
|
||||
tx,
|
||||
workspace,
|
||||
kind,
|
||||
plan_id,
|
||||
&pin.installation_id,
|
||||
&pin.artifact_digest,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let changed = tx.execute(
|
||||
"UPDATE plugin_execution_plans SET pin_state=?1 WHERE workspace_id=?2 AND plan_id=?3 AND pin_state=?4",
|
||||
params![target.name(),workspace,plan_id,expected.name()],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return conflict();
|
||||
}
|
||||
saved.pin_state = target;
|
||||
Ok(saved)
|
||||
})
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn plugin_plan_create(
|
||||
&self,
|
||||
actor: &str,
|
||||
operation_key: &str,
|
||||
plan: &WorkerPluginExecutionPlan,
|
||||
) -> Result<PluginPlanCreateReceipt> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let receipt = plugin_plan_create(&tx, actor, operation_key, plan)?;
|
||||
tx.commit()?;
|
||||
Ok(receipt)
|
||||
})
|
||||
}
|
||||
pub fn plugin_plan_load(&self, workspace: &str, plan_id: &str) -> Result<StoredPluginPlan> {
|
||||
self.with_conn(|conn| plugin_plan_load(conn, workspace, plan_id))
|
||||
}
|
||||
pub fn plugin_plan_transition(
|
||||
&self,
|
||||
workspace: &str,
|
||||
plan_id: &str,
|
||||
expected: PlanPinState,
|
||||
target: PlanPinState,
|
||||
) -> Result<StoredPluginPlan> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let saved = plugin_plan_transition(&tx, workspace, plan_id, expected, target)?;
|
||||
tx.commit()?;
|
||||
Ok(saved)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn verify_plan_schema(conn: &Connection) -> Result<()> {
|
||||
let expected = Connection::open_in_memory()?;
|
||||
expected.execute_batch(SCHEMA_SQL)?;
|
||||
let mut statement = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name='plugin_execution_plans' ORDER BY name")?;
|
||||
for item in statement.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})? {
|
||||
let (name, sql) = item?;
|
||||
let actual = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[&name],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
if actual.as_deref() != Some(&sql) {
|
||||
return Err(Error::Store(
|
||||
"Plugin plan storage schema has drifted".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
//! Real file-backed SQLite, production catalog operations and a checked-in PDK
|
||||
//! Component. Execution/Host side effects are intentionally outside this boundary.
|
||||
use super::*;
|
||||
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||
use rusqlite::{Connection, TransactionBehavior, params};
|
||||
use server_api::*;
|
||||
use std::fs;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
struct Fixture {
|
||||
dir: TempDir,
|
||||
store: SqliteWorkspaceStore,
|
||||
}
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap();
|
||||
for workspace in ["a", "b"] {
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: workspace.into(),
|
||||
owner_account_id: "owner-account".into(),
|
||||
display_name: workspace.into(),
|
||||
state: "active".into(),
|
||||
created_at: "2026-10-28T00:00:00Z".into(),
|
||||
updated_at: "2026-10-28T00:00:00Z".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Self { dir, store }
|
||||
}
|
||||
fn install(&self, alias: &str) -> PluginOperationResponse {
|
||||
let upload = self
|
||||
.store
|
||||
.plugin_upload(
|
||||
&self.dir.path().join("blobs"),
|
||||
"a",
|
||||
"actor",
|
||||
&package("0.1.0"),
|
||||
)
|
||||
.unwrap();
|
||||
self.store
|
||||
.plugin_install(
|
||||
"a",
|
||||
"actor",
|
||||
&PluginInstallRequest {
|
||||
upload_id: upload.upload_id,
|
||||
alias: alias.into(),
|
||||
operation_key: alias.into(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn count(&self, table: &str) -> i64 {
|
||||
self.store
|
||||
.with_conn(|conn| {
|
||||
Ok(conn.query_row(
|
||||
&format!(
|
||||
"SELECT count(*) FROM {table}{}",
|
||||
if table == "plugin_references" {
|
||||
" WHERE kind != 'config'"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn kind(&self) -> String {
|
||||
self.store
|
||||
.with_conn(|conn| {
|
||||
Ok(conn.query_row(
|
||||
"SELECT kind FROM plugin_references WHERE kind != 'config' LIMIT 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn transition(&self, id: &str, from: PlanPinState, to: PlanPinState) -> StoredPluginPlan {
|
||||
self.store
|
||||
.plugin_plan_transition("a", id, from, to)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
fn package(version: &str) -> Vec<u8> {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join("plugin.toml"),
|
||||
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml")
|
||||
.replace("version = \"0.1.0\"", &format!("version = \"{version}\"")),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("plugin.component.wasm"),
|
||||
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
let package = plugin::read_plugin_directory(
|
||||
dir.path(),
|
||||
plugin::PluginSourceKind::Explicit,
|
||||
&plugin::PluginPackageLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
plugin::pack_plugin_package(&package, &plugin::PluginPackageLimits::default())
|
||||
.unwrap()
|
||||
.bytes
|
||||
}
|
||||
fn digest(seed: &str) -> String {
|
||||
hash_bytes(seed.as_bytes())
|
||||
}
|
||||
fn projection() -> PluginPinProjection {
|
||||
PluginPinProjection {
|
||||
config: serde_json::json!({}),
|
||||
surfaces: vec![PluginSurface::Tool],
|
||||
tools: vec!["example_echo".into()],
|
||||
grants: vec![],
|
||||
bindings: vec![],
|
||||
}
|
||||
}
|
||||
fn pin(installed: &PluginOperationResponse) -> WorkerPluginPin {
|
||||
WorkerPluginPin::from_artifact(
|
||||
installed.installation.installation_id.clone(),
|
||||
installed.operation_id.clone(),
|
||||
&installed.installation.artifacts[0],
|
||||
projection(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn input(plugins: Vec<WorkerPluginPin>) -> WorkerPluginPlanInput {
|
||||
WorkerPluginPlanInput {
|
||||
workspace_id: "a".into(),
|
||||
config: ConfigProvenance {
|
||||
content_digest: digest("config"),
|
||||
toolchain_fingerprint: digest("toolchain"),
|
||||
},
|
||||
profile: ProfileProvenance {
|
||||
identity: "profile:coder".into(),
|
||||
projection_digest: digest("profile"),
|
||||
},
|
||||
execution: HostExecutionIdentity {
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "worker".into(),
|
||||
principal_id: "worker-principal".into(),
|
||||
delegation_id: Some("delegation".into()),
|
||||
},
|
||||
plugins,
|
||||
}
|
||||
}
|
||||
fn plan(plugins: Vec<WorkerPluginPin>) -> WorkerPluginExecutionPlan {
|
||||
WorkerPluginExecutionPlan::canonicalize(input(plugins)).unwrap()
|
||||
}
|
||||
fn error<T: std::fmt::Debug>(result: Result<T>, expected: PluginCatalogError) {
|
||||
assert!(
|
||||
matches!(result,Err(Error::PluginCatalog(actual)) if actual == expected),
|
||||
"expected {expected:?}, got {result:?}"
|
||||
);
|
||||
}
|
||||
fn uninstall(
|
||||
f: &Fixture,
|
||||
installation: &PluginOperationResponse,
|
||||
key: &str,
|
||||
) -> Result<PluginOperationResponse> {
|
||||
f.store.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
&installation.installation.installation_id,
|
||||
&PluginUninstallRequest {
|
||||
expected_last_operation: installation.operation_id.clone(),
|
||||
operation_key: key.into(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_order_and_fingerprint_bind_all_provenance_and_both_digests() {
|
||||
let f = Fixture::new().await;
|
||||
let a = f.install("one");
|
||||
let b = f.install("two");
|
||||
let p = plan(vec![pin(&a), pin(&b)]);
|
||||
assert_eq!(p, plan(vec![pin(&b), pin(&a)]));
|
||||
assert_eq!(
|
||||
p.fingerprint().unwrap(),
|
||||
plan(vec![pin(&b), pin(&a)]).fingerprint().unwrap()
|
||||
);
|
||||
let mut changes = Vec::new();
|
||||
let base = input(vec![pin(&a), pin(&b)]);
|
||||
let mut changed = base.clone();
|
||||
changed.config.content_digest = digest("new config");
|
||||
changes.push(changed);
|
||||
let mut changed = base.clone();
|
||||
changed.config.toolchain_fingerprint = digest("new toolchain");
|
||||
changes.push(changed);
|
||||
let mut changed = base.clone();
|
||||
changed.profile.projection_digest = digest("new profile");
|
||||
changes.push(changed);
|
||||
let mut changed = base.clone();
|
||||
changed.execution.delegation_id = Some("different".into());
|
||||
changes.push(changed);
|
||||
let mut changed = base.clone();
|
||||
changed.plugins[0].package_digest = digest("new package");
|
||||
changes.push(changed);
|
||||
let mut changed = base.clone();
|
||||
changed.plugins[0].artifact_digest = digest("new artifact");
|
||||
changes.push(changed);
|
||||
let mut changed = base;
|
||||
changed.plugins[0].expected_last_operation = "new observation".into();
|
||||
changes.push(changed);
|
||||
for changed in changes {
|
||||
assert_ne!(
|
||||
p.fingerprint().unwrap(),
|
||||
WorkerPluginExecutionPlan::canonicalize(changed)
|
||||
.unwrap()
|
||||
.fingerprint()
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
error(
|
||||
WorkerPluginExecutionPlan::canonicalize(input(vec![pin(&a), pin(&a)])),
|
||||
PluginCatalogError::Invalid,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_json_sorts_nested_config_and_selected_sets_without_copying_catalog_payloads() {
|
||||
let f = Fixture::new().await;
|
||||
let installed = f.install("one");
|
||||
let mut artifact = installed.installation.artifacts[0].clone();
|
||||
artifact.tools.push(PluginToolMetadata {
|
||||
name: "other".into(),
|
||||
..artifact.tools[0].clone()
|
||||
});
|
||||
artifact.requested_grants = vec![
|
||||
PluginGrantMetadata::Logging,
|
||||
PluginGrantMetadata::Https { slot: "api".into() },
|
||||
];
|
||||
artifact.connections = vec![PluginConnectionMetadata {
|
||||
name: "api".into(),
|
||||
methods: vec![PluginHttpMethod::Get],
|
||||
path_prefixes: vec!["/".into()],
|
||||
}];
|
||||
let mut first = projection();
|
||||
first.config = serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":[{"z":0,"a":false}]}"#).unwrap();
|
||||
first.tools = vec!["other".into(), "example_echo".into()];
|
||||
first.grants = vec![
|
||||
PluginGrant::Https { slot: "api".into() },
|
||||
PluginGrant::Logging,
|
||||
];
|
||||
first.bindings = vec![BindingPolicyReference {
|
||||
slot: "api".into(),
|
||||
binding_id: "binding".into(),
|
||||
policy_digest: digest("policy"),
|
||||
}];
|
||||
let mut second = first.clone();
|
||||
second.config = serde_json::from_str(r#"{"a":[{"a":false,"z":0}],"z":{"a":1,"b":2}}"#).unwrap();
|
||||
second.tools.reverse();
|
||||
second.grants.reverse();
|
||||
let construct = |projection| {
|
||||
plan(vec![
|
||||
WorkerPluginPin::from_artifact(
|
||||
installed.installation.installation_id.clone(),
|
||||
installed.operation_id.clone(),
|
||||
&artifact,
|
||||
projection,
|
||||
)
|
||||
.unwrap(),
|
||||
])
|
||||
};
|
||||
let canonical = construct(first);
|
||||
assert_eq!(canonical, construct(second));
|
||||
let json = canonical.canonical_json().unwrap();
|
||||
for excluded in [
|
||||
"revocation_reason",
|
||||
"description",
|
||||
"input_schema",
|
||||
"output_schema",
|
||||
"dcdl",
|
||||
"artifact_url",
|
||||
"host_path",
|
||||
"secret_value",
|
||||
"provider_handle",
|
||||
"token",
|
||||
"package_bytes",
|
||||
] {
|
||||
assert!(
|
||||
!json.contains(&format!("\"{excluded}\"")),
|
||||
"unexpected {excluded}"
|
||||
);
|
||||
}
|
||||
let mut unknown = serde_json::to_value(&canonical).unwrap();
|
||||
unknown["input"]["execution"]["token"] = Value::String("credential".into());
|
||||
assert!(serde_json::from_value::<WorkerPluginExecutionPlan>(unknown).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_replay_is_immutable_across_restart_and_rejects_changed_input() {
|
||||
let f = Fixture::new().await;
|
||||
let installed = f.install("one");
|
||||
let plan = f.configure(&[&installed]);
|
||||
let receipt = f
|
||||
.store
|
||||
.plugin_plan_create("actor", "launch", &plan)
|
||||
.unwrap();
|
||||
assert_eq!(receipt.initial_pin_state, PlanPinState::Pending);
|
||||
assert_eq!(f.kind(), "pending");
|
||||
f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Pending,
|
||||
PlanPinState::Active,
|
||||
);
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.plugin_plan_create("actor", "launch", &plan)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.plugin_plan_load("a", &receipt.plan_id)
|
||||
.unwrap()
|
||||
.pin_state,
|
||||
PlanPinState::Active
|
||||
);
|
||||
let mut changed = plan.input.clone();
|
||||
changed.profile.projection_digest = digest("new profile");
|
||||
error(
|
||||
reopened.plugin_plan_create(
|
||||
"actor",
|
||||
"launch",
|
||||
&WorkerPluginExecutionPlan::canonicalize(changed).unwrap(),
|
||||
),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
assert_eq!(f.count("plugin_execution_plans"), 1);
|
||||
assert_eq!(f.count("plugin_references"), 1);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.plugin_plan_load("a", &receipt.plan_id)
|
||||
.unwrap()
|
||||
.plan,
|
||||
plan
|
||||
);
|
||||
error(
|
||||
reopened.plugin_plan_load("b", &receipt.plan_id),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_pin_lifecycle_blocks_uninstall_and_released_replay_never_reacquires() {
|
||||
let f = Fixture::new().await;
|
||||
let installed = f.install("one");
|
||||
let plan = f.configure(&[&installed]);
|
||||
let receipt = f
|
||||
.store
|
||||
.plugin_plan_create("actor", "launch", &plan)
|
||||
.unwrap();
|
||||
error(
|
||||
uninstall(&f, &installed, "uninstall-pending"),
|
||||
PluginCatalogError::Referenced,
|
||||
);
|
||||
f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Pending,
|
||||
PlanPinState::Active,
|
||||
);
|
||||
assert_eq!(f.kind(), "active");
|
||||
error(
|
||||
uninstall(&f, &installed, "uninstall-active"),
|
||||
PluginCatalogError::Referenced,
|
||||
);
|
||||
f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Active,
|
||||
PlanPinState::Restorable,
|
||||
);
|
||||
assert_eq!(f.kind(), "restorable");
|
||||
error(
|
||||
uninstall(&f, &installed, "uninstall-restorable"),
|
||||
PluginCatalogError::Referenced,
|
||||
);
|
||||
f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Restorable,
|
||||
PlanPinState::Active,
|
||||
);
|
||||
f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Active,
|
||||
PlanPinState::Released,
|
||||
);
|
||||
f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Released,
|
||||
PlanPinState::Released,
|
||||
);
|
||||
assert_eq!(f.count("plugin_references"), 0);
|
||||
f.disable();
|
||||
uninstall(&f, &installed, "uninstall-released").unwrap();
|
||||
assert_eq!(
|
||||
f.store
|
||||
.plugin_plan_create("actor", "launch", &plan)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert_eq!(f.count("plugin_references"), 0);
|
||||
error(
|
||||
f.store.plugin_plan_transition(
|
||||
"a",
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Released,
|
||||
PlanPinState::Active,
|
||||
),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
error(
|
||||
f.store
|
||||
.plugin_plan_create("actor", "different-launch", &plan),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
assert_eq!(f.count("plugin_execution_plans"), 1);
|
||||
f.store
|
||||
.with_conn(|conn| {
|
||||
assert!(
|
||||
conn.execute("UPDATE plugin_execution_plans SET plan_json=plan_json", [])
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
conn.execute("UPDATE plugin_execution_plans SET pin_state='pending'", [])
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
conn.execute("DELETE FROM plugin_execution_plans", [])
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_blocks_create_and_new_pin_kinds_but_not_release_or_historical_replay() {
|
||||
let f = Fixture::new().await;
|
||||
let installed = f.install("one");
|
||||
let plan = f.configure(&[&installed]);
|
||||
let receipt = f
|
||||
.store
|
||||
.plugin_plan_create("actor", "launch", &plan)
|
||||
.unwrap();
|
||||
f.store
|
||||
.plugin_revoke(
|
||||
"a",
|
||||
"actor",
|
||||
&installed.installation.installation_id,
|
||||
&PluginRevokeRequest {
|
||||
artifact_digest: installed.installation.artifacts[0].artifact_digest.clone(),
|
||||
expected_last_operation: installed.operation_id.clone(),
|
||||
reason: "revoked".into(),
|
||||
operation_key: "revoke".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
f.store
|
||||
.plugin_plan_create("actor", "launch", &plan)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
error(
|
||||
f.store.plugin_plan_transition(
|
||||
"a",
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Pending,
|
||||
PlanPinState::Active,
|
||||
),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
assert_eq!(f.kind(), "pending");
|
||||
error(
|
||||
f.store.plugin_plan_create("actor", "new-launch", &plan),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
assert_eq!(f.count("plugin_execution_plans"), 1);
|
||||
f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Pending,
|
||||
PlanPinState::Released,
|
||||
);
|
||||
assert_eq!(f.count("plugin_references"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn artifact_addition_does_not_reselect_or_invalidate_existing_plan_lifecycle() {
|
||||
let f = Fixture::new().await;
|
||||
let installed = f.install("one");
|
||||
let plan = f.configure(&[&installed]);
|
||||
let receipt = f
|
||||
.store
|
||||
.plugin_plan_create("actor", "launch", &plan)
|
||||
.unwrap();
|
||||
let upload = f
|
||||
.store
|
||||
.plugin_upload(&f.dir.path().join("blobs"), "a", "actor", &package("0.2.0"))
|
||||
.unwrap();
|
||||
f.store
|
||||
.plugin_add_artifact(
|
||||
"a",
|
||||
"actor",
|
||||
&installed.installation.installation_id,
|
||||
&PluginAddArtifactRequest {
|
||||
upload_id: upload.upload_id,
|
||||
expected_last_operation: installed.operation_id.clone(),
|
||||
operation_key: "update".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let active = f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Pending,
|
||||
PlanPinState::Active,
|
||||
);
|
||||
assert_eq!(active.plan, plan);
|
||||
assert_eq!(
|
||||
active.plan.plugins()[0].artifact_digest(),
|
||||
installed.installation.artifacts[0].artifact_digest
|
||||
);
|
||||
error(
|
||||
f.store
|
||||
.plugin_plan_create("actor", "new-with-stale-observation", &plan),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_savepoint_and_outer_transaction_rollback_leave_no_partial_plan_or_pins() {
|
||||
let f = Fixture::new().await;
|
||||
let first = f.install("one");
|
||||
let second = f.install("two");
|
||||
let mut bad_input = f.configure(&[&first, &second]).input;
|
||||
let mut pins = bad_input.plugins.clone();
|
||||
pins.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
|
||||
pins[1].artifact_size += 1;
|
||||
bad_input.plugins = pins;
|
||||
let bad_plan = WorkerPluginExecutionPlan::canonicalize(bad_input).unwrap();
|
||||
f.store
|
||||
.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
error(
|
||||
plugin_plan_create(&tx, "actor", "bad", &bad_plan),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
// Even if the caller commits after a handled error, first pin is gone.
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(f.count("plugin_execution_plans"), 0);
|
||||
assert_eq!(f.count("plugin_references"), 0);
|
||||
let valid = f.configure(&[&first]);
|
||||
f.store
|
||||
.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
plugin_plan_create(&tx, "actor", "outer-rollback", &valid)?;
|
||||
tx.rollback()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(f.count("plugin_execution_plans"), 0);
|
||||
assert_eq!(f.count("plugin_references"), 0);
|
||||
// The same key is usable after rollback because neither domain fact committed.
|
||||
f.store
|
||||
.plugin_plan_create("actor", "outer-rollback", &valid)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_workspace_and_exact_digest_mismatches_fail_without_retaining_references() {
|
||||
let f = Fixture::new().await;
|
||||
let installed = f.install("one");
|
||||
let base = f.configure(&[&installed]);
|
||||
let mut other_workspace = base.input.clone();
|
||||
other_workspace.workspace_id = "b".into();
|
||||
error(
|
||||
f.store.plugin_plan_create(
|
||||
"actor",
|
||||
"cross",
|
||||
&WorkerPluginExecutionPlan::canonicalize(other_workspace).unwrap(),
|
||||
),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
for change_package in [true, false] {
|
||||
let mut changed = base.input.clone();
|
||||
if change_package {
|
||||
changed.plugins[0].package_digest = digest("wrong-package");
|
||||
} else {
|
||||
changed.plugins[0].artifact_digest = digest("wrong-artifact");
|
||||
}
|
||||
let result = f.store.plugin_plan_create(
|
||||
"actor",
|
||||
"digests",
|
||||
&WorkerPluginExecutionPlan::canonicalize(changed).unwrap(),
|
||||
);
|
||||
error(
|
||||
result,
|
||||
if change_package {
|
||||
PluginCatalogError::Conflict
|
||||
} else {
|
||||
PluginCatalogError::NotFound
|
||||
},
|
||||
);
|
||||
}
|
||||
assert_eq!(f.count("plugin_execution_plans"), 0);
|
||||
assert_eq!(f.count("plugin_references"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_write_fence_serializes_uninstall_and_pin_creation_across_connections() {
|
||||
let f = Fixture::new().await;
|
||||
let installed = f.install("one");
|
||||
let plan = f.configure(&[&installed]);
|
||||
let mut other = Connection::open(f.dir.path().join("server.db")).unwrap();
|
||||
other.busy_timeout(std::time::Duration::ZERO).unwrap();
|
||||
f.store
|
||||
.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
plugin_plan_create(&tx, "actor", "launch", &plan)?;
|
||||
// A second writer cannot pass the catalog deletion fence before commit.
|
||||
assert!(
|
||||
other
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.is_err()
|
||||
);
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let tx = other
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.unwrap();
|
||||
assert!(tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='a' AND installation_id=?1", params![installed.installation.installation_id]).is_err());
|
||||
tx.rollback().unwrap();
|
||||
error(
|
||||
uninstall(&f, &installed, "after-committed-pin"),
|
||||
PluginCatalogError::Referenced,
|
||||
);
|
||||
// Opposite ordering: uninstall wins before a new plan's owner acquires pins.
|
||||
let unpinned = f.install("unpinned");
|
||||
let unpinned_plan = f.configure(&[&unpinned]);
|
||||
f.disable();
|
||||
uninstall(&f, &unpinned, "uninstall-first").unwrap();
|
||||
error(
|
||||
f.store
|
||||
.plugin_plan_create("actor", "after-uninstall", &unpinned_plan),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn configure(&self, installed: &[&PluginOperationResponse]) -> WorkerPluginExecutionPlan {
|
||||
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigSchemaProvider};
|
||||
use config_source::{ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle};
|
||||
let bundle = WorkspaceConfigSchemaBundle::compose([
|
||||
crate::plugin_settings::PluginConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
crate::profile_settings::ProfileConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
let state = self
|
||||
.store
|
||||
.ensure_workspace_config_materialized_with_schema("a", "1", bundle.clone())
|
||||
.unwrap();
|
||||
let mut enabled = serde_json::Map::new();
|
||||
let mut selections = Vec::new();
|
||||
for (index, item) in installed.iter().enumerate() {
|
||||
let a = &item.installation.artifacts[0];
|
||||
enabled.insert(
|
||||
format!("plugin_{index}"),
|
||||
serde_json::json!({
|
||||
"installation_id": item.installation.installation_id,
|
||||
"expected_last_operation": item.operation_id,
|
||||
"package_digest": a.package_digest, "artifact_digest": a.artifact_digest,
|
||||
"tools": ["example_echo"], "config": {},
|
||||
}),
|
||||
);
|
||||
selections.push(serde_json::json!({"installation_id":item.installation.installation_id,"tools":["example_echo"]}));
|
||||
}
|
||||
let value = serde_json::json!({"plugins":{"enabled": enabled}, "profile":{"entries":[{"selector":"project:tool","profile":{"plugins":{"selections":selections}}}]}});
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let candidate = self
|
||||
.store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
"a",
|
||||
&ConfigCommitRequest {
|
||||
base_digest: state.snapshot.digest.clone(),
|
||||
entrypoints: vec![path.clone()],
|
||||
changes: vec![ConfigTreeChange::Update {
|
||||
path: path.clone(),
|
||||
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
|
||||
content: format!(
|
||||
"{} as WorkspaceConfigSchema",
|
||||
crate::plugin_settings::render_value(&value).unwrap()
|
||||
),
|
||||
}],
|
||||
},
|
||||
bundle,
|
||||
)
|
||||
.unwrap();
|
||||
let state = self
|
||||
.store
|
||||
.commit_evaluated_workspace_config("a", &candidate)
|
||||
.unwrap();
|
||||
self.store
|
||||
.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
crate::plugin_settings::resolve_worker_plugin_plan(
|
||||
&tx,
|
||||
"a",
|
||||
&state.snapshot.digest,
|
||||
"project:tool",
|
||||
input(vec![]).execution,
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn disable(&self) {
|
||||
self.configure(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
// Data-only donor for cross-owner migration preservation tests. The caller
|
||||
// imports explicit rows into real retained DDL, never this donor's schema or
|
||||
// migration ledgers. Production catalog/config/plan APIs generate the bytes.
|
||||
pub(crate) async fn plugin_plan_migration_donor() -> (
|
||||
TempDir,
|
||||
Self,
|
||||
Vec<(String, PluginPlanCreateReceipt, PlanPinState)>,
|
||||
) {
|
||||
let f = Fixture::new().await;
|
||||
let one = f.install("migration-one");
|
||||
let two = f.install("migration-two");
|
||||
let plan = f.configure(&[&one, &two]);
|
||||
assert_ne!(
|
||||
plan.plugins()[0].package_digest,
|
||||
plan.plugins()[0].artifact_digest
|
||||
);
|
||||
let mut receipts = Vec::new();
|
||||
for (key, state) in [
|
||||
("pending", PlanPinState::Pending),
|
||||
("active", PlanPinState::Active),
|
||||
("restorable", PlanPinState::Restorable),
|
||||
("released", PlanPinState::Released),
|
||||
] {
|
||||
let receipt = f.store.plugin_plan_create("actor", key, &plan).unwrap();
|
||||
if matches!(state, PlanPinState::Active | PlanPinState::Restorable) {
|
||||
f.transition(
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Pending,
|
||||
PlanPinState::Active,
|
||||
);
|
||||
}
|
||||
if state == PlanPinState::Restorable {
|
||||
f.transition(&receipt.plan_id, PlanPinState::Active, state);
|
||||
} else if state == PlanPinState::Released {
|
||||
f.transition(&receipt.plan_id, PlanPinState::Pending, state);
|
||||
}
|
||||
receipts.push((key.into(), receipt, state));
|
||||
}
|
||||
(f.dir, f.store, receipts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
//! Catalog-backed Plugin configuration. The virtual config tree remains the only
|
||||
//! configuration authority; static metadata resolution never instantiates a guest.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use config_source::{
|
||||
ConfigSchemaContribution, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment,
|
||||
ToolchainContract, WorkspaceConfigSchemaBundle,
|
||||
};
|
||||
use rusqlite::Transaction;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use server_api::{PluginArtifact, PluginGrantMetadata};
|
||||
|
||||
use crate::config_source::WorkspaceConfigSchemaProvider;
|
||||
use crate::plugin_binding::PluginConnectionBinding;
|
||||
use crate::plugin_catalog::{
|
||||
PluginReference, PluginReferenceKind, plugin_artifact_admit, plugin_reference_put,
|
||||
plugin_reference_remove,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const PROVIDER: &str = "builtin:plugins";
|
||||
const BOOTSTRAP: &str = include_str!("../../../resources/config-schema/plugins.dcdl");
|
||||
const CONFIG_OWNER: &str = "workspace-config";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PluginConfigSchemaProvider;
|
||||
impl WorkspaceConfigSchemaProvider for PluginConfigSchemaProvider {
|
||||
fn contribution(&self) -> Result<ConfigSchemaContribution> {
|
||||
ConfigSchemaContribution::new(
|
||||
PROVIDER,
|
||||
"plugins",
|
||||
config_source::digest_bytes(BOOTSTRAP.as_bytes()),
|
||||
BOOTSTRAP,
|
||||
)
|
||||
.map_err(config_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginEnablement {
|
||||
pub installation_id: String,
|
||||
pub expected_last_operation: String,
|
||||
pub package_digest: String,
|
||||
pub artifact_digest: String,
|
||||
pub tools: Vec<String>,
|
||||
pub logging: bool,
|
||||
pub connections: BTreeMap<String, String>,
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginSettings {
|
||||
pub enabled: BTreeMap<String, PluginEnablement>,
|
||||
pub bindings: BTreeMap<String, PluginConnectionBinding>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedPluginSettings {
|
||||
pub settings: PluginSettings,
|
||||
pub artifacts: BTreeMap<String, PluginArtifact>,
|
||||
}
|
||||
|
||||
fn config_error(error: impl std::fmt::Display) -> Error {
|
||||
Error::InvalidInput(format!("Plugin config: {error}"))
|
||||
}
|
||||
|
||||
fn contract(bundle: WorkspaceConfigSchemaBundle) -> ToolchainContract {
|
||||
ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![
|
||||
config_source::VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT)
|
||||
.expect("main path"),
|
||||
],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
bundle,
|
||||
)
|
||||
}
|
||||
|
||||
fn evaluate(
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
contract: &ToolchainContract,
|
||||
) -> Result<EvaluationResult> {
|
||||
SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(contract)
|
||||
.map_err(|diagnostics| {
|
||||
config_error(
|
||||
serde_json::to_string(&diagnostics)
|
||||
.unwrap_or_else(|_| "invalid static config".into()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn settings_from_evaluation(evaluation: &EvaluationResult) -> Result<PluginSettings> {
|
||||
let value = evaluation
|
||||
.projections
|
||||
.first()
|
||||
.ok_or_else(|| config_error("missing projection"))?;
|
||||
let root = &value.data_json;
|
||||
match root.get("plugins") {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(config_error),
|
||||
None => Ok(PluginSettings::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve core references before composing the closed artifact-specific schema.
|
||||
/// Both reads use the owner's transaction; the resulting bundle fixes the exact
|
||||
/// catalog operation as well as both byte identities, not a generic counter.
|
||||
pub(crate) fn evaluate_catalog_config(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<(ToolchainContract, EvaluationResult, ResolvedPluginSettings)> {
|
||||
let mut providers = bundle
|
||||
.contributions
|
||||
.into_iter()
|
||||
.filter(|p| p.provider_id != PROVIDER)
|
||||
.collect::<Vec<_>>();
|
||||
providers.push(PluginConfigSchemaProvider.contribution()?);
|
||||
let bootstrap =
|
||||
contract(WorkspaceConfigSchemaBundle::compose(providers.clone()).map_err(config_error)?);
|
||||
let core = evaluate(snapshot, &bootstrap)?;
|
||||
let settings = settings_from_evaluation(&core)?;
|
||||
crate::plugin_binding::validate_bindings(
|
||||
&settings.bindings,
|
||||
&crate::plugin_binding::PluginConnectionPolicyCeiling::default(),
|
||||
)
|
||||
.map_err(config_error)?;
|
||||
if settings.enabled.len() > 128 {
|
||||
return Err(config_error("too many enablements"));
|
||||
}
|
||||
let mut artifacts = BTreeMap::new();
|
||||
let mut schema_identities = BTreeMap::new();
|
||||
let mut installations = BTreeSet::new();
|
||||
for (alias, enabled) in &settings.enabled {
|
||||
if !installations.insert(&enabled.installation_id) {
|
||||
return Err(config_error("duplicate enabled installation"));
|
||||
}
|
||||
if !safe_name(alias) {
|
||||
return Err(config_error("invalid enablement alias"));
|
||||
}
|
||||
let artifact = plugin_artifact_admit(
|
||||
tx,
|
||||
workspace,
|
||||
&enabled.installation_id,
|
||||
&enabled.expected_last_operation,
|
||||
&enabled.package_digest,
|
||||
&enabled.artifact_digest,
|
||||
)?;
|
||||
validate_enablement(enabled, &artifact, &settings)?;
|
||||
let schema = &artifact.config_schema;
|
||||
let key = (schema.identity.clone(), schema.version.clone());
|
||||
let value = (schema.namespace.clone(), schema.digest.clone());
|
||||
if let Some(previous) = schema_identities.insert(key, value.clone()) {
|
||||
if previous != value {
|
||||
return Err(config_error("schema identity collision"));
|
||||
}
|
||||
}
|
||||
artifacts.insert(alias.clone(), artifact);
|
||||
}
|
||||
// Each alias has its own closed schema. Reusing an artifact with distinct
|
||||
// bindings is intentional and does not manufacture another schema authority.
|
||||
let enabled_fields = artifacts.iter().map(|(alias, artifact)| {
|
||||
format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl)
|
||||
}).collect::<Vec<_>>().join("\n");
|
||||
let enabled_schema = if enabled_fields.is_empty() {
|
||||
"{} default {}".to_string()
|
||||
} else {
|
||||
format!("{{{enabled_fields}}}")
|
||||
};
|
||||
let (prefix, remaining) = BOOTSTRAP
|
||||
.split_once("enabled = ")
|
||||
.ok_or_else(|| config_error("missing bootstrap enablement"))?;
|
||||
let (_, suffix) = remaining
|
||||
.split_once(";\n bindings = ")
|
||||
.ok_or_else(|| config_error("missing bootstrap bindings"))?;
|
||||
let source = format!("{prefix}enabled = {enabled_schema};\n bindings = {suffix}");
|
||||
let identities = settings
|
||||
.enabled
|
||||
.iter()
|
||||
.map(|(alias, e)| {
|
||||
(
|
||||
alias,
|
||||
&e.installation_id,
|
||||
&e.expected_last_operation,
|
||||
&e.package_digest,
|
||||
&e.artifact_digest,
|
||||
&artifacts[alias].config_schema,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let version = format!(
|
||||
"{}:{}",
|
||||
config_source::digest_bytes(BOOTSTRAP.as_bytes()),
|
||||
config_source::digest_bytes(&serde_json::to_vec(&identities).map_err(config_error)?)
|
||||
);
|
||||
providers.retain(|p| p.provider_id != PROVIDER);
|
||||
providers.push(
|
||||
ConfigSchemaContribution::new(PROVIDER, "plugins", version, source)
|
||||
.map_err(config_error)?,
|
||||
);
|
||||
let full_contract =
|
||||
contract(WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?);
|
||||
let full = evaluate(snapshot, &full_contract)?;
|
||||
let final_settings = settings_from_evaluation(&full)?;
|
||||
let resolved = ResolvedPluginSettings {
|
||||
settings: final_settings,
|
||||
artifacts,
|
||||
};
|
||||
validate_profile_selections(&full, &resolved)?;
|
||||
Ok((full_contract, full, resolved))
|
||||
}
|
||||
|
||||
fn safe_name(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
|
||||
fn validate_enablement(
|
||||
enabled: &PluginEnablement,
|
||||
artifact: &PluginArtifact,
|
||||
settings: &PluginSettings,
|
||||
) -> Result<()> {
|
||||
if artifact.schema_version != 2
|
||||
|| !matches!(
|
||||
artifact.world.as_str(),
|
||||
manifest::plugin::PLUGIN_COMPONENT_TOOL_WORLD
|
||||
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
|
||||
| manifest::plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
||||
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
||||
)
|
||||
|| artifact.required_imports.iter().any(|i| {
|
||||
!matches!(
|
||||
i.as_str(),
|
||||
"yoi:host/errors@2.0.0"
|
||||
| "yoi:host/connections@2.0.0"
|
||||
| "yoi:host/http@2.0.0"
|
||||
| "yoi:host/logging@2.0.0"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(config_error("unsupported Component API"));
|
||||
}
|
||||
let tools = enabled.tools.iter().collect::<BTreeSet<_>>();
|
||||
if tools.len() != enabled.tools.len()
|
||||
|| tools
|
||||
.iter()
|
||||
.any(|name| !artifact.tools.iter().any(|tool| &tool.name == *name))
|
||||
{
|
||||
return Err(config_error("unknown or duplicate Tool selection"));
|
||||
}
|
||||
if enabled.logging
|
||||
&& !artifact
|
||||
.requested_grants
|
||||
.contains(&PluginGrantMetadata::Logging)
|
||||
{
|
||||
return Err(config_error("logging exceeds package request"));
|
||||
}
|
||||
for (slot, binding) in &enabled.connections {
|
||||
let requested = artifact
|
||||
.connections
|
||||
.iter()
|
||||
.find(|s| &s.name == slot)
|
||||
.ok_or_else(|| config_error("undeclared connection slot"))?;
|
||||
let binding = settings
|
||||
.bindings
|
||||
.get(binding)
|
||||
.ok_or_else(|| config_error("missing connection binding"))?;
|
||||
// A binding can be narrower than the package request, never wider.
|
||||
if binding
|
||||
.methods
|
||||
.iter()
|
||||
.any(|m| !requested.methods.contains(m))
|
||||
|| binding.path_prefixes.iter().any(|path| {
|
||||
!requested.path_prefixes.iter().any(|prefix| {
|
||||
prefix == "/"
|
||||
|| path == prefix
|
||||
|| path
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
})
|
||||
})
|
||||
{
|
||||
return Err(config_error("binding exceeds package request"));
|
||||
}
|
||||
}
|
||||
if serde_json::to_vec(&enabled.config)
|
||||
.map_err(config_error)?
|
||||
.len()
|
||||
> 64 * 1024
|
||||
{
|
||||
return Err(config_error("non-secret config exceeds 64 KiB"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sync_config_references(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
resolved: &ResolvedPluginSettings,
|
||||
) -> Result<()> {
|
||||
let existing = {
|
||||
let mut statement = tx.prepare("SELECT installation_id, artifact_digest FROM plugin_references WHERE workspace_id=?1 AND kind='config' AND owner=?2")?;
|
||||
statement
|
||||
.query_map(rusqlite::params![workspace, CONFIG_OWNER], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
for (installation, digest) in existing {
|
||||
plugin_reference_remove(
|
||||
tx,
|
||||
workspace,
|
||||
PluginReferenceKind::Config,
|
||||
CONFIG_OWNER,
|
||||
&installation,
|
||||
&digest,
|
||||
)?;
|
||||
}
|
||||
for enabled in resolved.settings.enabled.values() {
|
||||
plugin_reference_put(
|
||||
tx,
|
||||
workspace,
|
||||
&PluginReference {
|
||||
kind: PluginReferenceKind::Config,
|
||||
owner: CONFIG_OWNER.into(),
|
||||
installation_id: enabled.installation_id.clone(),
|
||||
expected_last_operation: enabled.expected_last_operation.clone(),
|
||||
package_digest: enabled.package_digest.clone(),
|
||||
artifact_digest: enabled.artifact_digest.clone(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_profile_selections(
|
||||
evaluation: &EvaluationResult,
|
||||
resolved: &ResolvedPluginSettings,
|
||||
) -> Result<()> {
|
||||
let root = &evaluation.projections[0].data_json;
|
||||
if let Some(entries) = root
|
||||
.pointer("/profile/entries")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
{
|
||||
for entry in entries {
|
||||
if let Some(plugins) = entry.pointer("/profile/plugins") {
|
||||
let plugins: manifest::PluginProfileConfig =
|
||||
serde_json::from_value(plugins.clone()).map_err(config_error)?;
|
||||
project_profile_plugins(resolved, &plugins)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_profile_plugins(
|
||||
resolved: &ResolvedPluginSettings,
|
||||
profile: &manifest::PluginProfileConfig,
|
||||
) -> Result<Vec<crate::plugin_plan::WorkerPluginPin>> {
|
||||
use crate::plugin_plan::{
|
||||
BindingPolicyReference, PluginGrant, PluginPinProjection, PluginSurface, WorkerPluginPin,
|
||||
};
|
||||
let mut selected = BTreeSet::new();
|
||||
let mut pins = Vec::new();
|
||||
for selection in &profile.selections {
|
||||
if !selected.insert(&selection.installation_id) {
|
||||
return Err(config_error("duplicate Profile installation"));
|
||||
}
|
||||
let (alias, enabled) = resolved
|
||||
.settings
|
||||
.enabled
|
||||
.iter()
|
||||
.find(|(_, e)| e.installation_id == selection.installation_id)
|
||||
.ok_or_else(|| {
|
||||
config_error("Profile selects an installation not enabled by Workspace")
|
||||
})?;
|
||||
if selection
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| !enabled.tools.contains(tool))
|
||||
|| (selection.logging && !enabled.logging)
|
||||
|| selection
|
||||
.https_slots
|
||||
.iter()
|
||||
.any(|slot| !enabled.connections.contains_key(slot))
|
||||
{
|
||||
return Err(config_error(
|
||||
"Profile exceeds Workspace surface/grant ceiling",
|
||||
));
|
||||
}
|
||||
let mut config = enabled.config.clone();
|
||||
let object = config
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| config_error("non-secret config must be an object"))?;
|
||||
for (key, value) in &selection.config {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
let config =
|
||||
evaluate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?;
|
||||
let mut grants = Vec::new();
|
||||
if selection.logging {
|
||||
grants.push(PluginGrant::Logging);
|
||||
}
|
||||
let mut bindings = Vec::new();
|
||||
for slot in &selection.https_slots {
|
||||
let binding_id = &enabled.connections[slot];
|
||||
let binding = &resolved.settings.bindings[binding_id];
|
||||
grants.push(PluginGrant::Https { slot: slot.clone() });
|
||||
bindings.push(BindingPolicyReference {
|
||||
slot: slot.clone(),
|
||||
binding_id: binding_id.clone(),
|
||||
policy_digest: binding.digest(),
|
||||
});
|
||||
}
|
||||
pins.push(WorkerPluginPin::from_artifact(
|
||||
enabled.installation_id.clone(),
|
||||
enabled.expected_last_operation.clone(),
|
||||
&resolved.artifacts[alias],
|
||||
PluginPinProjection {
|
||||
config,
|
||||
surfaces: if selection.tools.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![PluginSurface::Tool]
|
||||
},
|
||||
tools: selection.tools.clone(),
|
||||
grants,
|
||||
bindings,
|
||||
},
|
||||
)?);
|
||||
}
|
||||
Ok(pins)
|
||||
}
|
||||
|
||||
fn evaluate_effective_config(value: &serde_json::Value, schema: &str) -> Result<serde_json::Value> {
|
||||
use config_source::{ConfigContentType, ConfigEntry, VirtualPath};
|
||||
if serde_json::to_vec(value).map_err(config_error)?.len() > 64 * 1024 {
|
||||
return Err(config_error("non-secret config exceeds 64 KiB"));
|
||||
}
|
||||
let path = VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT).expect("main path");
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
path,
|
||||
ConfigContentType::Decodal,
|
||||
format!(
|
||||
"{{ config = {}; }} as WorkspaceConfigSchema",
|
||||
render_value(value)?
|
||||
),
|
||||
)
|
||||
.map_err(config_error)?])
|
||||
.map_err(config_error)?;
|
||||
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"plugin-effective-config",
|
||||
"config",
|
||||
"1",
|
||||
format!("{{ config = ({schema}); }}"),
|
||||
)
|
||||
.map_err(config_error)?])
|
||||
.map_err(config_error)?;
|
||||
// Static singleton fields can concretize omitted nested override values.
|
||||
// Pin the complete projection, not just the candidate that passed validation.
|
||||
let evaluated = evaluate(&snapshot, &contract(bundle))?;
|
||||
let config = evaluated
|
||||
.projections
|
||||
.first()
|
||||
.and_then(|projection| projection.data_json.get("config"))
|
||||
.filter(|config| config.is_object())
|
||||
.ok_or_else(|| config_error("effective Plugin config projection must be an object"))?;
|
||||
if serde_json::to_vec(config).map_err(config_error)?.len() > 64 * 1024 {
|
||||
return Err(config_error("non-secret config exceeds 64 KiB"));
|
||||
}
|
||||
Ok(config.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn render_value(value: &serde_json::Value) -> Result<String> {
|
||||
Ok(match value {
|
||||
serde_json::Value::Object(fields) => format!(
|
||||
"{{ {} }}",
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, value)| Ok(format!(
|
||||
"{} = {};",
|
||||
if safe_name(key) {
|
||||
key.clone()
|
||||
} else {
|
||||
return Err(config_error("config key is not a static identifier"));
|
||||
},
|
||||
render_value(value)?
|
||||
)))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.join(" ")
|
||||
),
|
||||
serde_json::Value::Array(values) => format!(
|
||||
"[{}]",
|
||||
values
|
||||
.iter()
|
||||
.map(render_value)
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.join(", ")
|
||||
),
|
||||
serde_json::Value::Null => return Err(config_error("null is not a Plugin config value")),
|
||||
value => serde_json::to_string(value).map_err(config_error)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a maximum immutable snapshot from the current Workspace config and an
|
||||
/// actual registered Profile. The Host supplies execution/delegation identities;
|
||||
/// a caller-provided projection, binding digest or package payload is never used.
|
||||
/// The owning Worker-create transaction may further narrow this snapshot, then
|
||||
/// persist it with `plugin_plan_create` and its own launch fingerprint atomically.
|
||||
/// This does not admit execution, resolve secrets or start/restore a Worker.
|
||||
pub fn resolve_worker_plugin_plan(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
expected_content_digest: &str,
|
||||
profile_selector: &str,
|
||||
execution: crate::plugin_plan::HostExecutionIdentity,
|
||||
) -> Result<crate::plugin_plan::WorkerPluginExecutionPlan> {
|
||||
use crate::plugin_plan::{
|
||||
ConfigProvenance, ProfileProvenance, WorkerPluginExecutionPlan, WorkerPluginPlanInput,
|
||||
};
|
||||
let state = crate::config_source::load_state(tx, workspace)?
|
||||
.ok_or_else(|| config_error("Workspace config is not materialized"))?;
|
||||
if state.snapshot.digest != expected_content_digest {
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
"plan source content changed".into(),
|
||||
));
|
||||
}
|
||||
let (contract, evaluation, resolved) = evaluate_catalog_config(
|
||||
tx,
|
||||
workspace,
|
||||
&state.snapshot,
|
||||
state.contract.schema_bundle.clone(),
|
||||
)?;
|
||||
if state.contract != contract || state.projection_digest != evaluation.projection_digest {
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
"plan source catalog/schema changed".into(),
|
||||
));
|
||||
}
|
||||
let profile = resolve_profile(&evaluation, &state.snapshot.digest, profile_selector)?;
|
||||
let projection_digest = config_source::digest_bytes(
|
||||
&serde_json::to_vec(&profile.manifest_snapshot).map_err(config_error)?,
|
||||
);
|
||||
WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput {
|
||||
workspace_id: workspace.into(),
|
||||
config: ConfigProvenance {
|
||||
content_digest: state.snapshot.digest,
|
||||
toolchain_fingerprint: contract.fingerprint,
|
||||
},
|
||||
profile: ProfileProvenance {
|
||||
identity: profile_selector.into(),
|
||||
projection_digest,
|
||||
},
|
||||
execution,
|
||||
plugins: project_profile_plugins(&resolved, &profile.manifest.plugins)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_profile(
|
||||
evaluation: &EvaluationResult,
|
||||
content_digest: &str,
|
||||
selector: &str,
|
||||
) -> Result<manifest::ResolvedProfile> {
|
||||
let root = &evaluation.projections[0].data_json;
|
||||
if selector.starts_with("project:") {
|
||||
let value = root
|
||||
.pointer("/profile/entries")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|entries| {
|
||||
entries.iter().find(|entry| {
|
||||
entry.get("selector").and_then(serde_json::Value::as_str) == Some(selector)
|
||||
})
|
||||
})
|
||||
.and_then(|entry| entry.get("profile"))
|
||||
.ok_or_else(|| config_error("unknown registered Profile"))?;
|
||||
manifest::resolve_profile_artifact_value(
|
||||
value.clone(),
|
||||
manifest::ProfileSource::Archive {
|
||||
archive_id: format!("workspace-config:{content_digest}"),
|
||||
source: selector.into(),
|
||||
},
|
||||
std::path::Path::new("/"),
|
||||
"plugin-plan-projection",
|
||||
)
|
||||
.map_err(config_error)
|
||||
} else if selector.starts_with("builtin:") {
|
||||
let registry = manifest::ProfileDiscovery::with_sources(None, None)
|
||||
.discover()
|
||||
.map_err(config_error)?;
|
||||
manifest::ProfileResolver::new()
|
||||
.with_workspace_base("/")
|
||||
.resolve_from_registry(
|
||||
&manifest::ProfileSelector::source_named(
|
||||
manifest::ProfileRegistrySource::Builtin,
|
||||
selector.trim_start_matches("builtin:"),
|
||||
),
|
||||
®istry,
|
||||
manifest::ProfileResolveOptions::with_worker_name("plugin-plan-projection"),
|
||||
)
|
||||
.map_err(config_error)
|
||||
} else {
|
||||
Err(config_error("unknown registered Profile selector"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the owner-supplied launch snapshot against config authority on first
|
||||
/// create. Exact replay deliberately uses the saved snapshot instead of rereading
|
||||
/// changed config. Delegation can only remove pins/tools/grants from this maximum.
|
||||
pub(crate) fn validate_plan_source(
|
||||
tx: &Transaction<'_>,
|
||||
plan: &crate::plugin_plan::WorkerPluginExecutionPlan,
|
||||
) -> Result<()> {
|
||||
let maximum = resolve_worker_plugin_plan(
|
||||
tx,
|
||||
plan.workspace_id(),
|
||||
&plan.config().content_digest,
|
||||
&plan.profile().identity,
|
||||
plan.execution().clone(),
|
||||
)?;
|
||||
if maximum.config() != plan.config() || maximum.profile() != plan.profile() {
|
||||
return Err(config_error("plan source provenance mismatch"));
|
||||
}
|
||||
for pin in plan.plugins() {
|
||||
let upper = maximum
|
||||
.plugins()
|
||||
.iter()
|
||||
.find(|p| p.installation_id() == pin.installation_id())
|
||||
.ok_or_else(|| config_error("plan exceeds selected Profile"))?;
|
||||
let lower = pin.projection();
|
||||
let max = upper.projection();
|
||||
if pin.package_digest() != upper.package_digest()
|
||||
|| pin.artifact_digest() != upper.artifact_digest()
|
||||
|| pin.expected_last_operation() != upper.expected_last_operation()
|
||||
|| pin.config_digest() != upper.config_digest()
|
||||
|| pin.config_schema() != upper.config_schema()
|
||||
|| lower.config != max.config
|
||||
|| lower.tools.iter().any(|tool| !max.tools.contains(tool))
|
||||
|| lower.grants.iter().any(|grant| !max.grants.contains(grant))
|
||||
|| lower
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| !max.bindings.contains(binding))
|
||||
|| lower
|
||||
.surfaces
|
||||
.iter()
|
||||
.any(|surface| !max.surfaces.contains(surface))
|
||||
{
|
||||
return Err(config_error("plan exceeds config/Profile upper bound"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) fn base_schema_bundle(
|
||||
bundle: &WorkspaceConfigSchemaBundle,
|
||||
) -> Result<WorkspaceConfigSchemaBundle> {
|
||||
let mut contributions = bundle
|
||||
.contributions
|
||||
.iter()
|
||||
.filter(|p| p.provider_id != PROVIDER)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if bundle
|
||||
.contributions
|
||||
.iter()
|
||||
.any(|p| p.provider_id == PROVIDER)
|
||||
{
|
||||
contributions.push(PluginConfigSchemaProvider.contribution()?);
|
||||
}
|
||||
WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error)
|
||||
}
|
||||
|
||||
// Saved config retains the schema that validated it, even when live catalog
|
||||
// admission changes. Management must remain available to disable/rebind it after
|
||||
// restart. New preview/create still rechecks the live catalog inside its tx.
|
||||
pub(crate) fn materialization_schema(
|
||||
desired: WorkspaceConfigSchemaBundle,
|
||||
saved: &WorkspaceConfigSchemaBundle,
|
||||
) -> Result<WorkspaceConfigSchemaBundle> {
|
||||
let prefix = format!("{}:", config_source::digest_bytes(BOOTSTRAP.as_bytes()));
|
||||
let active = saved
|
||||
.contributions
|
||||
.iter()
|
||||
.find(|p| p.provider_id == PROVIDER && p.version.starts_with(&prefix));
|
||||
let Some(active) = active else {
|
||||
return Ok(desired);
|
||||
};
|
||||
let contributions = desired.contributions.into_iter().map(|p| {
|
||||
if p.provider_id == PROVIDER {
|
||||
active.clone()
|
||||
} else {
|
||||
p
|
||||
}
|
||||
});
|
||||
WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
//! Real catalog + virtual config + SQLite transactions. HTTP authentication and
|
||||
//! guest execution are outside this suite; the canonical Server config path is used.
|
||||
use super::*;
|
||||
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState};
|
||||
use crate::store::{ControlPlaneStore, WorkspaceRecord};
|
||||
use config_source::{ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle};
|
||||
use serde_json::json;
|
||||
use server_api::*;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
struct Fixture {
|
||||
dir: TempDir,
|
||||
store: crate::SqliteWorkspaceStore,
|
||||
installed: PluginOperationResponse,
|
||||
}
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
Self::with_schema("{ mode = String; }").await
|
||||
}
|
||||
async fn with_schema(schema: &str) -> Self {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = crate::SqliteWorkspaceStore::open_for_test(dir.path().join("db")).unwrap();
|
||||
for id in ["a", "b"] {
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: id.into(),
|
||||
owner_account_id: "owner-account".into(),
|
||||
display_name: id.into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let upload = store
|
||||
.plugin_upload(
|
||||
&dir.path().join("artifacts"),
|
||||
"a",
|
||||
"owner",
|
||||
&package_with_schema("0.1.0", schema),
|
||||
)
|
||||
.unwrap();
|
||||
let installed = store
|
||||
.plugin_install(
|
||||
"a",
|
||||
"owner",
|
||||
&PluginInstallRequest {
|
||||
upload_id: upload.upload_id,
|
||||
alias: "echo".into(),
|
||||
operation_key: "install".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for id in ["a", "b"] {
|
||||
store
|
||||
.ensure_workspace_config_materialized_with_schema(id, "1", bundle())
|
||||
.unwrap();
|
||||
}
|
||||
Self {
|
||||
dir,
|
||||
store,
|
||||
installed,
|
||||
}
|
||||
}
|
||||
fn source(&self) -> serde_json::Value {
|
||||
let a = &self.installed.installation.artifacts[0];
|
||||
json!({"plugins":{"enabled":{"echo":{
|
||||
"installation_id":self.installed.installation.installation_id,
|
||||
"expected_last_operation":self.installed.operation_id,
|
||||
"package_digest":a.package_digest,"artifact_digest":a.artifact_digest,
|
||||
"tools":["example_echo"],"logging":false,"connections":{},"config":{"mode":"hello"}
|
||||
}},"bindings":{}},"profile":{"default_profile":"project:tool","entries":[{"selector":"project:tool","profile":{"plugins":{"selections":[{"installation_id":self.installed.installation.installation_id,"tools":["example_echo"],"logging":false}]}},"label":"","description":""}]}})
|
||||
}
|
||||
fn candidate(
|
||||
&self,
|
||||
workspace: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<crate::config_source::EvaluatedConfigCandidate> {
|
||||
let state = self.store.load_workspace_config(workspace)?.unwrap();
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let request = ConfigCommitRequest {
|
||||
base_digest: state.snapshot.digest.clone(),
|
||||
entrypoints: vec![path.clone()],
|
||||
changes: vec![ConfigTreeChange::Update {
|
||||
path: path.clone(),
|
||||
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
|
||||
content: format!("{} as WorkspaceConfigSchema", render_value(&value)?),
|
||||
}],
|
||||
};
|
||||
self.store
|
||||
.evaluate_workspace_config_candidate_with_schema(workspace, &request, bundle())
|
||||
}
|
||||
fn commit(&self, value: serde_json::Value) -> WorkspaceConfigState {
|
||||
let c = self.candidate("a", value).unwrap();
|
||||
self.store
|
||||
.commit_evaluated_workspace_config("a", &c)
|
||||
.unwrap()
|
||||
}
|
||||
fn refs(&self) -> i64 {
|
||||
self.store
|
||||
.with_conn(|c| {
|
||||
Ok(c.query_row(
|
||||
"SELECT count(*) FROM plugin_references WHERE kind='config'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn plan(&self, state: &WorkspaceConfigState) -> crate::plugin_plan::WorkerPluginExecutionPlan {
|
||||
self.store
|
||||
.with_conn(|c| {
|
||||
let tx = c.unchecked_transaction()?;
|
||||
resolve_worker_plugin_plan(
|
||||
&tx,
|
||||
"a",
|
||||
&state.snapshot.digest,
|
||||
"project:tool",
|
||||
crate::plugin_plan::HostExecutionIdentity {
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "worker".into(),
|
||||
principal_id: "principal".into(),
|
||||
delegation_id: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
fn bundle() -> WorkspaceConfigSchemaBundle {
|
||||
WorkspaceConfigSchemaBundle::compose([
|
||||
PluginConfigSchemaProvider.contribution().unwrap(),
|
||||
crate::profile_settings::ProfileConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
fn package(version: &str) -> Vec<u8> {
|
||||
package_with_schema(version, "{ mode = String; }")
|
||||
}
|
||||
fn package_with_schema(version: &str, schema: &str) -> Vec<u8> {
|
||||
use manifest::plugin::*;
|
||||
let d = tempdir().unwrap();
|
||||
let source =
|
||||
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml")
|
||||
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""))
|
||||
.replace(
|
||||
"config_schema = \"{}\"",
|
||||
&format!("config_schema = {}", serde_json::to_string(schema).unwrap()),
|
||||
);
|
||||
std::fs::write(d.path().join("plugin.toml"), source).unwrap();
|
||||
std::fs::write(
|
||||
d.path().join("plugin.component.wasm"),
|
||||
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
let parsed = read_plugin_directory(
|
||||
d.path(),
|
||||
PluginSourceKind::Explicit,
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
pack_plugin_package(&parsed, &PluginPackageLimits::default())
|
||||
.unwrap()
|
||||
.bytes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_two_phase_closed_schema_and_profile_narrowing_reject_unknown_abstract_and_expansion()
|
||||
{
|
||||
let f = Fixture::new().await;
|
||||
let source = f.source();
|
||||
let candidate = f.candidate("a", source.clone()).unwrap();
|
||||
assert!(
|
||||
candidate
|
||||
.contract
|
||||
.schema_bundle
|
||||
.source
|
||||
.contains("mode = String")
|
||||
);
|
||||
let native = SnapshotEnvironment::new(candidate.snapshot.clone())
|
||||
.evaluate_contract(&candidate.contract)
|
||||
.unwrap();
|
||||
assert_eq!(native, candidate.evaluation);
|
||||
for pointer in [
|
||||
"/plugins/enabled/echo/config/extra",
|
||||
"/plugins/enabled/echo/logging",
|
||||
"/profile/entries/0/profile/plugins/selections/0/logging",
|
||||
] {
|
||||
let mut bad = source.clone();
|
||||
if pointer.ends_with("extra") {
|
||||
bad["plugins"]["enabled"]["echo"]["config"]["extra"] = json!(true);
|
||||
} else {
|
||||
*bad.pointer_mut(pointer)
|
||||
.unwrap_or_else(|| panic!("missing {pointer}")) = json!(true);
|
||||
}
|
||||
assert!(f.candidate("a", bad).is_err(), "accepted {pointer}");
|
||||
}
|
||||
let mut invalid = source.clone();
|
||||
invalid["plugins"]["enabled"]["echo"]["config"]["mode"] = json!(3);
|
||||
assert!(f.candidate("a", invalid).is_err());
|
||||
assert!(f.candidate("b", source).is_err());
|
||||
assert_eq!(f.refs(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_refs_commit_atomically_block_uninstall_and_release_on_disable() {
|
||||
let f = Fixture::new().await;
|
||||
f.commit(f.source());
|
||||
assert_eq!(f.refs(), 1);
|
||||
assert!(
|
||||
f.store
|
||||
.plugin_uninstall(
|
||||
"a",
|
||||
"owner",
|
||||
&f.installed.installation.installation_id,
|
||||
&PluginUninstallRequest {
|
||||
expected_last_operation: f.installed.operation_id.clone(),
|
||||
operation_key: "remove".into()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.commit(json!({"plugins":{},"profile":{"entries":[]}}));
|
||||
assert_eq!(f.refs(), 0);
|
||||
f.store
|
||||
.plugin_uninstall(
|
||||
"a",
|
||||
"owner",
|
||||
&f.installed.installation.installation_id,
|
||||
&PluginUninstallRequest {
|
||||
expected_last_operation: f.installed.operation_id.clone(),
|
||||
operation_key: "remove-after-disable".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catalog_add_after_preview_rejects_stale_candidate_and_preserves_config_refs() {
|
||||
let f = Fixture::new().await;
|
||||
let candidate = f.candidate("a", f.source()).unwrap();
|
||||
let upload = f
|
||||
.store
|
||||
.plugin_upload(
|
||||
&f.dir.path().join("artifacts"),
|
||||
"a",
|
||||
"owner",
|
||||
&package("0.2.0"),
|
||||
)
|
||||
.unwrap();
|
||||
f.store
|
||||
.plugin_add_artifact(
|
||||
"a",
|
||||
"owner",
|
||||
&f.installed.installation.installation_id,
|
||||
&PluginAddArtifactRequest {
|
||||
upload_id: upload.upload_id,
|
||||
expected_last_operation: f.installed.operation_id.clone(),
|
||||
operation_key: "update".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
f.store
|
||||
.commit_evaluated_workspace_config("a", &candidate)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(f.refs(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn immutable_plan_create_replay_and_config_changes_do_not_hot_reload() {
|
||||
use crate::plugin_plan::*;
|
||||
let f = Fixture::new().await;
|
||||
let state = f.commit(f.source());
|
||||
let plan = f.plan(&state);
|
||||
assert_eq!(plan, f.plan(&state));
|
||||
let receipt = f.store.plugin_plan_create("host", "create", &plan).unwrap();
|
||||
let json = plan.canonical_json().unwrap();
|
||||
assert!(!json.contains("https://"));
|
||||
assert!(!json.contains(f.dir.path().to_str().unwrap()));
|
||||
let mut source = f.source();
|
||||
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["tools"] = json!([]);
|
||||
f.commit(source);
|
||||
assert_eq!(
|
||||
f.store.plugin_plan_create("host", "create", &plan).unwrap(),
|
||||
receipt
|
||||
);
|
||||
let saved = f.store.plugin_plan_load("a", &receipt.plan_id).unwrap();
|
||||
assert_eq!(saved.plan, plan);
|
||||
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
|
||||
assert_eq!(
|
||||
reopened.plugin_plan_load("a", &receipt.plan_id).unwrap(),
|
||||
saved
|
||||
);
|
||||
reopened
|
||||
.plugin_plan_transition(
|
||||
"a",
|
||||
&receipt.plan_id,
|
||||
PlanPinState::Pending,
|
||||
PlanPinState::Released,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.plugin_plan_create("host", "create", &plan)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_override_is_closed_schema_validated_and_plan_only_uses_effective_config() {
|
||||
let f = Fixture::new().await;
|
||||
let mut source = f.source();
|
||||
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
|
||||
json!({"mode":"override"});
|
||||
let state = f.commit(source.clone());
|
||||
let plan = f.plan(&state);
|
||||
assert_eq!(
|
||||
plan.plugins()[0].projection().config,
|
||||
json!({"mode":"override"})
|
||||
);
|
||||
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
|
||||
json!({"unknown":"not permitted"});
|
||||
assert!(f.candidate("a", source).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_create_rejects_stale_or_forged_config_profile_and_projection_without_pins() {
|
||||
use crate::plugin_plan::WorkerPluginExecutionPlan;
|
||||
let f = Fixture::new().await;
|
||||
let state = f.commit(f.source());
|
||||
let plan = f.plan(&state);
|
||||
for pointer in [
|
||||
"/input/config/content_digest",
|
||||
"/input/profile/projection_digest",
|
||||
"/input/plugins/0/config_digest",
|
||||
] {
|
||||
let mut forged = serde_json::to_value(&plan).unwrap();
|
||||
*forged.pointer_mut(pointer).unwrap() = json!(config_source::digest_bytes(b"forged"));
|
||||
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
|
||||
assert!(
|
||||
f.store
|
||||
.plugin_plan_create("host", pointer, &forged)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut forged = serde_json::to_value(&plan).unwrap();
|
||||
forged["input"]["plugins"][0]["projection"]["config"] = json!({"mode":"caller-injected"});
|
||||
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
|
||||
assert!(
|
||||
f.store
|
||||
.plugin_plan_create("host", "value-injection", &forged)
|
||||
.is_err()
|
||||
);
|
||||
let mut next = f.source();
|
||||
next["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["tools"] = json!([]);
|
||||
f.commit(next);
|
||||
assert!(
|
||||
f.store
|
||||
.plugin_plan_create("host", "stale-plan", &plan)
|
||||
.is_err()
|
||||
);
|
||||
f.store
|
||||
.with_conn(|c| {
|
||||
assert_eq!(
|
||||
c.query_row("SELECT count(*) FROM plugin_execution_plans", [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM plugin_references WHERE kind != 'config'",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
0
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_persistence_failure_rolls_back_reference_changes_and_restart_keeps_management_after_revoke()
|
||||
{
|
||||
let f = Fixture::new().await;
|
||||
let before = f.store.load_workspace_config("a").unwrap().unwrap();
|
||||
let candidate = f.candidate("a", f.source()).unwrap();
|
||||
f.store.with_conn(|c| { c.execute_batch("CREATE TRIGGER stop_config BEFORE INSERT ON workspace_config_entries BEGIN SELECT RAISE(ABORT,'config failure'); END;")?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
f.store
|
||||
.commit_evaluated_workspace_config("a", &candidate)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(f.refs(), 0);
|
||||
assert_eq!(f.store.load_workspace_config("a").unwrap().unwrap(), before);
|
||||
f.store
|
||||
.with_conn(|c| {
|
||||
c.execute_batch("DROP TRIGGER stop_config")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let committed = f
|
||||
.store
|
||||
.commit_evaluated_workspace_config("a", &candidate)
|
||||
.unwrap();
|
||||
let a = &f.installed.installation.artifacts[0];
|
||||
f.store
|
||||
.plugin_revoke(
|
||||
"a",
|
||||
"owner",
|
||||
&f.installed.installation.installation_id,
|
||||
&PluginRevokeRequest {
|
||||
artifact_digest: a.artifact_digest.clone(),
|
||||
expected_last_operation: f.installed.operation_id.clone(),
|
||||
reason: "withdraw".into(),
|
||||
operation_key: "withdraw".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.ensure_workspace_config_materialized_with_schema("a", "2", bundle())
|
||||
.unwrap(),
|
||||
committed
|
||||
);
|
||||
assert!(f.candidate("a", f.source()).is_err());
|
||||
f.commit(json!({"plugins":{},"profile":{"entries":[]}}));
|
||||
assert_eq!(f.refs(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_config_binding_is_validated_pinned_by_digest_and_never_copied_to_plan() {
|
||||
use crate::plugin_plan::WorkerPluginExecutionPlan;
|
||||
use manifest::plugin::*;
|
||||
let f = Fixture::new().await;
|
||||
let d = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
d.path().join("plugin.toml"),
|
||||
include_str!("../../../../resources/plugin/templates/rust-component-https/plugin.toml"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
d.path().join("plugin.component.wasm"),
|
||||
include_bytes!("../../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
let limits = PluginPackageLimits::default();
|
||||
let bytes = pack_plugin_package(
|
||||
&read_plugin_directory(d.path(), PluginSourceKind::Explicit, &limits).unwrap(),
|
||||
&limits,
|
||||
)
|
||||
.unwrap()
|
||||
.bytes;
|
||||
let upload = f
|
||||
.store
|
||||
.plugin_upload(&f.dir.path().join("artifacts"), "a", "owner", &bytes)
|
||||
.unwrap();
|
||||
let installed = f
|
||||
.store
|
||||
.plugin_install(
|
||||
"a",
|
||||
"owner",
|
||||
&PluginInstallRequest {
|
||||
upload_id: upload.upload_id,
|
||||
alias: "https".into(),
|
||||
operation_key: "https-install".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let artifact = &installed.installation.artifacts[0];
|
||||
let mut source = f.source();
|
||||
source["plugins"]["enabled"]["echo"] = json!({"installation_id":installed.installation.installation_id,"expected_last_operation":installed.operation_id,"package_digest":artifact.package_digest,"artifact_digest":artifact.artifact_digest,"tools":["example_status"],"connections":{"api":"remote"},"config":{}});
|
||||
source["plugins"]["bindings"] = json!({"remote":{"origin":"https://api.example.com","methods":["GET"],"path_prefixes":["/status"],"request_headers":["accept"],"response_headers":["content-type"],"secret_ref":"credential_ref","limits":{"timeout_ms":1000,"max_request_bytes":1024,"max_response_bytes":1024,"max_header_bytes":512}}});
|
||||
source["profile"]["entries"][0]["profile"]["plugins"]["selections"] = json!([{"installation_id":installed.installation.installation_id,"tools":["example_status"],"https_slots":["api"]}]);
|
||||
let state = f.commit(source.clone());
|
||||
let plan = f.plan(&state);
|
||||
let encoded = plan.canonical_json().unwrap();
|
||||
for excluded in [
|
||||
"https://api.example.com",
|
||||
"credential_ref",
|
||||
"origin",
|
||||
"request_headers",
|
||||
"secret_ref",
|
||||
] {
|
||||
assert!(!encoded.contains(excluded), "leaked {excluded}");
|
||||
}
|
||||
assert_eq!(
|
||||
plan.plugins()[0].projection().bindings[0].binding_id,
|
||||
"remote"
|
||||
);
|
||||
let receipt = f
|
||||
.store
|
||||
.plugin_plan_create("host", "https-plan", &plan)
|
||||
.unwrap();
|
||||
let mut forged = serde_json::to_value(&plan).unwrap();
|
||||
forged["input"]["plugins"][0]["projection"]["bindings"][0]["policy_digest"] =
|
||||
json!(config_source::digest_bytes(b"forged-policy"));
|
||||
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
|
||||
assert!(
|
||||
f.store
|
||||
.plugin_plan_create("host", "forged-policy", &forged)
|
||||
.is_err()
|
||||
);
|
||||
for path in ["/statusish", "/%2e%2e/private"] {
|
||||
let mut bad = source.clone();
|
||||
bad["plugins"]["bindings"]["remote"]["path_prefixes"] = json!([path]);
|
||||
assert!(f.candidate("a", bad).is_err());
|
||||
}
|
||||
source["plugins"]["bindings"]["remote"]["origin"] = json!("https://other.example.com");
|
||||
f.commit(source);
|
||||
assert!(
|
||||
f.store
|
||||
.plugin_plan_create("host", "stale-binding", &plan)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
f.store
|
||||
.plugin_plan_create("host", "https-plan", &plan)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_profile_override_pins_evaluated_singletons_through_create_restart_and_replay() {
|
||||
use crate::plugin_plan::{WorkerPluginExecutionPlan, WorkerPluginPlanInput};
|
||||
let f = Fixture::with_schema("{ nested = { mode = String; count = 1; }; }").await;
|
||||
let mut source = f.source();
|
||||
source["plugins"]["enabled"]["echo"]["config"] = json!({"nested":{"mode":"base","count":1}});
|
||||
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
|
||||
json!({"nested":{"mode":"override"}});
|
||||
let state = f.commit(source);
|
||||
let plan = f.plan(&state);
|
||||
let expected = json!({"nested":{"count":1,"mode":"override"}});
|
||||
assert_eq!(plan.plugins()[0].projection().config, expected);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&plan).unwrap()["input"]["plugins"][0]["config_digest"],
|
||||
config_source::digest_bytes(&serde_json::to_vec(&expected).unwrap())
|
||||
);
|
||||
let receipt = f
|
||||
.store
|
||||
.plugin_plan_create("host", "singletons", &plan)
|
||||
.unwrap();
|
||||
assert_eq!(receipt.plan.plugins()[0].projection().config, expected);
|
||||
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
|
||||
let stored = reopened.plugin_plan_load("a", &receipt.plan_id).unwrap();
|
||||
assert_eq!(stored.plan, plan);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.plugin_plan_create("host", "singletons", &plan)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
// Even a canonically rehashed candidate cannot substitute the raw override
|
||||
// for the complete projection reconstructed at the public create fence.
|
||||
let mut forged = serde_json::to_value(&plan).unwrap()["input"].clone();
|
||||
forged["plugins"][0]["projection"]["config"] = json!({"nested":{"mode":"override"}});
|
||||
let input: WorkerPluginPlanInput = serde_json::from_value(forged).unwrap();
|
||||
let incomplete = WorkerPluginExecutionPlan::canonicalize(input).unwrap();
|
||||
assert!(
|
||||
reopened
|
||||
.plugin_plan_create("host", "raw-override", &incomplete)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_config_size_limit_applies_after_schema_concretization() {
|
||||
let schema = format!("{{ value = {}; }}", json!("a".repeat(64 * 1024)));
|
||||
let error = evaluate_effective_config(&json!({}), &schema).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("non-secret config exceeds 64 KiB"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,14 @@ mod schema_convergence {
|
||||
version: 106,
|
||||
name: crate::plugin_catalog::MIGRATION_NAME.into(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 107,
|
||||
name: "Immutable Worker Plugin plans and retained pins".into(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 108,
|
||||
name: "Subject Ticket responsibility and current-body notification routing".into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -121,6 +129,8 @@ mod schema_convergence {
|
||||
expected.push((104, "Converge Passkey results and Job input retention".into()));
|
||||
expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into()));
|
||||
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
|
||||
expected.push((107, "Immutable Worker Plugin plans and retained pins".into()));
|
||||
expected.push((108, "Subject Ticket responsibility and current-body notification routing".into()));
|
||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
|
||||
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Included in store::tests so both paths use the real retained migration chain.
|
||||
fn state_contract_schema_signature(conn: &Connection, table: &str) -> Vec<Vec<String>> {
|
||||
// Retained host generations alone add this exact Workspace-parent FK to
|
||||
// the three principal tables. All other FKs (including composite owner FKs)
|
||||
// remain in the signature; do not discard foreign-key contracts wholesale.
|
||||
let queries = [
|
||||
"SELECT name, type, CAST(\"notnull\" AS TEXT), COALESCE(dflt_value, '<NULL>'), CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name",
|
||||
"SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) ORDER BY \"table\", \"from\", \"to\", seq",
|
||||
"SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) WHERE NOT (?1 IN ('ticket_worker_assignments','ticket_current_worker_assignments','ticket_assignment_operations') AND \"table\"='workspaces' AND \"from\"='workspace_id' AND \"to\"='workspace_id' AND on_update='NO ACTION' AND on_delete='CASCADE' AND match='NONE' AND seq=0) ORDER BY \"table\", \"from\", \"to\", seq",
|
||||
"SELECT CAST(i.\"unique\" AS TEXT), CAST(i.partial AS TEXT), (SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1, 2, 3",
|
||||
"SELECT name, sql FROM sqlite_schema WHERE type='trigger' AND tbl_name=?1 ORDER BY name",
|
||||
];
|
||||
@@ -52,8 +55,16 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignments",
|
||||
"ticket_assignment_operations",
|
||||
"ticket_assignment_work_releases",
|
||||
"artifacts",
|
||||
"plugin_installations",
|
||||
"plugin_artifacts",
|
||||
"plugin_uploads",
|
||||
"plugin_operations",
|
||||
"plugin_references",
|
||||
"plugin_execution_plans",
|
||||
"workspace_config_trees",
|
||||
"workspace_config_tree_history",
|
||||
"workspace_memory_settings",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ mod chat_delegation_connected;
|
||||
mod chat_tests;
|
||||
#[path = "subject_job_model_supply_tests.rs"]
|
||||
mod subject_job_model_supply;
|
||||
#[path = "ticket_worker_messaging_connected_tests.rs"]
|
||||
mod ticket_worker_messaging_connected;
|
||||
#[path = "worker_messaging_connected_tests.rs"]
|
||||
mod worker_messaging_connected;
|
||||
#[path = "worker_operation_authorization_tests.rs"]
|
||||
@@ -2610,6 +2612,60 @@ impl Drop for ConnectedRuntimeServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_subject_child_cannot_downgrade_ticket_start_to_manual_by_omitting_assignment() {
|
||||
let f = Fixture::new().await;
|
||||
let create = ScopedPermission {
|
||||
operation: "worker.create".into(),
|
||||
resource: PermissionResource::Workspace {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
},
|
||||
};
|
||||
f.assign(vec![create.clone()], vec![create.clone()]).await;
|
||||
let mut request = f.child_request("ticket-initiation-source", vec![create.clone()]);
|
||||
request["execution_grant"]["redelegation"] = json!([create]);
|
||||
let (status, created) = f.spawn(&f.source, request).await;
|
||||
assert_eq!(status, StatusCode::OK, "{created}");
|
||||
let child = RuntimeWorkerRef::new(
|
||||
created["runtime_id"].as_str().unwrap(),
|
||||
created["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
// The child's ordinary redelegation remains valid: body ownership is a
|
||||
// Ticket-initiation condition, not a blanket ban on general Worker creation.
|
||||
let (status, ordinary) = f
|
||||
.spawn(&child, f.child_request("ordinary-grandchild", vec![]))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{ordinary}");
|
||||
|
||||
let ticket = browser_ticket_backend(&f.api)
|
||||
.unwrap()
|
||||
.create(NewTicket::new("Another Subject's Ticket"))
|
||||
.unwrap();
|
||||
assign_test_orchestrator(&f.api, &ticket.id);
|
||||
let spawns = f.runtime.spawn_requests().len();
|
||||
let mut request = f.child_request("child-ticket-control", vec![]);
|
||||
request["ticket_assignment"] = json!({
|
||||
"ticket_id": ticket.id, "operation_id":"child-ticket-start-without-assignment",
|
||||
});
|
||||
let (status, denied) = f.spawn(&child, request).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{denied}");
|
||||
assert!(
|
||||
denied["message"].as_str().unwrap().contains("Subject body"),
|
||||
"{denied}"
|
||||
);
|
||||
assert_eq!(f.runtime.spawn_requests().len(), spawns);
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
.get_ticket_assignment_operation(
|
||||
TEST_WORKSPACE_ID,
|
||||
"child-ticket-start-without-assignment",
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
const CONNECTED_RUNTIME: &str = "connected-subject-authorization";
|
||||
|
||||
/// Shared signed Backend/Runtime connection. Only the supplied model is replaced;
|
||||
@@ -2629,6 +2685,33 @@ struct ConnectedWorkerHarness {
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
impl ConnectedWorkerHarness {
|
||||
async fn human(&self, method: reqwest::Method, path: &str, body: Value) -> (StatusCode, Value) {
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap()
|
||||
.request(method.clone(), format!("{}{path}", self.backend_url))
|
||||
.bearer_auth(&self.owner)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = StatusCode::from_u16(response.status().as_u16()).unwrap();
|
||||
let bytes = response.bytes().await.unwrap();
|
||||
let body = if status == StatusCode::NO_CONTENT {
|
||||
assert!(bytes.is_empty(), "{method} {path}: HTTP204 must be empty");
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&bytes).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"{method} {path}: HTTP{status}, invalid JSON: {error}; body={:?}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
)
|
||||
})
|
||||
};
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn start<M: agen::llm_client::LlmClient + Clone + 'static>(model: M) -> Self {
|
||||
Self::start_with_layer(model, |router| router).await
|
||||
}
|
||||
@@ -3093,6 +3176,528 @@ async fn connected_read_only_subject_delegates_editing_profile_to_real_runtime_r
|
||||
);
|
||||
}
|
||||
|
||||
/// Deterministic external-model substitute, not an LLM evaluation. Each real
|
||||
/// Controller reads before joining the barrier, so serialized child execution
|
||||
/// cannot pass. File IO and result persistence always use production tools.
|
||||
#[derive(Clone)]
|
||||
struct ConcurrentTicketAuthorizationModel {
|
||||
overlap: Arc<tokio::sync::Barrier>,
|
||||
completed: tokio::sync::mpsc::Sender<(String, Vec<(String, bool, String)>)>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl agen::llm_client::LlmClient for ConcurrentTicketAuthorizationModel {
|
||||
async fn stream(
|
||||
&self,
|
||||
request: agen::llm_client::Request,
|
||||
) -> std::result::Result<
|
||||
std::pin::Pin<
|
||||
Box<
|
||||
dyn futures::Stream<
|
||||
Item = std::result::Result<
|
||||
agen::llm_client::event::Event,
|
||||
agen::llm_client::error::ClientError,
|
||||
>,
|
||||
> + Send,
|
||||
>,
|
||||
>,
|
||||
agen::llm_client::error::ClientError,
|
||||
> {
|
||||
use agen::llm_client::{
|
||||
event::{Event, StopReason},
|
||||
types::{ContentPart, Item},
|
||||
};
|
||||
let input = request.items.iter().find_map(|item| {
|
||||
let Item::Message { content, .. } = item else {
|
||||
return None;
|
||||
};
|
||||
content.iter().find_map(|part| {
|
||||
let ContentPart::Text { text } = part else {
|
||||
return None;
|
||||
};
|
||||
text.strip_prefix("connected-ticket-case:")
|
||||
.map(|json| serde_json::from_str::<Value>(json).unwrap())
|
||||
})
|
||||
});
|
||||
let end = || {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "scripted turn finished"),
|
||||
Event::text_block_stop(0, Some(StopReason::EndTurn)),
|
||||
]
|
||||
};
|
||||
let Some(input) = input else {
|
||||
// Bodies may receive normal lifecycle/notification input. That must
|
||||
// never run the child script or count as a concurrent child turn.
|
||||
return Ok(Box::pin(futures::stream::iter(end().into_iter().map(Ok))));
|
||||
};
|
||||
let ticket = input["ticket"].as_str().unwrap();
|
||||
let expected = input["content"].as_str().unwrap();
|
||||
let results: Vec<_> = request
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
is_error,
|
||||
content,
|
||||
summary,
|
||||
..
|
||||
} if call_id.starts_with("parallel-") => Some((
|
||||
call_id.clone(),
|
||||
*is_error,
|
||||
format!("{summary}; {content:?}"),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
results.iter().all(|(_, error, _)| !error),
|
||||
"real child tool failed: {results:?}"
|
||||
);
|
||||
let has = |id: &str| results.iter().any(|(call, _, _)| call == id);
|
||||
let events = if has("parallel-report") {
|
||||
self.completed.try_send((ticket.into(), results)).unwrap();
|
||||
end()
|
||||
} else {
|
||||
let (id, name, arguments) = if has("parallel-reread") {
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.find(|(id, _, _)| id == "parallel-reread")
|
||||
.unwrap()
|
||||
.2
|
||||
.contains(expected),
|
||||
"result must follow the real post-write Read: {results:?}"
|
||||
);
|
||||
(
|
||||
"parallel-report",
|
||||
"TicketImplementationResult",
|
||||
json!({"ticket":ticket, "kind":"result",
|
||||
"body":format!("Verified README.md contains {expected}"), "merge_request":null}),
|
||||
)
|
||||
} else if has("parallel-write") {
|
||||
(
|
||||
"parallel-reread",
|
||||
"Read",
|
||||
json!({"target_workdir":"workdir", "file_path":"README.md"}),
|
||||
)
|
||||
} else if has("parallel-read") {
|
||||
assert!(
|
||||
results[0].2.contains("clean"),
|
||||
"actual initial read: {results:?}"
|
||||
);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), self.overlap.wait())
|
||||
.await
|
||||
.expect("both real child turns must overlap after their initial Read");
|
||||
(
|
||||
"parallel-write",
|
||||
"Write",
|
||||
json!({"target_workdir":"workdir", "file_path":"README.md", "content":expected}),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"parallel-read",
|
||||
"Read",
|
||||
json!({"target_workdir":"workdir", "file_path":"README.md"}),
|
||||
)
|
||||
};
|
||||
assert!(
|
||||
request.tools.iter().any(|tool| tool.name == name),
|
||||
"Profile must expose {name}"
|
||||
);
|
||||
vec![
|
||||
Event::tool_use_start(0, id, name),
|
||||
Event::tool_input_delta(0, arguments.to_string()),
|
||||
Event::tool_use_stop(0),
|
||||
]
|
||||
};
|
||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||
async fn connected_two_subjects_delegate_distinct_tickets_concurrently_on_separate_branches() {
|
||||
use worker_runtime::working_directory::WorkingDirectoryMaterializer;
|
||||
|
||||
struct Case {
|
||||
subject: String,
|
||||
ticket: String,
|
||||
branch: String,
|
||||
workdir: String,
|
||||
root: PathBuf,
|
||||
source: RuntimeWorkerRef,
|
||||
delegation: String,
|
||||
content: String,
|
||||
permissions: Vec<ScopedPermission>,
|
||||
change: String,
|
||||
request: Value,
|
||||
}
|
||||
let (completed, mut completion) = tokio::sync::mpsc::channel(2);
|
||||
let f = ConnectedWorkerHarness::start(ConcurrentTicketAuthorizationModel {
|
||||
overlap: Arc::new(tokio::sync::Barrier::new(2)),
|
||||
completed,
|
||||
})
|
||||
.await;
|
||||
let api = &f.api;
|
||||
install_subject_profiles(api, "workspace_write");
|
||||
let subjects = open_subjektiv_store(api).unwrap();
|
||||
let mut cases = Vec::new();
|
||||
for label in ["alpha", "beta"] {
|
||||
let branch = format!("subject-{label}-work");
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(f._dir.path())
|
||||
.args(["branch", &branch])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let (status, created) = f
|
||||
.human(
|
||||
reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
|
||||
json!({"runtime_id":CONNECTED_RUNTIME, "repository_key":"test-repository",
|
||||
"selector":branch, "operation_id":format!("parallel-{label}-workdir")}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED, "{created}");
|
||||
let workdir = created["item"]["working_directory_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let root = f
|
||||
.materializer
|
||||
.bind_working_directory(&workdir, None)
|
||||
.unwrap()
|
||||
.root()
|
||||
.to_path_buf();
|
||||
// Runtime materialization intentionally checks out detached. Establish
|
||||
// each working branch as fixture setup before either child starts; the
|
||||
// real Controller tools below must mutate these separate checkouts.
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&root)
|
||||
.args(["switch", "-c", &branch])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("README.md")).unwrap(),
|
||||
"clean\n"
|
||||
);
|
||||
let subject = subjects
|
||||
.create_subject(
|
||||
crate::subjektiv::SubjectRole::new(format!("parallel-{label}")).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut input = NewTicket::new(format!("Connected {label} work"));
|
||||
set_test_ticket_target(&mut input, "test-repository", &branch);
|
||||
let ticket = browser_ticket_backend(api).unwrap().create(input).unwrap();
|
||||
let ticket_permission = |operation: &str| ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Ticket {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket.id.clone(),
|
||||
},
|
||||
};
|
||||
// No workspace-wide delegation grant: exactly this file in this Workdir
|
||||
// and read/result operations on this Ticket. The body stays Workdirless.
|
||||
let permissions: Vec<_> = ["filesystem.read", "filesystem.write"]
|
||||
.into_iter()
|
||||
.map(|operation| ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Workdir {
|
||||
working_directory_id: workdir.clone(),
|
||||
path: "README.md".into(),
|
||||
recursive: false,
|
||||
},
|
||||
})
|
||||
.chain([
|
||||
ticket_permission("ticket.read"),
|
||||
ticket_permission("ticket.implementation_result.write"),
|
||||
])
|
||||
.collect();
|
||||
let mut execution: Vec<_> = [
|
||||
"session.record",
|
||||
"memory.resident.read",
|
||||
"memory.resident.supply",
|
||||
"journal.startup.read",
|
||||
"journal.startup.supply",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|operation| subject_permission(&subject, operation))
|
||||
.collect();
|
||||
execution.push(ticket_permission("ticket.assignment.assign"));
|
||||
execution.push(ScopedPermission {
|
||||
operation: "worker.create".into(),
|
||||
resource: PermissionResource::Workspace {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
},
|
||||
});
|
||||
let (status, policy) = f
|
||||
.human(
|
||||
reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||
json!({"operation_id":format!("parallel-{label}-policy"), "expected_change_id":null,
|
||||
"execution":execution, "delegation":permissions}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{policy}");
|
||||
let policy = subjects.subject_permissions(&subject).unwrap().unwrap();
|
||||
assert_eq!(policy.delegation, permissions);
|
||||
let (status, source) = f.human(reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||
json!({"runtime_id":CONNECTED_RUNTIME, "display_name":format!("{label} read-only body"),
|
||||
"profile":"project:authority-source", "initial_submit":[], "workdir_attachments":[],
|
||||
"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"connect: {source}; Runtime: {:?}",
|
||||
f.runtime_errors.lock().unwrap()
|
||||
);
|
||||
let source = RuntimeWorkerRef::new(
|
||||
source["runtime_id"].as_str().unwrap(),
|
||||
source["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &source)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let lease = api
|
||||
.store
|
||||
.require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &source)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
lease.key,
|
||||
crate::subjektiv::subject_worker_singleton_key(&subject).unwrap()
|
||||
);
|
||||
let (status, assigned) = f.human(reqwest::Method::PUT,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/orchestrator", ticket.id),
|
||||
json!({"operation_id":format!("parallel-{label}-assign"), "expected_assignment_id":null,
|
||||
"principal":{"kind":"subject", "subject_id":subject}})).await;
|
||||
assert_eq!(status, StatusCode::OK, "{assigned}");
|
||||
assert_eq!(assigned["assignment"]["principal"]["subject_id"], subject);
|
||||
let delegation = assigned["assignment"]["assignment_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let content = format!("connected {label} result");
|
||||
let request = json!({"runtime_id":CONNECTED_RUNTIME,
|
||||
"display_name":format!("{label} delegated Ticket editor"), "profile":"project:authority-editor",
|
||||
"ticket_assignment":{"ticket_id":ticket.id, "operation_id":format!("parallel-{label}-start"),
|
||||
"orchestrator_assignment_id":delegation},
|
||||
"initial_submit":[{"kind":"text", "content":format!("connected-ticket-case:{}", json!({"ticket":ticket.id,"content":content}))}],
|
||||
"workdir_attachments":[{"alias":"workdir", "working_directory_id":workdir}],
|
||||
"feature_connections":{}, "control_operation_id":format!("parallel-{label}-control"),
|
||||
"execution_grant":{"permissions":permissions, "redelegation":[]}});
|
||||
cases.push(Case {
|
||||
subject,
|
||||
ticket: ticket.id,
|
||||
branch,
|
||||
workdir,
|
||||
root,
|
||||
source,
|
||||
delegation,
|
||||
content,
|
||||
permissions,
|
||||
change: policy.change_id,
|
||||
request,
|
||||
});
|
||||
}
|
||||
assert_ne!(cases[0].subject, cases[1].subject);
|
||||
assert_ne!(cases[0].ticket, cases[1].ticket);
|
||||
assert_ne!(cases[0].branch, cases[1].branch);
|
||||
assert_ne!(cases[0].workdir, cases[1].workdir);
|
||||
assert_ne!(cases[0].root, cases[1].root);
|
||||
assert_ne!(cases[0].source, cases[1].source);
|
||||
assert!(
|
||||
api.store
|
||||
.current_worker_singleton_owner(
|
||||
TEST_WORKSPACE_ID,
|
||||
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers");
|
||||
let (alpha, beta) = tokio::join!(
|
||||
connected_authorization_post(
|
||||
&f.backend_url,
|
||||
&f.identity,
|
||||
&cases[0].source,
|
||||
&path,
|
||||
&cases[0].request
|
||||
),
|
||||
connected_authorization_post(
|
||||
&f.backend_url,
|
||||
&f.identity,
|
||||
&cases[1].source,
|
||||
&path,
|
||||
&cases[1].request
|
||||
),
|
||||
);
|
||||
let mut children = Vec::new();
|
||||
for (case, (status, child)) in cases.iter().zip([alpha, beta]) {
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"signed Ticket spawn: {child}; Runtime: {:?}",
|
||||
f.runtime_errors.lock().unwrap()
|
||||
);
|
||||
let child = RuntimeWorkerRef::new(
|
||||
child["runtime_id"].as_str().unwrap(),
|
||||
child["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
let grant = subjects
|
||||
.worker_execution_grant(&child.runtime_id, &child.worker_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(grant.subject_id, case.subject);
|
||||
assert_eq!(grant.origin_worker_id, case.source.worker_id);
|
||||
assert_eq!(grant.source_change_id, case.change);
|
||||
assert_eq!(grant.permissions, case.permissions);
|
||||
assert!(grant.redelegation.is_empty());
|
||||
assert!(
|
||||
api.store
|
||||
.require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &child)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let assigned = api
|
||||
.store
|
||||
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &case.ticket)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(assigned.worker, child);
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &child)
|
||||
.unwrap();
|
||||
assert_eq!(links.len(), 1);
|
||||
assert_eq!(links[0].workdir_id, case.workdir);
|
||||
children.push(child);
|
||||
}
|
||||
assert_ne!(children[0], children[1]);
|
||||
let mut completions = std::collections::BTreeMap::new();
|
||||
for _ in 0..2 {
|
||||
let (ticket, results) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(45), completion.recv())
|
||||
.await
|
||||
.expect("both barrier-overlapped Controllers must finish")
|
||||
.expect("model completion");
|
||||
assert_eq!(
|
||||
results.len(),
|
||||
4,
|
||||
"Read, Write, Read, persisted result: {results:?}"
|
||||
);
|
||||
assert!(
|
||||
completions.insert(ticket, results).is_none(),
|
||||
"each Ticket completes once"
|
||||
);
|
||||
}
|
||||
let saved = api.config_store.with_conn(|conn| {
|
||||
let mut statement = conn.prepare("SELECT ticket_id, worker_id, result_id FROM ticket_implementation_results WHERE workspace_id=?1 ORDER BY ticket_id")?;
|
||||
Ok(statement.query_map([TEST_WORKSPACE_ID], |row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?, row.get::<_,String>(2)?)))?
|
||||
.collect::<std::result::Result<Vec<_>,_>>()?)
|
||||
}).unwrap();
|
||||
assert_eq!(saved.len(), 2);
|
||||
assert_ne!(saved[0].2, saved[1].2);
|
||||
for (i, case) in cases.iter().enumerate() {
|
||||
assert!(completions.contains_key(&case.ticket));
|
||||
assert_eq!(
|
||||
fs::read_to_string(case.root.join("README.md")).unwrap(),
|
||||
case.content
|
||||
);
|
||||
let branch = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&case.root)
|
||||
.args(["branch", "--show-current"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(branch.status.success());
|
||||
assert_eq!(
|
||||
String::from_utf8(branch.stdout).unwrap().trim(),
|
||||
case.branch
|
||||
);
|
||||
let row = saved.iter().find(|row| row.0 == case.ticket).unwrap();
|
||||
assert_eq!(row.1, children[i].worker_id);
|
||||
let receipt = api
|
||||
.config_store
|
||||
.implementation_result(TEST_WORKSPACE_ID, &case.ticket, &row.2)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.request.body,
|
||||
format!("Verified README.md contains {}", case.content)
|
||||
);
|
||||
assert!(
|
||||
api.config_store
|
||||
.implementation_result(TEST_WORKSPACE_ID, &cases[1 - i].ticket, &row.2)
|
||||
.is_err(),
|
||||
"the other Ticket cannot read this result"
|
||||
);
|
||||
let current = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &case.ticket)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_eq!(current.assignment_id, case.delegation);
|
||||
assert_eq!(
|
||||
current.principal,
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Subject {
|
||||
subject_id: case.subject.clone()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
current.tracking.unwrap().status,
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting,
|
||||
"child result is not a Subject self-report"
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&case.ticket).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
);
|
||||
// Live Host policy also rejects crossing the other Subject's Workdir.
|
||||
let mut other = case.permissions[0].clone();
|
||||
other.resource = PermissionResource::Workdir {
|
||||
working_directory_id: cases[1 - i].workdir.clone(),
|
||||
path: "README.md".into(),
|
||||
recursive: false,
|
||||
};
|
||||
let (status, body) = connected_authorization_post(
|
||||
&f.backend_url,
|
||||
&f.identity,
|
||||
&children[i],
|
||||
&authorization_path(),
|
||||
&serde_json::to_value(other).unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"cross-Subject Workdir: {body}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(f._dir.path().join("README.md")).unwrap(),
|
||||
"clean\n",
|
||||
"distinct runtime Workdirs must not mutate the repository source"
|
||||
);
|
||||
}
|
||||
|
||||
fn attach_authorization_metadata(
|
||||
f: &Fixture,
|
||||
worker: &RuntimeWorkerRef,
|
||||
|
||||
@@ -0,0 +1,994 @@
|
||||
//! T778's Ticket consumer on the selected T803 public delegation contract.
|
||||
//! Only the external model is scripted. Shared signed Backend/Runtime harness,
|
||||
//! Controllers, public Tools, policy/grant ledgers, Workdirs and results are real.
|
||||
//! Acceptance alone is not execution: completion requires Read/Write/Read/result
|
||||
//! and an actual public Notify reply observed by the originating model.
|
||||
use super::*;
|
||||
use agen::llm_client::{
|
||||
event::{Event, StopReason},
|
||||
types::{ContentPart, Item},
|
||||
};
|
||||
use worker_runtime::worker_backend::RuntimeWorkerFactory;
|
||||
|
||||
const ORIGIN: &str = "t778-public-origin:";
|
||||
const CHILD: &str = "t778-public-child:";
|
||||
const REPLY: &str = "T778_PUBLIC_REPLY:";
|
||||
const REVOCATION: &str = "t778-public-revocation:";
|
||||
|
||||
fn operation(name: &str) -> ScopedPermission {
|
||||
ScopedPermission {
|
||||
operation: name.into(),
|
||||
resource: PermissionResource::WorkerOperation {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn collection(name: &str) -> ScopedPermission {
|
||||
ScopedPermission {
|
||||
operation: name.into(),
|
||||
resource: PermissionResource::Workspace {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn ticket_permission(ticket: &str, name: &str) -> ScopedPermission {
|
||||
ScopedPermission {
|
||||
operation: name.into(),
|
||||
resource: PermissionResource::Ticket {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn file_permissions(workdir: &str) -> Vec<ScopedPermission> {
|
||||
["filesystem.read", "filesystem.write"]
|
||||
.into_iter()
|
||||
.map(|operation| ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Workdir {
|
||||
working_directory_id: workdir.into(),
|
||||
path: "README.md".into(),
|
||||
recursive: false,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn config(request: &agen::llm_client::Request, prefix: &str) -> Option<Value> {
|
||||
request.items.iter().find_map(|item| {
|
||||
let Item::Message { content, .. } = item else {
|
||||
return None;
|
||||
};
|
||||
content.iter().find_map(|part| match part {
|
||||
// Notify is rendered through the production notify_wrapper prompt;
|
||||
// parse the first JSON value after our marker, not the wrapper suffix.
|
||||
ContentPart::Text { text } => text.find(prefix).map(|offset| {
|
||||
serde_json::Deserializer::from_str(&text[offset + prefix.len()..])
|
||||
.into_iter::<Value>()
|
||||
.next()
|
||||
.expect("script marker JSON")
|
||||
.expect("valid script marker JSON")
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
fn tool_result(request: &agen::llm_client::Request, id: &str) -> Option<(bool, String)> {
|
||||
request.items.iter().find_map(|item| match item {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
is_error,
|
||||
content,
|
||||
summary,
|
||||
..
|
||||
} if call_id == id => Some((
|
||||
*is_error,
|
||||
content.clone().unwrap_or_else(|| summary.clone()),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
fn success(request: &agen::llm_client::Request, id: &str) -> Option<Value> {
|
||||
tool_result(request, id).map(|(error, content)| {
|
||||
assert!(!error, "public {id} refused: {content}");
|
||||
serde_json::from_str(&content).expect("public JSON Tool result")
|
||||
})
|
||||
}
|
||||
fn refused(request: &agen::llm_client::Request, id: &str) -> bool {
|
||||
tool_result(request, id).is_some_and(|(error, content)| {
|
||||
assert!(error, "public {id} must refuse: {content}");
|
||||
true
|
||||
})
|
||||
}
|
||||
fn call(request: &agen::llm_client::Request, id: &str, name: &str, input: Value) -> Vec<Event> {
|
||||
assert!(
|
||||
request.tools.iter().any(|tool| tool.name == name),
|
||||
"Profile must expose {name}; absence is not authorization evidence"
|
||||
);
|
||||
vec![
|
||||
Event::tool_use_start(0, id, name),
|
||||
Event::tool_input_delta(0, input.to_string()),
|
||||
Event::tool_use_stop(0),
|
||||
]
|
||||
}
|
||||
fn done() -> Vec<Event> {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "scripted public Ticket turn finished"),
|
||||
Event::text_block_stop(0, Some(StopReason::EndTurn)),
|
||||
]
|
||||
}
|
||||
fn target(worker: &RuntimeWorkerRef) -> Value {
|
||||
json!({"kind":"runtime_worker", "runtime_id":worker.runtime_id, "worker_id":worker.worker_id})
|
||||
}
|
||||
|
||||
fn notifications(snapshot: &protocol::SessionSnapshot) -> Vec<&str> {
|
||||
// Outgoing refused ToolCall arguments are ALSO canonical history. They are
|
||||
// not evidence that the destination accepted an incoming notification.
|
||||
snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.data {
|
||||
protocol::SessionSnapshotEntryData::SystemItem {
|
||||
item_kind, content, ..
|
||||
} if item_kind == "notification" => Some(content.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PublicTicketModel {
|
||||
admission: Arc<tokio::sync::Barrier>,
|
||||
overlap: Arc<tokio::sync::Barrier>,
|
||||
replied: Arc<Mutex<std::collections::BTreeMap<String, Arc<tokio::sync::Notify>>>>,
|
||||
completed: tokio::sync::mpsc::Sender<Value>,
|
||||
recorded: Arc<Mutex<std::collections::BTreeSet<String>>>,
|
||||
}
|
||||
impl PublicTicketModel {
|
||||
async fn origin(&self, request: &agen::llm_client::Request, input: Value) -> Vec<Event> {
|
||||
let ticket = input["ticket"].as_str().unwrap();
|
||||
// These are ordinary public Tool calls. No signed helper substitutes for
|
||||
// the subject Controller's Spawn authorization or WorkerNotify dispatch.
|
||||
for denial in input["denials"].as_array().unwrap() {
|
||||
let id = denial["id"].as_str().unwrap();
|
||||
if !refused(request, id) {
|
||||
return call(request, id, "WorkerSpawn", denial["input"].clone());
|
||||
}
|
||||
if let Some(reason) = denial["reason"].as_str() {
|
||||
assert!(
|
||||
tool_result(request, id).unwrap().1.contains(reason),
|
||||
"{id} must exercise the intended {reason} fence, not another refusal: {:?}",
|
||||
tool_result(request, id)
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(spawn) = success(request, "public-ticket-spawn") else {
|
||||
// Neither peer may acquire its valid Ticket execution assignment
|
||||
// before both wrong-assignee probes reach the intended Subject fence.
|
||||
tokio::time::timeout(std::time::Duration::from_secs(40), self.admission.wait())
|
||||
.await
|
||||
.expect("both public refusal sequences completed");
|
||||
return call(
|
||||
request,
|
||||
"public-ticket-spawn",
|
||||
"WorkerSpawn",
|
||||
input["spawn"].clone(),
|
||||
);
|
||||
};
|
||||
let Some(replay) = success(request, "public-ticket-replay") else {
|
||||
// Exact same caller intent and full public input, not a new operation.
|
||||
return call(
|
||||
request,
|
||||
"public-ticket-replay",
|
||||
"WorkerSpawn",
|
||||
input["spawn"].clone(),
|
||||
);
|
||||
};
|
||||
assert_eq!(spawn["runtime_id"], replay["runtime_id"]);
|
||||
assert_eq!(spawn["worker_id"], replay["worker_id"]);
|
||||
for (id, subject) in [
|
||||
("public-cross-subject-notify", input["other_origin"].clone()),
|
||||
(
|
||||
"public-invalid-target-notify",
|
||||
json!({"kind":"runtime_worker", "runtime_id":CONNECTED_RUNTIME,"worker_id":"t778-unknown-target"}),
|
||||
),
|
||||
] {
|
||||
if !refused(request, id) {
|
||||
return call(
|
||||
request,
|
||||
id,
|
||||
"WorkerNotify",
|
||||
json!({"subject":subject,"content":"T778_MUST_NOT_ACCEPT_FOREIGN_NOTIFY"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
let messages = request
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::Message { content, .. } => Some(serde_json::to_string(content).unwrap()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if let Some(reply) = config(request, REPLY) {
|
||||
assert_eq!(
|
||||
reply["ticket"], ticket,
|
||||
"another Ticket's reply cannot finish this body"
|
||||
);
|
||||
let Some(evaluated) = success(request, "public-origin-evaluate-result") else {
|
||||
return call(
|
||||
request,
|
||||
"public-origin-evaluate-result",
|
||||
"TicketImplementationResultQuery",
|
||||
json!({"ticket":ticket,"result_id":reply["result_id"]}),
|
||||
);
|
||||
};
|
||||
assert_eq!(evaluated["ticket_id"], ticket);
|
||||
assert_eq!(evaluated["result_id"], reply["result_id"]);
|
||||
assert_eq!(
|
||||
evaluated["request"]["body"],
|
||||
format!(
|
||||
"Public tools verified {}",
|
||||
input["child"]["content"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
if self.recorded.lock().unwrap().insert(ticket.into()) {
|
||||
self.completed
|
||||
.try_send(
|
||||
json!({"ticket":ticket,"spawn":spawn,"replay":replay,"messages":messages,"evaluated":evaluated}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
return done();
|
||||
}
|
||||
if let Some(ping) = success(request, "public-ticket-ping") {
|
||||
assert_eq!(ping["state"], "accepted", "{ping}");
|
||||
let signal = self.replied.lock().unwrap().get(ticket).unwrap().clone();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(40), signal.notified())
|
||||
.await
|
||||
.expect("real child public reply result");
|
||||
return done();
|
||||
}
|
||||
call(
|
||||
request,
|
||||
"public-ticket-ping",
|
||||
"WorkerNotify",
|
||||
json!({
|
||||
"subject":{"kind":"runtime_worker", "runtime_id":spawn["runtime_id"],"worker_id":spawn["worker_id"]},
|
||||
"content":format!("{CHILD}{}",input["child"])
|
||||
}),
|
||||
)
|
||||
}
|
||||
async fn child(&self, request: &agen::llm_client::Request, input: Value) -> Vec<Event> {
|
||||
let ticket = input["ticket"].as_str().unwrap();
|
||||
if let Some(reply) = success(request, "public-child-reply") {
|
||||
assert_eq!(reply["state"], "accepted", "{reply}");
|
||||
self.replied
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(ticket)
|
||||
.unwrap()
|
||||
.notify_one();
|
||||
return done();
|
||||
}
|
||||
if let Some(receipt) = success(request, "public-child-result") {
|
||||
if !refused(request, "public-child-foreign-result") {
|
||||
return call(
|
||||
request,
|
||||
"public-child-foreign-result",
|
||||
"TicketImplementationResult",
|
||||
json!({"ticket":input["other_ticket"],"kind":"result","body":"T778_MUST_NOT_SAVE_FOREIGN_RESULT"}),
|
||||
);
|
||||
}
|
||||
if !refused(request, "public-child-foreign-notify") {
|
||||
return call(
|
||||
request,
|
||||
"public-child-foreign-notify",
|
||||
"WorkerNotify",
|
||||
json!({"subject":input["other_origin"],"content":"T778_MUST_NOT_ACCEPT_FOREIGN_NOTIFY"}),
|
||||
);
|
||||
}
|
||||
return call(
|
||||
request,
|
||||
"public-child-reply",
|
||||
"WorkerNotify",
|
||||
json!({"subject":input["origin"],"content":format!("{REPLY}{}",json!({"ticket":ticket,"result_id":receipt["result_id"]}))}),
|
||||
);
|
||||
}
|
||||
if let Some((error, reread)) = tool_result(request, "public-child-reread") {
|
||||
assert!(!error, "post-write Read: {reread}");
|
||||
assert!(
|
||||
reread.contains(input["content"].as_str().unwrap()),
|
||||
"{reread}"
|
||||
);
|
||||
return call(
|
||||
request,
|
||||
"public-child-result",
|
||||
"TicketImplementationResult",
|
||||
json!({"ticket":ticket,"kind":"result","body":format!("Public tools verified {}",input["content"].as_str().unwrap())}),
|
||||
);
|
||||
}
|
||||
if let Some((error, content)) = tool_result(request, "public-child-write") {
|
||||
assert!(!error, "real Write: {content}");
|
||||
return call(
|
||||
request,
|
||||
"public-child-reread",
|
||||
"Read",
|
||||
json!({"target_workdir":"workdir","file_path":"README.md"}),
|
||||
);
|
||||
}
|
||||
if let Some((error, read)) = tool_result(request, "public-child-read") {
|
||||
assert!(
|
||||
!error && read.contains("clean"),
|
||||
"actual initial Read: {read}"
|
||||
);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(40), self.overlap.wait())
|
||||
.await
|
||||
.expect("two public delegated Controllers overlap after Read");
|
||||
return call(
|
||||
request,
|
||||
"public-child-write",
|
||||
"Write",
|
||||
json!({"target_workdir":"workdir","file_path":"README.md","content":input["content"]}),
|
||||
);
|
||||
}
|
||||
if !refused(request, "public-child-private-read") {
|
||||
return call(
|
||||
request,
|
||||
"public-child-private-read",
|
||||
"Read",
|
||||
json!({"target_workdir":"workdir","file_path":"private.txt"}),
|
||||
);
|
||||
}
|
||||
call(
|
||||
request,
|
||||
"public-child-read",
|
||||
"Read",
|
||||
json!({"target_workdir":"workdir","file_path":"README.md"}),
|
||||
)
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl agen::llm_client::LlmClient for PublicTicketModel {
|
||||
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(
|
||||
&self,
|
||||
request: agen::llm_client::Request,
|
||||
) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError>
|
||||
{
|
||||
let events = if let Some(input) = config(&request, REVOCATION) {
|
||||
if !refused(&request, "revoked-public-spawn") {
|
||||
call(
|
||||
&request,
|
||||
"revoked-public-spawn",
|
||||
"WorkerSpawn",
|
||||
input["spawn"].clone(),
|
||||
)
|
||||
} else if !refused(&request, "revoked-public-notify") {
|
||||
call(
|
||||
&request,
|
||||
"revoked-public-notify",
|
||||
"WorkerNotify",
|
||||
json!({
|
||||
"subject":input["child"],"content":"T778_MUST_NOT_ACCEPT_REVOKED_NOTIFY"
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
let key = format!("revocation:{}", input["ticket"].as_str().unwrap());
|
||||
if self.recorded.lock().unwrap().insert(key) {
|
||||
self.completed
|
||||
.try_send(json!({"revocation":input["ticket"]}))
|
||||
.unwrap();
|
||||
}
|
||||
done()
|
||||
}
|
||||
} else if let Some(input) = config(&request, ORIGIN) {
|
||||
self.origin(&request, input).await
|
||||
} else if let Some(input) = config(&request, CHILD) {
|
||||
self.child(&request, input).await
|
||||
} else {
|
||||
// Normal startup/assignment lifecycle input is not the child script.
|
||||
done()
|
||||
};
|
||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
}
|
||||
|
||||
fn install_public_ticket_profiles(api: &WorkspaceApi) {
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let main = state
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("main.dcdl").unwrap())
|
||||
.unwrap();
|
||||
let content = r#"{ profile = { default_profile = "project:public-ticket-origin"; entries = [
|
||||
{ selector = "project:public-ticket-origin"; label = "Ticket Subject origin"; description = "Public Ticket consumer";
|
||||
profile = (import "$builtin/profiles/companion.dcdl") // { slug = "public-ticket-origin"; scope = "workspace_read"; delegation_scope = "workspace_read"; feature = { worker = { enabled = true; direct_spawn = true; }; }; };
|
||||
},
|
||||
{ selector = "project:public-ticket-editor"; label = "Ticket delegated editor"; description = "Explicit grants only";
|
||||
profile = (import "$builtin/profiles/coder.dcdl") // { slug = "public-ticket-editor"; scope = "workspace_write"; feature = { subjektiv = { enabled = false; }; worker = { enabled = true; direct_spawn = false; }; }; };
|
||||
}
|
||||
]; }; }"#;
|
||||
commit_workspace_config_tree(
|
||||
api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&ConfigCommitRequest {
|
||||
base_digest: state.snapshot.digest.clone(),
|
||||
entrypoints: state.contract.entrypoints.clone(),
|
||||
changes: vec![config_source::ConfigTreeChange::Update {
|
||||
path: main.path.clone(),
|
||||
expected_digest: main.content_digest.clone(),
|
||||
content: content.into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn public_two_subject_ticket_spawn_notify_edit_result_retry_and_fences() {
|
||||
use worker_runtime::working_directory::WorkingDirectoryMaterializer;
|
||||
struct Case {
|
||||
subject: String,
|
||||
ticket: String,
|
||||
assignment: String,
|
||||
branch: String,
|
||||
workdir: String,
|
||||
root: PathBuf,
|
||||
origin: RuntimeWorkerRef,
|
||||
permissions: Vec<ScopedPermission>,
|
||||
execution: Vec<ScopedPermission>,
|
||||
change: String,
|
||||
spawn: Value,
|
||||
content: String,
|
||||
}
|
||||
let (completed, mut completion) = tokio::sync::mpsc::channel(2);
|
||||
let replied = Arc::new(Mutex::new(std::collections::BTreeMap::new()));
|
||||
let harness = ConnectedWorkerHarness::start(PublicTicketModel {
|
||||
admission: Arc::new(tokio::sync::Barrier::new(2)),
|
||||
overlap: Arc::new(tokio::sync::Barrier::new(2)),
|
||||
replied: replied.clone(),
|
||||
completed,
|
||||
recorded: Arc::new(Mutex::new(std::collections::BTreeSet::new())),
|
||||
})
|
||||
.await;
|
||||
let api = &harness.api;
|
||||
install_public_ticket_profiles(api);
|
||||
let subjects = open_subjektiv_store(api).unwrap();
|
||||
// Deliberately permitted resource, but wrong Ticket branch. This separates a
|
||||
// target rejection from generic delegation-scope rejection.
|
||||
let (status, wrong) = harness.human(reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
|
||||
json!({"runtime_id":CONNECTED_RUNTIME,"repository_key":"test-repository","selector":"HEAD","operation_id":"public-ticket-wrong-target-workdir"})).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "{wrong}");
|
||||
let wrong_workdir = wrong["item"]["working_directory_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let mut cases = Vec::new();
|
||||
for label in ["alpha", "beta"] {
|
||||
let branch = format!("public-ticket-{label}");
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(harness._dir.path())
|
||||
.args(["branch", &branch])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let (status, created) = harness.human(reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
|
||||
json!({"runtime_id":CONNECTED_RUNTIME,"repository_key":"test-repository","selector":branch,"operation_id":format!("public-{label}-workdir")})).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "{created}");
|
||||
let workdir = created["item"]["working_directory_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let root = harness
|
||||
.materializer
|
||||
.bind_working_directory(&workdir, None)
|
||||
.unwrap()
|
||||
.root()
|
||||
.to_path_buf();
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&root)
|
||||
.args(["switch", "-c", &branch])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
fs::write(root.join("private.txt"), "PRIVATE_MUST_NOT_READ\n").unwrap();
|
||||
let subject = subjects
|
||||
.create_subject(
|
||||
crate::subjektiv::SubjectRole::new(format!("public-ticket-{label}")).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut input = NewTicket::new(format!("Public connected {label} Ticket"));
|
||||
set_test_ticket_target(&mut input, "test-repository", &branch);
|
||||
let ticket = browser_ticket_backend(api)
|
||||
.unwrap()
|
||||
.create(input)
|
||||
.unwrap()
|
||||
.id;
|
||||
replied
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(ticket.clone(), Arc::new(tokio::sync::Notify::new()));
|
||||
let mut permissions = file_permissions(&workdir);
|
||||
permissions.extend([
|
||||
ticket_permission(&ticket, "ticket.read"),
|
||||
ticket_permission(&ticket, "ticket.implementation_result.write"),
|
||||
operation("worker.messaging"),
|
||||
]);
|
||||
let mut delegation = permissions.clone();
|
||||
delegation.extend(file_permissions(&wrong_workdir));
|
||||
let mut execution: Vec<_> = [
|
||||
"session.record",
|
||||
"memory.resident.read",
|
||||
"memory.resident.supply",
|
||||
"journal.startup.read",
|
||||
"journal.startup.supply",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| subject_permission(&subject, name))
|
||||
.collect();
|
||||
execution.extend([
|
||||
collection("worker.create"),
|
||||
operation("worker.messaging"),
|
||||
ticket_permission(&ticket, "ticket.assignment.assign"),
|
||||
ticket_permission(&ticket, "ticket.read"),
|
||||
ticket_permission(&ticket, "ticket.implementation_result.read"),
|
||||
]);
|
||||
let (status, policy) = harness.human(reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||
json!({"operation_id":format!("public-{label}-policy"),"expected_change_id":null,"execution":execution,"delegation":delegation})).await;
|
||||
assert_eq!(status, StatusCode::OK, "{policy}");
|
||||
let change = subjects
|
||||
.subject_permissions(&subject)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id;
|
||||
let (status, source) = harness.human(reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||
json!({"runtime_id":CONNECTED_RUNTIME,"display_name":format!("{label} Ticket Subject"),"profile":"project:public-ticket-origin","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"{source}; Runtime: {:?}",
|
||||
harness.runtime_errors.lock().unwrap()
|
||||
);
|
||||
let origin = RuntimeWorkerRef::new(
|
||||
source["runtime_id"].as_str().unwrap(),
|
||||
source["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
let (status, assigned) = harness.human(reqwest::Method::PUT,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"),
|
||||
json!({"operation_id":format!("public-{label}-assign"),"expected_assignment_id":null,"principal":{"kind":"subject","subject_id":subject}})).await;
|
||||
assert_eq!(status, StatusCode::OK, "{assigned}");
|
||||
let assignment = assigned["assignment"]["assignment_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let content = format!("public connected {label} verified\n");
|
||||
let spawn = json!({"runtime_id":CONNECTED_RUNTIME,"profile":"project:public-ticket-editor","operation_id":format!("public-{label}-spawn"),
|
||||
"display_name":format!("{label} public Ticket editor"),"ticket_id":ticket,"orchestrator_assignment_id":assignment,
|
||||
"working_directory_id":workdir,"initial_submit":[],"execution_grant":{"permissions":permissions,"redelegation":[]}});
|
||||
cases.push(Case {
|
||||
subject,
|
||||
ticket,
|
||||
assignment,
|
||||
branch,
|
||||
workdir,
|
||||
root,
|
||||
origin,
|
||||
permissions,
|
||||
execution,
|
||||
change,
|
||||
spawn,
|
||||
content,
|
||||
});
|
||||
}
|
||||
assert_ne!(cases[0].subject, cases[1].subject);
|
||||
assert_ne!(cases[0].ticket, cases[1].ticket);
|
||||
assert_ne!(cases[0].workdir, cases[1].workdir);
|
||||
assert_ne!(cases[0].root, cases[1].root);
|
||||
// Permit the generic start operation on the peer Ticket as well. Its current
|
||||
// assigned Subject must STILL refuse this caller, independently of operation
|
||||
// permission and the caller's valid explicit child execution grant.
|
||||
for i in 0..2 {
|
||||
let peer_ticket = cases[1 - i].ticket.clone();
|
||||
let case = &mut cases[i];
|
||||
case.execution
|
||||
.push(ticket_permission(&peer_ticket, "ticket.assignment.assign"));
|
||||
let previous = subjects
|
||||
.subject_permissions(&case.subject)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (status, body) = harness.human(reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions", case.subject),
|
||||
json!({"operation_id":format!("public-peer-start-operation-{i}"), "expected_change_id":previous.change_id,
|
||||
"execution":case.execution,"delegation":previous.delegation})).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
case.change = subjects
|
||||
.subject_permissions(&case.subject)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id;
|
||||
}
|
||||
for (i, case) in cases.iter().enumerate() {
|
||||
let other = &cases[1 - i];
|
||||
let mut assignee = case.spawn.clone();
|
||||
assignee["operation_id"] = json!(format!("public-denied-assignee-{i}"));
|
||||
assignee["ticket_id"] = json!(other.ticket);
|
||||
assignee["orchestrator_assignment_id"] = json!(other.assignment);
|
||||
let mut wrong_target = case.spawn.clone();
|
||||
wrong_target["operation_id"] = json!(format!("public-denied-target-{i}"));
|
||||
wrong_target["working_directory_id"] = json!(wrong_workdir);
|
||||
let mut wrong_permissions = case.permissions[2..].to_vec();
|
||||
wrong_permissions.extend(file_permissions(&wrong_workdir));
|
||||
wrong_target["execution_grant"]["permissions"] = json!(wrong_permissions);
|
||||
let mut resource = case.spawn.clone();
|
||||
resource["operation_id"] = json!(format!("public-denied-resource-{i}"));
|
||||
let mut cross_permissions = case.permissions.clone();
|
||||
cross_permissions.extend(file_permissions(&other.workdir));
|
||||
resource["execution_grant"]["permissions"] = json!(cross_permissions);
|
||||
let input = json!({"ticket":case.ticket,"spawn":case.spawn,"other_origin":target(&other.origin),
|
||||
"denials":[{"id":"public-wrong-assignee-spawn","input":assignee,"reason":"target Subject"},
|
||||
{"id":"public-wrong-target-spawn","input":wrong_target,"reason":"selector"},
|
||||
{"id":"public-cross-resource-spawn","input":resource}],
|
||||
"child":{"ticket":case.ticket,"other_ticket":other.ticket,"content":case.content,
|
||||
"origin":target(&case.origin),"other_origin":target(&other.origin)}});
|
||||
// Human Notify only starts the already-created Subject body (also safe
|
||||
// while assignment attention is Busy). Child creation, retry, messaging
|
||||
// and editing below occur through public Tools.
|
||||
let (status, body) = harness
|
||||
.human(
|
||||
reqwest::Method::POST,
|
||||
&format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{}/workers/{}/input",
|
||||
case.origin.runtime_id, case.origin.worker_id
|
||||
),
|
||||
json!({"kind":"notify","content":format!("{ORIGIN}{input}")}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
}
|
||||
let mut observed = std::collections::BTreeMap::new();
|
||||
for _ in 0..2 {
|
||||
let value = tokio::time::timeout(std::time::Duration::from_secs(90), completion.recv())
|
||||
.await
|
||||
.expect("two real public Ticket roundtrips")
|
||||
.expect("model completion evidence");
|
||||
let ticket = value["ticket"].as_str().unwrap().to_owned();
|
||||
assert!(observed.insert(ticket, value).is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
harness.runtime.list_workers().unwrap().len(),
|
||||
4,
|
||||
"two bodies + exactly two children; public replay and refusals allocate no extra child"
|
||||
);
|
||||
let saved = api.config_store.with_conn(|conn| {
|
||||
let mut statement = conn.prepare("SELECT ticket_id,worker_id,result_id FROM ticket_implementation_results WHERE workspace_id=?1 ORDER BY ticket_id")?;
|
||||
Ok(statement.query_map([TEST_WORKSPACE_ID],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?,row.get::<_,String>(2)?)))?.collect::<std::result::Result<Vec<_>,_>>()?)
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
saved.len(),
|
||||
2,
|
||||
"foreign public result requests must not persist"
|
||||
);
|
||||
let mut children = Vec::new();
|
||||
for (i, case) in cases.iter().enumerate() {
|
||||
let spawn = &observed[&case.ticket]["spawn"];
|
||||
let child = RuntimeWorkerRef::new(
|
||||
spawn["runtime_id"].as_str().unwrap(),
|
||||
spawn["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
let grant = subjects
|
||||
.worker_execution_grant(&child.runtime_id, &child.worker_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(grant.subject_id, case.subject);
|
||||
assert_eq!(grant.origin_worker_id, case.origin.worker_id);
|
||||
assert_eq!(grant.source_change_id, case.change);
|
||||
assert_eq!(grant.permissions, case.permissions);
|
||||
assert!(grant.redelegation.is_empty());
|
||||
let forward = api
|
||||
.store
|
||||
.get_active_worker_control_grant(TEST_WORKSPACE_ID, &case.origin, &child)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(forward.permissions.contains(&"notify".into()));
|
||||
let reverse = api
|
||||
.store
|
||||
.get_active_worker_control_grant(TEST_WORKSPACE_ID, &child, &case.origin)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(reverse.permissions, vec!["notify".to_owned()]);
|
||||
assert!(
|
||||
api.store
|
||||
.get_active_worker_control_grant(TEST_WORKSPACE_ID, &child, &cases[1 - i].origin)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &case.origin)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &child)
|
||||
.unwrap();
|
||||
assert_eq!(links.len(), 1);
|
||||
assert_eq!(links[0].workdir_id, case.workdir);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &case.ticket)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.worker,
|
||||
child
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(case.root.join("README.md")).unwrap(),
|
||||
case.content
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(case.root.join("private.txt")).unwrap(),
|
||||
"PRIVATE_MUST_NOT_READ\n"
|
||||
);
|
||||
let branch = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&case.root)
|
||||
.args(["branch", "--show-current"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(branch.status.success());
|
||||
assert_eq!(
|
||||
String::from_utf8(branch.stdout).unwrap().trim(),
|
||||
case.branch
|
||||
);
|
||||
let row = saved.iter().find(|row| row.0 == case.ticket).unwrap();
|
||||
assert_eq!(
|
||||
observed[&case.ticket]["evaluated"]["result_id"], row.2,
|
||||
"origin evaluated the same canonical result that the child persisted"
|
||||
);
|
||||
assert_eq!(row.1, child.worker_id);
|
||||
let receipt = api
|
||||
.config_store
|
||||
.implementation_result(TEST_WORKSPACE_ID, &case.ticket, &row.2)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.request.body,
|
||||
format!("Public tools verified {}", case.content)
|
||||
);
|
||||
let current = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &case.ticket)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_eq!(current.assignment_id, case.assignment);
|
||||
assert_eq!(
|
||||
current.principal,
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Subject {
|
||||
subject_id: case.subject.clone()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
current.tracking.unwrap().status,
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting,
|
||||
"result is not Subject handling or case closure"
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&case.ticket).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
);
|
||||
let worker = worker_runtime::identity::WorkerRef::try_from(&child).unwrap();
|
||||
let snapshot = harness.factory.retained_session_snapshot(&worker).unwrap();
|
||||
let history = serde_json::to_string(&snapshot.snapshot).unwrap();
|
||||
let incoming = notifications(&snapshot.snapshot);
|
||||
assert!(
|
||||
incoming.iter().any(|content| content.contains(CHILD)),
|
||||
"real Idle Notify reached canonical notification history"
|
||||
);
|
||||
assert!(
|
||||
incoming
|
||||
.iter()
|
||||
.all(|content| !content.contains("T778_MUST_NOT_ACCEPT_FOREIGN_NOTIFY")),
|
||||
"foreign Notify must not become an incoming notification"
|
||||
);
|
||||
let origin_ref = worker_runtime::identity::WorkerRef::try_from(&case.origin).unwrap();
|
||||
let origin_snapshot = harness
|
||||
.factory
|
||||
.retained_session_snapshot(&origin_ref)
|
||||
.unwrap();
|
||||
let origin_incoming = notifications(&origin_snapshot.snapshot);
|
||||
assert!(
|
||||
origin_incoming
|
||||
.iter()
|
||||
.any(|content| content.contains(REPLY)),
|
||||
"reply reached the origin's canonical notification history"
|
||||
);
|
||||
assert!(
|
||||
origin_incoming
|
||||
.iter()
|
||||
.all(|content| !content.contains("T778_MUST_NOT_ACCEPT_FOREIGN_NOTIFY"))
|
||||
);
|
||||
assert!(
|
||||
!history.contains("PRIVATE_MUST_NOT_READ"),
|
||||
"denied private Read leaked no file bytes"
|
||||
);
|
||||
for kind in ["assignee", "resource"] {
|
||||
assert!(
|
||||
api.store
|
||||
.get_ticket_assignment_operation(
|
||||
TEST_WORKSPACE_ID,
|
||||
&format!("public-denied-{kind}-{i}")
|
||||
)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"public refused authority must not reserve Ticket operation"
|
||||
);
|
||||
}
|
||||
let failed = api
|
||||
.store
|
||||
.get_ticket_assignment_operation(
|
||||
TEST_WORKSPACE_ID,
|
||||
&format!("public-denied-target-{i}"),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("target validation follows durable start admission");
|
||||
assert_eq!(failed.claim_state, "failed");
|
||||
assert!(failed.assignment_id.is_none());
|
||||
assert!(
|
||||
failed.worker.is_none(),
|
||||
"target refusal precedes Runtime allocation"
|
||||
);
|
||||
children.push(child);
|
||||
}
|
||||
assert_ne!(children[0], children[1]);
|
||||
assert_eq!(
|
||||
fs::read_to_string(harness._dir.path().join("README.md")).unwrap(),
|
||||
"clean\n"
|
||||
);
|
||||
// Complementary final-dispatch checks use the SAME production connection,
|
||||
// after public end-to-end success. They are not sole public-tool evidence.
|
||||
let alpha = &cases[0];
|
||||
let child = &children[0];
|
||||
let forward = api
|
||||
.store
|
||||
.get_active_worker_control_grant(TEST_WORKSPACE_ID, &alpha.origin, child)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
api.store
|
||||
.revoke_worker_control_grant(
|
||||
TEST_WORKSPACE_ID,
|
||||
&forward.grant_id,
|
||||
&now_registry_timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
let (status, body) = connected_authorization_post(
|
||||
&harness.backend_url,
|
||||
&harness.identity,
|
||||
&alpha.origin,
|
||||
&format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/worker-control/workers/{}/{}/input",
|
||||
child.runtime_id, child.worker_id
|
||||
),
|
||||
&json!({"kind":"notify","content":"T778_MUST_NOT_ACCEPT_REVOKED_NOTIFY"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"revoked directional control: {body}"
|
||||
);
|
||||
let worker = worker_runtime::identity::WorkerRef::try_from(child).unwrap();
|
||||
assert!(
|
||||
!serde_json::to_string(
|
||||
&harness
|
||||
.factory
|
||||
.retained_session_snapshot(&worker)
|
||||
.unwrap()
|
||||
.snapshot
|
||||
)
|
||||
.unwrap()
|
||||
.contains("T778_MUST_NOT_ACCEPT_REVOKED_NOTIFY")
|
||||
);
|
||||
let previous = subjects
|
||||
.subject_permissions(&alpha.subject)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (status, body) = harness.human(reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions",alpha.subject),
|
||||
json!({"operation_id":"public-alpha-revoke-delegation","expected_change_id":previous.change_id,"execution":alpha.execution,"delegation":[]})).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let mut replay = json!({"runtime_id":CONNECTED_RUNTIME,"display_name":alpha.spawn["display_name"],"profile":alpha.spawn["profile"],
|
||||
"ticket_assignment":{"ticket_id":alpha.ticket,"operation_id":alpha.spawn["operation_id"],"orchestrator_assignment_id":alpha.assignment},
|
||||
"initial_submit":[],"workdir_attachments":[{"alias":"workdir","working_directory_id":alpha.workdir}],"feature_connections":{},
|
||||
"control_operation_id":alpha.spawn["operation_id"],"execution_grant":alpha.spawn["execution_grant"]});
|
||||
// Backend replay is exact to public serialization (relative_cwd defaults to null).
|
||||
replay["workdir_attachments"][0]["relative_cwd"] = Value::Null;
|
||||
let (status, body) = connected_authorization_post(
|
||||
&harness.backend_url,
|
||||
&harness.identity,
|
||||
&alpha.origin,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"),
|
||||
&replay,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"saved successful receipt cannot refresh revoked delegation: {body}"
|
||||
);
|
||||
// Re-enter the real origin Controller after revocation as well: both public
|
||||
// Spawn replay and Notify must consult current authority, not cached results.
|
||||
let input = json!({"ticket":alpha.ticket,"spawn":alpha.spawn,"child":target(child)});
|
||||
let (status, body) = harness
|
||||
.human(
|
||||
reqwest::Method::POST,
|
||||
&format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{}/workers/{}/input",
|
||||
alpha.origin.runtime_id, alpha.origin.worker_id
|
||||
),
|
||||
json!({"kind":"notify","content":format!("{REVOCATION}{input}")}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let revoked = tokio::time::timeout(std::time::Duration::from_secs(45), completion.recv())
|
||||
.await
|
||||
.expect("public current-authority re-evaluation")
|
||||
.expect("public revocation evidence");
|
||||
assert_eq!(revoked["revocation"], alpha.ticket);
|
||||
assert!(
|
||||
!serde_json::to_string(
|
||||
&harness
|
||||
.factory
|
||||
.retained_session_snapshot(&worker)
|
||||
.unwrap()
|
||||
.snapshot
|
||||
)
|
||||
.unwrap()
|
||||
.contains("T778_MUST_NOT_ACCEPT_REVOKED_NOTIFY")
|
||||
);
|
||||
assert_eq!(harness.runtime.list_workers().unwrap().len(), 4);
|
||||
assert!(
|
||||
subjects
|
||||
.worker_execution_grant(&child.runtime_id, &child.worker_id)
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"historical execution evidence retained, not current authority"
|
||||
);
|
||||
let calls = harness.authorizations.lock().unwrap();
|
||||
for name in [
|
||||
"worker.create",
|
||||
"worker.messaging",
|
||||
"filesystem.read",
|
||||
"filesystem.write",
|
||||
] {
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.any(|(request, status)| request["operation"] == name && *status == StatusCode::OK),
|
||||
"real common authorization {name}: {calls:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.filter(|(request, _)| request["operation"] == "worker.messaging")
|
||||
.all(|(request, _)| request["resource"]["kind"] == "worker_operation")
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ async fn attention_test_api() -> (
|
||||
) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let (api, execution) = assignment_test_api_with_recording_backend(dir.path()).await;
|
||||
attention_assign_ticket(&api, "Attention backlog");
|
||||
(dir, api, execution)
|
||||
}
|
||||
@@ -25,18 +25,214 @@ fn attention_pending(api: &WorkspaceApi) -> Vec<crate::store::TicketAssignmentDe
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn start_attention_test_orchestrator(
|
||||
// Operational fixtures use Subject Feature storage, never a legacy agent alias.
|
||||
fn ensure_test_assignment_subject(api: &WorkspaceApi) -> String {
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
if let Some(subject) = store
|
||||
.list_subjects(100)
|
||||
.unwrap()
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|subject| subject.role.as_str() == "assignment-fixture")
|
||||
{
|
||||
return subject.id;
|
||||
}
|
||||
store
|
||||
.create_subject_with_behavior(
|
||||
crate::subjektiv::SubjectRole::new("assignment-fixture").unwrap(),
|
||||
"Handle only the assigned Ticket; distinguish delivery from processing.".into(),
|
||||
)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
fn test_assignment_principal(api: &WorkspaceApi) -> serde_json::Value {
|
||||
serde_json::json!({"kind":"subject", "subject_id":ensure_test_assignment_subject(api)})
|
||||
}
|
||||
|
||||
fn grant_test_assignment_ticket(api: &WorkspaceApi, subject_id: &str, ticket_id: &str) {
|
||||
grant_test_ticket_operations(
|
||||
api,
|
||||
subject_id,
|
||||
ticket_id,
|
||||
&[
|
||||
"ticket.read",
|
||||
"ticket.assignment.query",
|
||||
"ticket.assignment.history",
|
||||
"ticket.implementation_result.write",
|
||||
"ticket.assignment.assign",
|
||||
"ticket.assignment.clear",
|
||||
"ticket.assignment.answer",
|
||||
"ticket.assignment.update",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn grant_test_ticket_operations(
|
||||
api: &WorkspaceApi,
|
||||
) -> BrowserWorkspaceOrchestratorResponse {
|
||||
scoped_start_workspace_orchestrator(
|
||||
subject_id: &str,
|
||||
ticket_id: &str,
|
||||
operations: &[&str],
|
||||
) {
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
let previous = store.subject_permissions(subject_id).unwrap();
|
||||
let mut execution = previous
|
||||
.as_ref()
|
||||
.map(|p| p.execution.clone())
|
||||
.unwrap_or_default();
|
||||
for operation in operations {
|
||||
let grant = ScopedPermission {
|
||||
operation: (*operation).into(),
|
||||
resource: PermissionResource::Ticket {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket_id.into(),
|
||||
},
|
||||
};
|
||||
if !execution.contains(&grant) {
|
||||
execution.push(grant);
|
||||
}
|
||||
}
|
||||
if previous.as_ref().is_some_and(|p| p.execution == execution) {
|
||||
return;
|
||||
}
|
||||
store
|
||||
.set_subject_permissions(
|
||||
subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: Uuid::now_v7().to_string(),
|
||||
expected_change_id: previous.as_ref().map(|p| p.change_id.clone()),
|
||||
execution,
|
||||
delegation: previous.map(|p| p.delegation).unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn create_test_assignment_body(
|
||||
api: &WorkspaceApi,
|
||||
runtime_id: &str,
|
||||
subject_id: &str,
|
||||
) -> RuntimeWorkerRef {
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
HeaderMap::new(),
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: runtime_id.into(),
|
||||
display_name: "Assignment Subject".into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:companion".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
|
||||
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
|
||||
subject_id: subject_id.into(),
|
||||
}),
|
||||
},
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.unwrap();
|
||||
RuntimeWorkerRef::new(created.runtime_id, created.worker_id)
|
||||
}
|
||||
|
||||
fn test_assignment_body(api: &WorkspaceApi) -> Option<InternalWorkerSummary> {
|
||||
let key = crate::subjektiv::subject_worker_singleton_key(&ensure_test_assignment_subject(api))
|
||||
.unwrap();
|
||||
let owner = api
|
||||
.store
|
||||
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &key)
|
||||
.unwrap()?;
|
||||
api.runtime.worker(&owner.worker).ok()
|
||||
}
|
||||
|
||||
async fn start_attention_test_orchestrator(
|
||||
api: &WorkspaceApi,
|
||||
) -> BrowserWorkspaceOrchestratorResponse {
|
||||
// Serialize fixture callers, but exercise normal Subject create/restore.
|
||||
// There is no legacy launch, synthetic singleton or alternate executor.
|
||||
let api = api.clone();
|
||||
api.host_executors
|
||||
.host()
|
||||
.unwrap()
|
||||
.run(move || {
|
||||
let guard = api.orchestrator_spawn_lock.lock().unwrap();
|
||||
seed_test_delivery_binding(&api);
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let (worker, disposition) = if let Some(existing) = test_assignment_body(&api) {
|
||||
if workspace_orchestrator_is_online(&existing) {
|
||||
(existing, "existing")
|
||||
} else {
|
||||
let restored =
|
||||
restore_internal_observed_worker(&api, &existing, "subject-restore".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.state,
|
||||
server_api::WorkerRestoreState::Accepted,
|
||||
"{:?}",
|
||||
restored.diagnostics
|
||||
);
|
||||
(api.runtime.worker(&existing.worker).unwrap(), "restored")
|
||||
}
|
||||
} else {
|
||||
let Json(created) =
|
||||
futures::executor::block_on(create_workspace_worker_authenticated_on_host(
|
||||
api.clone(),
|
||||
HeaderMap::new(),
|
||||
CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(),
|
||||
display_name: "Assignment Subject".into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:companion".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachments: vec![],
|
||||
feature_connections:
|
||||
server_api::WorkspaceWorkerFeatureConnectionsRequest {
|
||||
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
|
||||
subject_id,
|
||||
}),
|
||||
},
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
(
|
||||
api.runtime
|
||||
.worker(&RuntimeWorkerRef::new(
|
||||
created.runtime_id,
|
||||
created.worker_id,
|
||||
))
|
||||
.unwrap(),
|
||||
"created",
|
||||
)
|
||||
};
|
||||
if worker.worker.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||
// Bind the canonical Session before dispatch. The deterministic
|
||||
// executor uses pending metadata; scripted Controllers retain
|
||||
// their already-created active Session, never a replacement ID.
|
||||
bootstrap_test_assignment_session(&api, &worker.worker);
|
||||
}
|
||||
drop(guard);
|
||||
dispatch_orchestrator_assignment_attention(&api, &worker.worker);
|
||||
Ok::<_, ApiError>(BrowserWorkspaceOrchestratorResponse {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
online: workspace_orchestrator_is_online(&worker),
|
||||
disposition: disposition.into(),
|
||||
worker: Some(worker_launch_worker_summary(worker)),
|
||||
diagnostics: vec![],
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -191,6 +387,7 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
|
||||
std::thread::scope(|scope| {
|
||||
let api_for_caller = api.clone();
|
||||
let worker_for_caller = worker.clone();
|
||||
let acknowledged_for_caller = acknowledged.request_id.clone();
|
||||
let caller = scope.spawn(move || {
|
||||
let mut checks = 0;
|
||||
with_orchestrator_attention_reservation(
|
||||
@@ -215,7 +412,7 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
|
||||
// but the exact same production reservation still exists.
|
||||
assert_eq!(
|
||||
state.lock().unwrap().accepted.as_ref().unwrap().request_id,
|
||||
acknowledged.request_id
|
||||
acknowledged_for_caller
|
||||
);
|
||||
entered_tx.send(()).unwrap();
|
||||
release_rx
|
||||
@@ -235,19 +432,31 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
|
||||
let restored = futures::executor::block_on(start_attention_test_orchestrator(&api));
|
||||
assert_eq!(restored.disposition, "restored");
|
||||
let deferred = state.lock().unwrap().input_recheck;
|
||||
let old_retired = state.lock().unwrap().accepted.is_none();
|
||||
let acknowledged_id = state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.accepted
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.request_id
|
||||
.clone();
|
||||
let inputs_before_release = execution.take_inputs();
|
||||
release_tx.send(()).unwrap();
|
||||
caller.join().unwrap();
|
||||
assert!(deferred);
|
||||
assert!(old_retired);
|
||||
// Generic Subject restore preserves the local success cache; the
|
||||
// durable delivery ACK, not cache retirement, prevents a resend.
|
||||
assert_eq!(acknowledged_id, acknowledged.request_id);
|
||||
assert!(inputs_before_release.is_empty());
|
||||
});
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
assert!(execution.take_input_request_ids().is_empty());
|
||||
assert!(attention_pending(&api).is_empty());
|
||||
let state = state.lock().unwrap();
|
||||
assert!(state.accepted.is_none());
|
||||
assert_eq!(
|
||||
state.accepted.as_ref().unwrap().request_id,
|
||||
acknowledged.request_id
|
||||
);
|
||||
assert!(!state.sending);
|
||||
assert!(!state.input_recheck);
|
||||
}
|
||||
@@ -307,8 +516,38 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() {
|
||||
let pending = state.lock().unwrap().pending.clone().unwrap();
|
||||
let attempts = execution.take_inputs();
|
||||
let ids = execution.take_input_request_ids();
|
||||
assert_eq!(attempts.len(), 2);
|
||||
assert_eq!(ids, vec![Some(pending.request_id.clone()); 2]);
|
||||
// Subject creation can trigger several ordinary delivery scans. Every
|
||||
// unknown-outcome attempt must preserve the exact request and payload.
|
||||
assert!(!attempts.is_empty());
|
||||
assert!(attempts.iter().all(|attempt| attempt.1 == pending.content));
|
||||
assert_eq!(ids, vec![Some(pending.request_id.clone()); attempts.len()]);
|
||||
dispatch_orchestrator_assignment_attention(&api, &worker);
|
||||
let explicit_retry = execution.take_inputs();
|
||||
assert!(!explicit_retry.is_empty());
|
||||
assert!(
|
||||
explicit_retry
|
||||
.iter()
|
||||
.all(|attempt| attempt.1 == pending.content)
|
||||
);
|
||||
assert_eq!(
|
||||
execution.take_input_request_ids(),
|
||||
vec![Some(pending.request_id.clone()); explicit_retry.len()]
|
||||
);
|
||||
// Complement the known-ACK cached resend regression below. An uncertain
|
||||
// transport outcome has NO durable acceptance, so the existing request must
|
||||
// remain retryable. Persistent Runtime receipt recovery is exercised by the
|
||||
// notification delivery fixture rather than reimplemented by this adapter.
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let pending_acks = || {
|
||||
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, pending.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
|
||||
};
|
||||
let pinned_bytes = || {
|
||||
conn.query_row("SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, pending.input.delivery_id], |row| row.get::<_, String>(0)).unwrap()
|
||||
};
|
||||
assert_eq!(pending_acks(), 0);
|
||||
assert_eq!(pinned_bytes(), pending.content);
|
||||
// Change the backlog before retry: uncertain acceptance must never reuse
|
||||
// the ID for a newly rendered payload.
|
||||
attention_assign_ticket(&api, "Changed backlog");
|
||||
@@ -327,7 +566,9 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() {
|
||||
assert!(retry[1].1.contains("Changed backlog"));
|
||||
let ids = execution.take_input_request_ids();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert_eq!(ids[0], Some(pending.request_id));
|
||||
assert_eq!(ids[0], Some(pending.request_id.clone()));
|
||||
assert_eq!(pending_acks(), 1);
|
||||
assert_eq!(pinned_bytes(), pending.content);
|
||||
assert_ne!(ids[1], ids[0]);
|
||||
assert!(state.lock().unwrap().pending.is_none());
|
||||
assert!(state.lock().unwrap().accepted.is_some());
|
||||
@@ -414,7 +655,7 @@ impl AttentionWorkerFactory {
|
||||
worker_id: String,
|
||||
restore: bool,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
let name = format!("attention-{worker_id}");
|
||||
let name = format!("worker-runtime-{worker_id}");
|
||||
let root = self.root.join("workers").join(&worker_id);
|
||||
let store = session_store::CombinedStore::new(
|
||||
session_store::WorkerSessionStore::new(root.join("session"))
|
||||
@@ -789,12 +1030,16 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
|
||||
.unwrap();
|
||||
let restored = start_attention_test_orchestrator(&api).await;
|
||||
assert_eq!(restored.disposition, "restored");
|
||||
assert!(
|
||||
assert_eq!(
|
||||
orchestrator_attention_state(&api, &worker)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.accepted
|
||||
.is_none()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.request_id,
|
||||
first_id,
|
||||
"generic Subject restore preserves the acknowledged delivery cache"
|
||||
);
|
||||
assert!(attention_pending(&api).is_empty());
|
||||
assert_eq!(
|
||||
@@ -803,8 +1048,13 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
|
||||
);
|
||||
assert_eq!(receipts().len(), 1);
|
||||
assert_eq!(commits(), 1);
|
||||
// Retry directly at the real Controller boundary: the same Worker's
|
||||
// persisted receipt survives restore even after server memory is retired.
|
||||
// Explicitly simulate loss of the server's optional success cache. Retry
|
||||
// directly at the real Controller boundary: its persisted receipt survives
|
||||
// restore independently of server memory.
|
||||
orchestrator_attention_state(&api, &worker)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.accepted = None;
|
||||
let retry = api
|
||||
.runtime
|
||||
.send_input(
|
||||
@@ -919,15 +1169,9 @@ async fn orchestrator_attention_restore_and_replacement_do_not_resend_acknowledg
|
||||
"existing"
|
||||
);
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
// This is a fixture Worker, not the running dogfooding Orchestrator.
|
||||
api.runtime.delete_worker(&worker).unwrap();
|
||||
api.worker_projection
|
||||
.publish_ordered(|| {
|
||||
api.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &worker)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.unwrap();
|
||||
// Connected Subject occupancy is released only by the normal Host protocol,
|
||||
// even though this fixture substitutes the deterministic Controller.
|
||||
remove_test_assignment_body(&api, &worker).await;
|
||||
let replacement = start_attention_test_orchestrator(&api).await;
|
||||
let replacement_id = replacement.worker.unwrap().worker_id;
|
||||
assert_ne!(replacement_id, worker.worker_id);
|
||||
@@ -1021,8 +1265,8 @@ async fn orchestrator_attention_close_reopen_does_not_revive_old_input_but_reass
|
||||
ticket_id: original.input.ticket_id.clone(),
|
||||
assignment_id: "attention-reassigned".into(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: ensure_test_assignment_subject(&api),
|
||||
},
|
||||
assigned_by: "test-user".into(),
|
||||
assigned_at: "2026-09-02T00:00:00Z".into(),
|
||||
@@ -1150,3 +1394,219 @@ async fn orchestrator_running_to_idle_recovers_unreceived_input_without_notifica
|
||||
"foreign_workspace_ticket_projection"
|
||||
);
|
||||
}
|
||||
|
||||
// Lookup-only fault adapter around the existing fixture Runtime. It is installed
|
||||
// after normal Subject body creation and owns no launch/delegation implementation.
|
||||
// All observations and sends retain the existing adapter's behavior; only one
|
||||
// selected Runtime.worker() return runs the deterministic policy-change callback.
|
||||
struct AssignmentLookupHookRuntime {
|
||||
inner: WorkdirlessFixtureRuntime,
|
||||
hook: Mutex<Option<Box<dyn FnOnce() + Send>>>,
|
||||
}
|
||||
|
||||
impl crate::hosts::WorkspaceWorkerRuntime for AssignmentLookupHookRuntime {
|
||||
fn runtime_id(&self) -> &str {
|
||||
crate::hosts::WorkspaceWorkerRuntime::runtime_id(&self.inner)
|
||||
}
|
||||
fn runtime_summary(&self, limit: usize) -> crate::hosts::InternalRuntimeSummary {
|
||||
crate::hosts::WorkspaceWorkerRuntime::runtime_summary(&self.inner, limit)
|
||||
}
|
||||
fn list_hosts(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> crate::hosts::RuntimeList<crate::hosts::InternalHostSummary> {
|
||||
crate::hosts::WorkspaceWorkerRuntime::list_hosts(&self.inner, limit)
|
||||
}
|
||||
fn list_workers(&self, limit: usize) -> crate::hosts::RuntimeList<InternalWorkerSummary> {
|
||||
crate::hosts::WorkspaceWorkerRuntime::list_workers(&self.inner, limit)
|
||||
}
|
||||
fn worker(&self, worker_id: &str) -> crate::hosts::WorkerLookupResult {
|
||||
let observed = crate::hosts::WorkspaceWorkerRuntime::worker(&self.inner, worker_id);
|
||||
let hook = self.hook.lock().unwrap().take();
|
||||
if let Some(hook) = hook {
|
||||
hook();
|
||||
}
|
||||
observed
|
||||
}
|
||||
fn send_input(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
request: WorkerInputRequest,
|
||||
) -> crate::hosts::WorkerInputResult {
|
||||
crate::hosts::WorkspaceWorkerRuntime::send_input(&self.inner, worker_id, request)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_assignment_cached_send_rechecks_policy_after_runtime_target_lookup() {
|
||||
use server_api::authorization::SubjectPermissionsUpdate;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let body =
|
||||
create_test_assignment_body(&api, WorkdirlessFixtureRuntime::RUNTIME_ID, &subject_id).await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Private assignment input"))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
|
||||
api.store
|
||||
.set_current_ticket_role_assignment(
|
||||
&TicketRoleAssignmentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket.id.clone(),
|
||||
assignment_id: "policy-lookup-assignment".into(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: subject_id.clone(),
|
||||
},
|
||||
assigned_by: "fixture-owner".into(),
|
||||
assigned_at: TEST_CREATED_AT.into(),
|
||||
},
|
||||
None,
|
||||
"policy-lookup-event",
|
||||
"policy-lookup-assign",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let input = api
|
||||
.store
|
||||
.list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 20)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
let target = orchestrator_delivery_target(&api, &body).unwrap();
|
||||
let input = api
|
||||
.store
|
||||
.reserve_ticket_assignment_delivery(
|
||||
TEST_WORKSPACE_ID,
|
||||
&input.assignment_id,
|
||||
&input.input_event_id,
|
||||
&target,
|
||||
)
|
||||
.unwrap();
|
||||
let content = api
|
||||
.store
|
||||
.prepare_ticket_assignment_delivery_content(
|
||||
TEST_WORKSPACE_ID,
|
||||
&input.delivery_id,
|
||||
"Pinned private Ticket context",
|
||||
&target,
|
||||
)
|
||||
.unwrap();
|
||||
let delivery = OrchestratorAttentionDelivery {
|
||||
fingerprint: input.delivery_id.clone(),
|
||||
request_id: input.request_id.clone(),
|
||||
content,
|
||||
input,
|
||||
};
|
||||
let state = orchestrator_attention_state(&api, &body);
|
||||
state.lock().unwrap().pending = Some(delivery.clone());
|
||||
let callback_ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let called = callback_ran.clone();
|
||||
let revoking_api = api.clone();
|
||||
let revoking_body = body.clone();
|
||||
let revoking_ticket = ticket.id.clone();
|
||||
let revoking_subject = subject_id.clone();
|
||||
api.runtime
|
||||
.register_or_replace(AssignmentLookupHookRuntime {
|
||||
inner: runtime.clone(),
|
||||
hook: Mutex::new(Some(Box::new(move || {
|
||||
// The first authorization already passed. Policy is still live at
|
||||
// this exact target lookup, then changes before the final send.
|
||||
authorize_assignment_delivery(&revoking_api, &revoking_body, &revoking_ticket)
|
||||
.unwrap();
|
||||
let store = open_subjektiv_store(&revoking_api).unwrap();
|
||||
let previous = store
|
||||
.subject_permissions(&revoking_subject)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&revoking_subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "revoke-at-target-lookup".into(),
|
||||
expected_change_id: Some(previous.change_id),
|
||||
execution: vec![],
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
called.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}))),
|
||||
});
|
||||
assert!(runtime.input_requests.lock().unwrap().is_empty());
|
||||
// Invoke the real cached-send path. Its next Runtime.worker() call is the
|
||||
// target observation between initial authorization and DB/send admission.
|
||||
send_orchestrator_attention(&api, &body, &state, delivery.clone());
|
||||
assert!(callback_ran.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(authorize_assignment_delivery(&api, &body, &ticket.id).is_err());
|
||||
assert!(
|
||||
runtime.input_requests.lock().unwrap().is_empty(),
|
||||
"revoked context reached Runtime send_input"
|
||||
);
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let acknowledgements = || {
|
||||
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, delivery.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
|
||||
};
|
||||
assert_eq!(acknowledgements(), 0);
|
||||
assert!(
|
||||
state.lock().unwrap().pending.is_some(),
|
||||
"denied send must not manufacture acceptance"
|
||||
);
|
||||
assert!(state.lock().unwrap().accepted.is_none());
|
||||
// Positive control: explicit policy restoration can admit the SAME pinned
|
||||
// request. The target/reservation was valid; no new delegation was needed.
|
||||
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
|
||||
send_orchestrator_attention(&api, &body, &state, delivery.clone());
|
||||
let inputs = runtime.input_requests.lock().unwrap();
|
||||
assert_eq!(inputs.len(), 1);
|
||||
assert_eq!(
|
||||
inputs[0].submission_request_id.as_deref(),
|
||||
Some(delivery.request_id.as_str())
|
||||
);
|
||||
assert_eq!(inputs[0].content, delivery.content);
|
||||
assert_eq!(acknowledgements(), 1);
|
||||
assert!(state.lock().unwrap().pending.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_assignment_known_ack_closes_cached_pending_resend_admission() {
|
||||
let (_dir, api, execution) = attention_test_api().await;
|
||||
let started = start_attention_test_orchestrator(&api).await;
|
||||
let body = RuntimeWorkerRef::new(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
started.worker.unwrap().worker_id,
|
||||
);
|
||||
let state = orchestrator_attention_state(&api, &body);
|
||||
let acknowledged = state.lock().unwrap().accepted.clone().unwrap();
|
||||
assert!(attention_pending(&api).is_empty());
|
||||
execution.take_inputs();
|
||||
execution.take_input_request_ids();
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let acks = || {
|
||||
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, acknowledged.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
acks(),
|
||||
1,
|
||||
"positive Runtime acceptance must already be durable"
|
||||
);
|
||||
// Model a stale local pending slot after another sender committed its ACK.
|
||||
// Memory does not regain send authority merely by holding the original bytes.
|
||||
state.lock().unwrap().pending = Some(acknowledged.clone());
|
||||
send_orchestrator_attention(&api, &body, &state, acknowledged.clone());
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
assert!(execution.take_input_request_ids().is_empty());
|
||||
assert!(state.lock().unwrap().pending.is_none());
|
||||
assert_eq!(acks(), 1);
|
||||
assert!(attention_pending(&api).is_empty());
|
||||
assert_eq!(
|
||||
state.lock().unwrap().accepted.as_ref().unwrap().request_id,
|
||||
acknowledged.request_id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,13 +87,14 @@ async fn delivery_http(
|
||||
}
|
||||
|
||||
async fn delivery_assign(api: &WorkspaceApi, ticket: &str, key: &str) -> Value {
|
||||
grant_test_assignment_ticket(api, &ensure_test_assignment_subject(api), ticket);
|
||||
delivery_http(
|
||||
api,
|
||||
"PUT",
|
||||
&delivery_assignment_path(ticket),
|
||||
Some(json!({
|
||||
"operation_id": key,
|
||||
"principal": {"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}
|
||||
"principal": test_assignment_principal(&api)
|
||||
})),
|
||||
StatusCode::OK,
|
||||
)
|
||||
@@ -152,7 +153,7 @@ async fn delivery_recording_fixture(
|
||||
RuntimeWorkerRef,
|
||||
RuntimeIdentityMaterial,
|
||||
) {
|
||||
let (mut api, execution) = test_api_with_recording_backend(root).await;
|
||||
let (mut api, execution) = assignment_test_api_with_recording_backend(root).await;
|
||||
seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
let worker = delivery_worker(start_attention_test_orchestrator(&api).await);
|
||||
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
|
||||
@@ -617,7 +618,7 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop
|
||||
"clear/reissue must not rewrite the old status to done"
|
||||
);
|
||||
assert_eq!(
|
||||
find_workspace_orchestrator(&api).unwrap().worker,
|
||||
test_assignment_body(&api).unwrap().worker,
|
||||
worker,
|
||||
"clear/reassign are not Worker stop or replacement"
|
||||
);
|
||||
@@ -782,34 +783,46 @@ struct DeliveryWorkerFactory {
|
||||
}
|
||||
|
||||
impl DeliveryWorkerFactory {
|
||||
fn manifest(
|
||||
worker_id: &str,
|
||||
profile: &worker_runtime::catalog::ProfileSelector,
|
||||
bundle: Option<&worker_runtime::config_bundle::ConfigBundle>,
|
||||
) -> std::result::Result<manifest::WorkerManifest, String> {
|
||||
let selector = match profile {
|
||||
worker_runtime::catalog::ProfileSelector::Builtin(value)
|
||||
| worker_runtime::catalog::ProfileSelector::Named(value) => value,
|
||||
};
|
||||
let mut manifest = crate::profile_settings::resolve_profile_manifest_from_config_bundle(
|
||||
bundle.ok_or("delivery fixture requires the normal resolved Config bundle")?,
|
||||
selector,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
manifest.worker.name = format!("worker-runtime-{worker_id}");
|
||||
// These notification fixtures attach no Workdirs. Apply that zero-file
|
||||
// launch ceiling rather than resolving Profile scope against the test cwd.
|
||||
manifest.scope.allow.clear();
|
||||
// Retain the resolved Profile; bootstrap saves the actual execution policy,
|
||||
// including that ceiling, through its normal metadata writer.
|
||||
assert!(manifest.requires_persisted_execution_snapshot());
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
async fn controller(
|
||||
&self,
|
||||
worker_id: String,
|
||||
restore: bool,
|
||||
manifest: manifest::WorkerManifest,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
let name = format!("delivery-{worker_id}");
|
||||
let name = format!("worker-runtime-{worker_id}");
|
||||
let root = self.root.join("workers").join(&worker_id);
|
||||
let store = session_store::CombinedStore::new(
|
||||
WorkerSessionStore::new(root.join("session")).map_err(|e| e.to_string())?,
|
||||
session_store::WorkerAggregateStore::new(&root, &name).map_err(|e| e.to_string())?,
|
||||
);
|
||||
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
||||
r#"
|
||||
[worker]
|
||||
name = "{name}"
|
||||
pwd = "./"
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "scripted-delivery"
|
||||
auth = {{ kind = "none" }}
|
||||
[engine]
|
||||
max_tokens = 64
|
||||
[scope]
|
||||
allow = []
|
||||
"#
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let context = worker::WorkerWorkspaceContext::local_filesystem(None);
|
||||
|
||||
let context = worker::WorkerWorkspaceContext::local_filesystem(Some(
|
||||
worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap(),
|
||||
));
|
||||
let workspace_client = context.client_handle();
|
||||
let bootstrap = worker::WorkerBootstrap::new(
|
||||
manifest,
|
||||
@@ -827,7 +840,7 @@ impl DeliveryWorkerFactory {
|
||||
} else {
|
||||
bootstrap.prepare().await
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let started = prepared.start().await.map_err(|e| e.to_string())?;
|
||||
Ok(worker_runtime::worker_backend::RuntimeWorkerController {
|
||||
handle: started.handle,
|
||||
@@ -845,8 +858,13 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
|
||||
&self,
|
||||
request: delivery_execution::WorkerExecutionSpawnRequest,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
self.controller(request.worker_ref.worker_id.to_string(), false)
|
||||
.await
|
||||
let worker_id = request.worker_ref.worker_id.to_string();
|
||||
let manifest = Self::manifest(
|
||||
&worker_id,
|
||||
&request.request.profile,
|
||||
request.config_bundle.as_ref(),
|
||||
)?;
|
||||
self.controller(worker_id, false, manifest).await
|
||||
}
|
||||
async fn prepare_restore(
|
||||
&self,
|
||||
@@ -855,13 +873,27 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
|
||||
Box<dyn worker_runtime::worker_backend::RuntimeRestoreCandidate>,
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure,
|
||||
> {
|
||||
let worker_id = request.worker_ref.worker_id.to_string();
|
||||
// Restore must read the saved execution snapshot, not reselect today's
|
||||
// Config/Profile (a reopened Runtime need not have a cached bundle).
|
||||
// Workspace-bound prepare_restored rejects a missing saved snapshot.
|
||||
let mut fallback = manifest::WorkerManifestConfig::resolution_defaults();
|
||||
fallback.worker.name = Some(format!("worker-runtime-{worker_id}"));
|
||||
let manifest = manifest::WorkerManifest::try_from(fallback).map_err(|error| {
|
||||
eprintln!("delivery fixture restore defaults failed: {error}");
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure::refused(
|
||||
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
|
||||
"delivery fixture restore defaults failed",
|
||||
)
|
||||
})?;
|
||||
Ok(Box::new(DeliveryRestoreCandidate {
|
||||
inputs: Some((
|
||||
DeliveryWorkerFactory {
|
||||
root: self.root.clone(),
|
||||
client: self.client.clone(),
|
||||
},
|
||||
request.worker_ref.worker_id.to_string(),
|
||||
worker_id,
|
||||
manifest,
|
||||
)),
|
||||
}))
|
||||
}
|
||||
@@ -870,7 +902,7 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
|
||||
// Retain the same scripted client/receipt store without opening either store or
|
||||
// starting a Controller until activation. Discard drops only these owned inputs.
|
||||
struct DeliveryRestoreCandidate {
|
||||
inputs: Option<(DeliveryWorkerFactory, String)>,
|
||||
inputs: Option<(DeliveryWorkerFactory, String, manifest::WorkerManifest)>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -885,18 +917,22 @@ impl worker_runtime::worker_backend::RuntimeRestoreCandidate for DeliveryRestore
|
||||
worker_runtime::worker_backend::RuntimeWorkerController,
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure,
|
||||
> {
|
||||
let (factory, worker_id) = self.inputs.take().ok_or_else(|| {
|
||||
let (factory, worker_id, manifest) = self.inputs.take().ok_or_else(|| {
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure::refused(
|
||||
worker_runtime::worker_backend::RuntimeRestoreStage::Controller,
|
||||
"delivery fixture candidate already consumed",
|
||||
)
|
||||
})?;
|
||||
factory.controller(worker_id, true).await.map_err(|_| {
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure::unproven(
|
||||
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
|
||||
"delivery fixture reconstruction failed",
|
||||
)
|
||||
})
|
||||
factory
|
||||
.controller(worker_id, true, manifest)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
eprintln!("delivery fixture reconstruction failed: {error}");
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure::unproven(
|
||||
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
|
||||
"delivery fixture reconstruction failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn discard(
|
||||
@@ -919,6 +955,12 @@ impl delivery_execution::WorkerExecutionBackend for DeliveryResponseLossBackend
|
||||
fn backend_id(&self) -> &str {
|
||||
self.inner.backend_id()
|
||||
}
|
||||
fn subject_release(
|
||||
&self,
|
||||
request: &runtime_api::SubjectReleaseRequest,
|
||||
) -> std::result::Result<runtime_api::SubjectReleaseResponse, String> {
|
||||
self.inner.subject_release(request)
|
||||
}
|
||||
fn spawn_worker(
|
||||
&self,
|
||||
r: delivery_execution::WorkerExecutionSpawnRequest,
|
||||
@@ -1364,17 +1406,24 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
|
||||
let initial = delivery_tracking(&api, &handled.id, assignment);
|
||||
let old_worker = delivery_worker(start_attention_test_orchestrator(&api).await);
|
||||
delivery_wait_idle(&api, &old_worker, 1).await;
|
||||
delivery_stop(&api, &old_worker);
|
||||
// Delete only this isolated fixture's stopped identity. A new live Worker
|
||||
// has a new session; it is not a restore and cannot reuse old session receipts.
|
||||
api.runtime.delete_worker(&old_worker).unwrap();
|
||||
api.worker_projection
|
||||
.publish_ordered(|| {
|
||||
api.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &old_worker)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), 1);
|
||||
// The factory has no Journal tool wiring. Drive real Host admission and the
|
||||
// dispatched Journal Job's sequential services over the actual saved source,
|
||||
// rather than bypassing the normal release coverage invariant.
|
||||
reflect_test_assignment_session(
|
||||
&api,
|
||||
&old_worker,
|
||||
"Read the delegation; returning without starting a Coder.",
|
||||
)
|
||||
.await;
|
||||
let reflected_attempts = execution.attempts.lock().unwrap().len();
|
||||
assert_eq!(
|
||||
reflected_attempts, 2,
|
||||
"one delivery plus one actual Journal Job"
|
||||
);
|
||||
// Release while the actual Controller is Idle, before stopping/deleting its
|
||||
// retained identity. Deletion itself cannot release connected Subject occupancy.
|
||||
remove_test_assignment_body(&api, &old_worker).await;
|
||||
let pending = delivery_ticket(&api, "Pending input for replacement");
|
||||
let new_assign = delivery_assign(&api, &pending.id, "assign-pending-replacement").await;
|
||||
let pending_assignment = new_assign["assignment"]["assignment_id"].as_str().unwrap();
|
||||
@@ -1391,7 +1440,10 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
|
||||
!commits[0].contains(handled.resource_key.as_deref().unwrap()),
|
||||
"replacement must not execute acknowledged history"
|
||||
);
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
execution.attempts.lock().unwrap().len(),
|
||||
reflected_attempts + 1
|
||||
);
|
||||
assert_eq!(
|
||||
delivery_tracking(&api, &handled.id, assignment),
|
||||
initial,
|
||||
@@ -1402,7 +1454,10 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
|
||||
pending_initial
|
||||
);
|
||||
delivery_scan(&api, &replacement);
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
execution.attempts.lock().unwrap().len(),
|
||||
reflected_attempts + 1
|
||||
);
|
||||
delivery_stop(&api, &replacement);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,64 +7,56 @@ use super::*;
|
||||
struct StartSource {
|
||||
identity: RuntimeIdentityMaterial,
|
||||
worker: RuntimeWorkerRef,
|
||||
delegation: Vec<server_api::authorization::ScopedPermission>,
|
||||
}
|
||||
|
||||
async fn start_source(api: &mut WorkspaceApi, runtime: &WorkdirlessFixtureRuntime) -> StartSource {
|
||||
let runtime_id = WorkdirlessFixtureRuntime::RUNTIME_ID;
|
||||
async fn start_source(api: &mut WorkspaceApi, _runtime: &WorkdirlessFixtureRuntime) -> StartSource {
|
||||
start_body_source(api, WorkdirlessFixtureRuntime::RUNTIME_ID).await
|
||||
}
|
||||
|
||||
async fn start_body_source(api: &mut WorkspaceApi, runtime_id: &str) -> StartSource {
|
||||
let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap();
|
||||
configure_runtime_request_auth(api, &identity, runtime_id);
|
||||
let memory = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||
.unwrap();
|
||||
let reservation = api
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
runtime_id,
|
||||
"start-orchestrator",
|
||||
"sha256:start-orchestrator",
|
||||
Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY),
|
||||
&memory,
|
||||
let subject_id = ensure_test_assignment_subject(api);
|
||||
let worker = create_test_assignment_body(api, runtime_id, &subject_id).await;
|
||||
for ticket in api.authority.list_tickets(100).unwrap().items {
|
||||
grant_test_assignment_ticket(api, &subject_id, &ticket.id);
|
||||
}
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
let delegation: Vec<_> = api
|
||||
.store
|
||||
.list_workdir_registry(TEST_WORKSPACE_ID, 100)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|workdir| ScopedPermission {
|
||||
operation: "filesystem.read".into(),
|
||||
resource: PermissionResource::Workdir {
|
||||
working_directory_id: workdir.workdir_id,
|
||||
path: ".".into(),
|
||||
recursive: true,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
let previous = store.subject_permissions(&subject_id).unwrap();
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "start-source-delegation".into(),
|
||||
expected_change_id: previous.as_ref().map(|policy| policy.change_id.clone()),
|
||||
execution: previous.map(|policy| policy.execution).unwrap_or_default(),
|
||||
delegation: delegation.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut summary = WorkdirlessFixtureRuntime::worker_summary(
|
||||
WorkerCreateBinding {
|
||||
worker_id: reservation.worker_id,
|
||||
create_fingerprint: reservation.create_fingerprint,
|
||||
},
|
||||
&WorkerSpawnRequest {
|
||||
requested_worker_name: Some("Start Orchestrator".into()),
|
||||
singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()),
|
||||
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:orchestrator".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachments: Vec::new(),
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
);
|
||||
summary.tags = vec!["orchestrator".into(), "singleton".into()];
|
||||
let worker = summary.worker.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
seed_worker_source_member(api, runtime_id, &worker.worker_id);
|
||||
api.config_store
|
||||
.complete_worker_create_reservation(TEST_WORKSPACE_ID, worker.worker_id.parse().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(find_workspace_orchestrator(api).unwrap().worker, worker);
|
||||
StartSource { identity, worker }
|
||||
StartSource {
|
||||
identity,
|
||||
worker,
|
||||
delegation,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_unfinished_start_dependency(api: &WorkspaceApi, ticket_id: &str) -> String {
|
||||
@@ -91,8 +83,37 @@ fn start_http_request(
|
||||
token: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Value,
|
||||
mut body: Value,
|
||||
) -> Request<Body> {
|
||||
if let Some(source) =
|
||||
source.filter(|_| method == "POST" && path.ends_with("/worker-control/workers"))
|
||||
{
|
||||
// Supply the normal explicit least-authority child request. Only the
|
||||
// selected reference Workdirs receive read; no write/command is issued.
|
||||
let attachments = body["workdir_attachments"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let permissions: Vec<_> = source
|
||||
.delegation
|
||||
.iter()
|
||||
.filter(|permission| {
|
||||
let server_api::authorization::PermissionResource::Workdir {
|
||||
working_directory_id,
|
||||
..
|
||||
} = &permission.resource
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
attachments.iter().any(|attachment| {
|
||||
attachment["working_directory_id"].as_str()
|
||||
== Some(working_directory_id.as_str())
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
body["execution_grant"] = serde_json::json!({"permissions":permissions, "redelegation":[]});
|
||||
}
|
||||
let body = serde_json::to_vec(&body).unwrap();
|
||||
match source {
|
||||
Some(source) => runtime_source_request(
|
||||
@@ -182,6 +203,54 @@ fn assert_start_dependency_diagnostic_only(api: &WorkspaceApi, ticket: &str, dep
|
||||
assert!(prerequisite.assignment_tracking.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_start_rechecks_changed_target_after_allocation_before_runtime_dispatch() {
|
||||
for change in ["access='read_only'", "ref_selector='different-branch'"] {
|
||||
let fixture = guarded_spawn_fixture().await;
|
||||
let conn = rusqlite::Connection::open(&fixture.api.config.database_path).unwrap();
|
||||
// Deterministic interleaving at a real persistence boundary: the initial
|
||||
// target check has finished, but no Runtime spawn has been dispatched.
|
||||
conn.execute_batch(&format!(
|
||||
"CREATE TRIGGER narrow_target_after_allocation AFTER INSERT ON worker_create_reservations
|
||||
WHEN NEW.runtime_id='workdirless-runtime'
|
||||
BEGIN UPDATE typed_ticket_targets SET {change}
|
||||
WHERE ticket_id='{}' AND repository_key='test-repository'; END;",
|
||||
fixture.ticket_id,
|
||||
)).unwrap();
|
||||
let body = guarded_spawn_payload(&fixture, "target-changed-before-dispatch");
|
||||
let response = post_guarded_spawn(&fixture, body).await;
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert!(
|
||||
matches!(status, StatusCode::FORBIDDEN | StatusCode::BAD_REQUEST),
|
||||
"{change}: {status}: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
assert!(
|
||||
fixture.runtime.spawn_requests().is_empty(),
|
||||
"{change}: no Runtime effect"
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
.store
|
||||
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id,)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM worker_workdir_attachment_reservations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0,
|
||||
"failed admission must release newly reserved attachments"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfinished_dependencies_allow_manual_and_orchestrator_resource_free_start_and_assign() {
|
||||
for orchestrator in [false, true] {
|
||||
@@ -590,7 +659,7 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
|
||||
ticket.id
|
||||
);
|
||||
start_http_response(&api, start_http_request(None, &token, "PUT", &path,
|
||||
serde_json::json!({"operation_id":"reissue-observed", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})), StatusCode::OK).await;
|
||||
serde_json::json!({"operation_id":"reissue-observed", "principal":test_assignment_principal(&api)})), StatusCode::OK).await;
|
||||
assert_ne!(
|
||||
observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(),
|
||||
observed
|
||||
@@ -664,11 +733,12 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
|
||||
async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_delegation() {
|
||||
for existing in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(directory.path()).await;
|
||||
let (mut api, _) = assignment_test_api_with_recording_backend(directory.path()).await;
|
||||
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let source = start_source(&mut api, &runtime).await;
|
||||
let source = start_body_source(&mut api, EMBEDDED_WORKER_RUNTIME_ID).await;
|
||||
bootstrap_test_assignment_session(&api, &source.worker);
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "obsolete-worker-owner").await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
@@ -697,21 +767,16 @@ async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_del
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Backend ownership changes independently of the retained assignment row.
|
||||
let replacement =
|
||||
RuntimeWorkerRef::new(&source.worker.runtime_id, WorkerId::now_v7().to_string());
|
||||
seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id);
|
||||
let mut summary = runtime.workers.lock().unwrap()[0].clone();
|
||||
summary.worker = replacement.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
rusqlite::Connection::open(&api.config.database_path).unwrap().execute(
|
||||
"UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3",
|
||||
rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY],
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
find_workspace_orchestrator(&api).unwrap().worker,
|
||||
replacement
|
||||
);
|
||||
// Body replacement preserves Subject assignment, but rotates the real
|
||||
// create reservation/singleton generation through normal Subject launch.
|
||||
remove_test_assignment_body(&api, &source.worker).await;
|
||||
let replacement = create_test_assignment_body(
|
||||
&api,
|
||||
&source.worker.runtime_id,
|
||||
&ensure_test_assignment_subject(&api),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(test_assignment_body(&api).unwrap().worker, replacement);
|
||||
assert_eq!(
|
||||
observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(),
|
||||
delegation
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
// Test-only Subject lifetime support. Session metadata and Host writer binding
|
||||
// are real; the deterministic executor substitutes only the Controller protocol.
|
||||
// Tests requiring real Controller release use the connected Runtime suites.
|
||||
async fn assignment_test_api_with_recording_backend(
|
||||
root: impl Into<PathBuf>,
|
||||
) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) {
|
||||
let (api, execution) = test_api_with_recording_backend(root).await;
|
||||
execution
|
||||
.assignment_release_enabled
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
(api, execution)
|
||||
}
|
||||
|
||||
fn bootstrap_test_assignment_session(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
use session_store::WorkerMetadataStore;
|
||||
assert_eq!(worker.runtime_id, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
let lease = api
|
||||
.store
|
||||
.require_current_worker_singleton_owner(api.workspace_id(), worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let subject = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX).unwrap();
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
let previous = store.subject_permissions(subject).unwrap();
|
||||
let mut execution = previous
|
||||
.as_ref()
|
||||
.map(|p| p.execution.clone())
|
||||
.unwrap_or_default();
|
||||
let before = execution.clone();
|
||||
for operation in ["session.record", "journal.recording.request"] {
|
||||
let permission = ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Subject {
|
||||
workspace_id: api.workspace_id().into(),
|
||||
subject_id: subject.into(),
|
||||
},
|
||||
};
|
||||
if !execution.contains(&permission) {
|
||||
execution.push(permission);
|
||||
}
|
||||
}
|
||||
if before != execution {
|
||||
store
|
||||
.set_subject_permissions(
|
||||
subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: format!("assignment-session-{}", worker.worker_id),
|
||||
expected_change_id: previous.as_ref().map(|p| p.change_id.clone()),
|
||||
execution,
|
||||
delegation: previous.map(|p| p.delegation).unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let name = format!("worker-runtime-{}", worker.worker_id);
|
||||
let aggregate = session_store::worker_metadata::WorkerAggregateStore::new(
|
||||
api.config
|
||||
.embedded_runtime_store_root
|
||||
.join("workers")
|
||||
.join(&worker.worker_id),
|
||||
&name,
|
||||
)
|
||||
.unwrap();
|
||||
let metadata = aggregate.read_read_only().unwrap();
|
||||
let session = if let Some(active) = metadata.as_ref().and_then(|m| m.active.as_ref()) {
|
||||
active.session_id
|
||||
} else {
|
||||
let session = session_store::new_session_id();
|
||||
let mut metadata =
|
||||
metadata.unwrap_or_else(|| session_store::WorkerMetadata::new(&name, None));
|
||||
metadata.active = Some(session_store::WorkerActiveSegmentRef::pending_segment(
|
||||
session,
|
||||
));
|
||||
aggregate
|
||||
.write(&metadata.with_workspace_id(api.workspace_id()))
|
||||
.unwrap();
|
||||
session
|
||||
};
|
||||
subject_authorization::authorize(
|
||||
api,
|
||||
worker,
|
||||
&server_api::authorization::OperationAuthorizationRequest {
|
||||
operation: "session.record".into(),
|
||||
resource: PermissionResource::Subject {
|
||||
workspace_id: api.workspace_id().into(),
|
||||
subject_id: subject.into(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
subjektiv_journal::bind_host_session(
|
||||
api,
|
||||
crate::subjektiv::SubjectSessionAttribution::new(
|
||||
subject,
|
||||
&worker.runtime_id,
|
||||
&worker.worker_id,
|
||||
session.to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// The delivery factory persists real canonical Session bytes, but its scripted model
|
||||
// has no Journal tools. Substitute only the sequential model/tool calls here: normal
|
||||
// Host admission selects the saved source, dispatches the immutable BackendJob input
|
||||
// and attests actual Runtime acceptance. No fixture source or delivery proof is seeded.
|
||||
async fn reflect_test_assignment_session(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
expected_public_output: &str,
|
||||
) {
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
use subjektiv::journal::transport::{JournalJobRequest, JournalJobResponse};
|
||||
use subjektiv::journal::{JournalAction, JournalEntryState, JournalOperation};
|
||||
|
||||
let lease = api
|
||||
.store
|
||||
.require_current_worker_singleton_owner(api.workspace_id(), worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let subject = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX).unwrap();
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
let previous = store.subject_permissions(subject).unwrap().unwrap();
|
||||
let mut execution = previous.execution.clone();
|
||||
for operation in [
|
||||
"journal.job.input.read",
|
||||
"journal.job.input.supply",
|
||||
"journal.state.read",
|
||||
"journal.entry.create",
|
||||
"journal.position.update",
|
||||
"journal.checkpoint",
|
||||
"journal.finish",
|
||||
] {
|
||||
let permission = ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Subject {
|
||||
workspace_id: api.workspace_id().into(),
|
||||
subject_id: subject.into(),
|
||||
},
|
||||
};
|
||||
if !execution.contains(&permission) {
|
||||
execution.push(permission);
|
||||
}
|
||||
}
|
||||
store
|
||||
.set_subject_permissions(
|
||||
subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: format!("assignment-journal-{}", worker.worker_id),
|
||||
expected_change_id: Some(previous.change_id.clone()),
|
||||
execution,
|
||||
delegation: previous.delegation.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let saved_permissions = store.subject_permissions(subject).unwrap().unwrap();
|
||||
assert!(
|
||||
previous
|
||||
.execution
|
||||
.iter()
|
||||
.all(|permission| saved_permissions.execution.contains(permission))
|
||||
);
|
||||
assert_eq!(saved_permissions.delegation, previous.delegation);
|
||||
let attrs = store
|
||||
.subject_session_attributions(subject)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|a| a.runtime_id == worker.runtime_id && a.worker_id == worker.worker_id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(attrs.len(), 1, "fixture expects one actual saved Session");
|
||||
let attr = &attrs[0];
|
||||
subjektiv_journal::reconcile_created_worker(api, worker).unwrap();
|
||||
let (job, attempt) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
loop {
|
||||
let jobs = api
|
||||
.store
|
||||
.list_backend_journal_jobs(api.workspace_id(), &attr.session_id, None, 100)
|
||||
.unwrap();
|
||||
assert!(
|
||||
jobs.len() <= 1,
|
||||
"unexpected duplicate Journal intent: {jobs:?}"
|
||||
);
|
||||
if let Some(job) = jobs.into_iter().next() {
|
||||
let attempt = api
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
api.workspace_id(),
|
||||
&job.request.job_id,
|
||||
&crate::backend_job::attempt_id(&job.request.job_id, job.current_attempt),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!attempt.state.terminal(),
|
||||
"Journal dispatch failed before reflection: {attempt:?}"
|
||||
);
|
||||
if attempt.state == BackendJobAttemptState::Dispatched {
|
||||
break (job, attempt);
|
||||
}
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("actual Journal BackendJob was not dispatched");
|
||||
let journal_worker = attempt.worker.as_ref().unwrap();
|
||||
assert_ne!(journal_worker, worker);
|
||||
let input = subjektiv::journal::recording::JournalInput::decode(&job.request.input).unwrap();
|
||||
let canonical = subjektiv_journal::capture_input(api, attr, 0, 0, 48 * 1024).unwrap();
|
||||
assert_eq!(input.capture.entries, canonical.entries);
|
||||
assert!(!input.selected_ranges.is_empty());
|
||||
let captured_text = input
|
||||
.capture
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| match &entry.body {
|
||||
session_store::capture::CaptureBody::CanonicalPublic { text, .. } => text.as_str(),
|
||||
_ => panic!("only actual canonical public material is eligible"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
// System notifications are not canonical public recording material. Reflect
|
||||
// the actual assistant output, not a reconstructed copy of its notification.
|
||||
assert!(captured_text.contains(expected_public_output));
|
||||
let JournalJobResponse::State(state) =
|
||||
subjektiv_journal::execute(api, journal_worker, JournalJobRequest::ReadState).unwrap()
|
||||
else {
|
||||
panic!("bound Journal state required")
|
||||
};
|
||||
assert_eq!(state.selected_ranges, input.selected_ranges);
|
||||
assert_eq!(state.initial_journal.state_token, input.initial_state_token);
|
||||
assert!(state.current_open.is_none());
|
||||
let record_id = format!("record:{}", state.generation_id);
|
||||
let mut token = state.current_journal.state_token;
|
||||
for (request_id, action, covered_ranges) in [
|
||||
(
|
||||
record_id.clone(),
|
||||
JournalAction::Create {
|
||||
title: "Assignment delivery before body replacement".into(),
|
||||
body: "Received the Ticket delegation. The scripted body returned without starting a Coder; no implementation result is asserted.".into(),
|
||||
ongoing: "Keep the Subject assignment; handle future pending Ticket input in the replacement body.".into(),
|
||||
state: JournalEntryState::Open,
|
||||
source_end: None,
|
||||
correction_refs: vec![],
|
||||
},
|
||||
input.selected_ranges.clone(),
|
||||
),
|
||||
(
|
||||
format!("position:{}", state.generation_id),
|
||||
JournalAction::UpdatePosition {
|
||||
current_position: "Delegation received; handling remains waiting. Release this body before replacement; do not replay acknowledged input.".into(),
|
||||
},
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
format!("checkpoint:{}", state.generation_id),
|
||||
JournalAction::Checkpoint { record_operation_ids: vec![record_id] },
|
||||
input.selected_ranges,
|
||||
),
|
||||
] {
|
||||
let JournalJobResponse::Saved(receipt) = subjektiv_journal::execute(
|
||||
api,
|
||||
journal_worker,
|
||||
JournalJobRequest::Execute {
|
||||
operation: JournalOperation { request_id, expected_state: token, action, covered_ranges },
|
||||
},
|
||||
).unwrap() else {
|
||||
panic!("saved sequential Journal receipt required")
|
||||
};
|
||||
token = receipt.state_token;
|
||||
}
|
||||
let completion = subjektiv_journal::finish(api, journal_worker).unwrap();
|
||||
assert_eq!(completion.state_token, token);
|
||||
let accepted = api
|
||||
.accept_backend_job_result(
|
||||
journal_worker,
|
||||
&BackendJobResultSubmission {
|
||||
job_id: job.request.job_id.clone(),
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
input_digest: attempt.input_digest,
|
||||
result: serde_json::to_value(completion).unwrap(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(accepted.attempt.state, BackendJobAttemptState::Completed);
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
api,
|
||||
&job.request.job_id,
|
||||
&attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn release_test_assignment_body(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
|
||||
let response = subjektiv_journal::release_subject(
|
||||
api,
|
||||
serde_json::from_value(json!({
|
||||
"worker":worker, "operation_id":format!("assignment-release-{}", worker.worker_id),
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.state,
|
||||
runtime_api::SubjectReleaseState::Disconnected
|
||||
);
|
||||
let stopped = api
|
||||
.runtime
|
||||
.stop_worker(worker, WorkerLifecycleRequest::default())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stopped.state,
|
||||
InternalWorkerOperationState::Accepted,
|
||||
"{:?}",
|
||||
stopped.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
async fn remove_test_assignment_body(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
|
||||
release_test_assignment_body(api, worker);
|
||||
let removed = WorkerRemovalService::new(api)
|
||||
.execute_target_removal(
|
||||
api.runtime.as_ref(),
|
||||
worker,
|
||||
"replace assignment test body",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(removed.status, StatusCode::OK.as_u16(), "{}", removed.body);
|
||||
}
|
||||
|
||||
impl DeterministicExecutionBackend {
|
||||
fn release_assignment_controller(
|
||||
&self,
|
||||
request: &runtime_api::SubjectReleaseRequest,
|
||||
) -> std::result::Result<runtime_api::SubjectReleaseResponse, String> {
|
||||
use runtime_api::{SubjectReleaseAction as A, SubjectReleaseState as S};
|
||||
if !self
|
||||
.assignment_release_enabled
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
return Err(
|
||||
"deterministic backend has no actual Subject Controller release support".into(),
|
||||
);
|
||||
}
|
||||
let worker = worker_runtime::identity::WorkerRef::new(request.worker_id);
|
||||
let execution = self
|
||||
.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&worker)
|
||||
.cloned()
|
||||
.ok_or("Subject execution is absent; no disconnect proof")?;
|
||||
let mut saved = execution.subject_release.lock().unwrap();
|
||||
if let Some((prior, state)) = saved.as_ref() {
|
||||
if *state != S::Cancelled
|
||||
&& (prior.binding_id != request.binding_id
|
||||
|| prior.operation_id != request.operation_id
|
||||
|| prior.workspace_id != request.workspace_id)
|
||||
{
|
||||
return Err("another Subject release owns this execution".into());
|
||||
}
|
||||
}
|
||||
let state = match request.action {
|
||||
A::Reserve => {
|
||||
if saved.as_ref().is_some_and(|(p, s)| {
|
||||
p.operation_id == request.operation_id && *s == S::Cancelled
|
||||
}) {
|
||||
return Err("cancelled Subject release cannot rearm".into());
|
||||
}
|
||||
if execution.state.lock().unwrap().state != protocol::WorkerState::Idle
|
||||
|| execution.method_rx.lock().unwrap().is_none()
|
||||
{
|
||||
return Err("mock execution cannot reserve its live Idle receiver".into());
|
||||
}
|
||||
S::Reserved
|
||||
}
|
||||
A::Cancel => S::Cancelled,
|
||||
A::Disconnect => {
|
||||
if !saved.as_ref().is_some_and(|(p, s)| {
|
||||
p.operation_id == request.operation_id
|
||||
&& p.binding_id == request.binding_id
|
||||
&& matches!(s, S::Reserved | S::Disconnected)
|
||||
}) {
|
||||
return Err("Subject disconnect has no exact receiver reservation".into());
|
||||
}
|
||||
execution.method_rx.lock().unwrap().take();
|
||||
S::Disconnected
|
||||
}
|
||||
};
|
||||
*saved = Some((request.clone(), state));
|
||||
Ok(runtime_api::SubjectReleaseResponse {
|
||||
binding_id: request.binding_id.clone(),
|
||||
operation_id: request.operation_id.clone(),
|
||||
state,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
|
||||
let input = |key: &str| {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"operation_id": key,
|
||||
"principal": {"kind":"workspace_agent","agent_key":"workspace-orchestrator"}
|
||||
"principal": test_assignment_principal(&api)
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
@@ -175,6 +175,7 @@ fn tracking_path(ticket: &str) -> String {
|
||||
}
|
||||
|
||||
fn assignment_request(
|
||||
api: &WorkspaceApi,
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
worker: &str,
|
||||
ticket: &str,
|
||||
@@ -182,7 +183,7 @@ fn assignment_request(
|
||||
) -> Request<Body> {
|
||||
runtime_source_request(identity, Some(worker), "PUT",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"),
|
||||
serde_json::to_vec(&serde_json::json!({"operation_id":operation,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap())
|
||||
serde_json::to_vec(&serde_json::json!({"operation_id":operation,"principal":test_assignment_principal(&api)})).unwrap())
|
||||
}
|
||||
|
||||
fn assignment_clear_path(ticket: &str, assignment: &str, operation: &str) -> String {
|
||||
@@ -191,22 +192,40 @@ fn assignment_clear_path(ticket: &str, assignment: &str, operation: &str) -> Str
|
||||
)
|
||||
}
|
||||
|
||||
fn replace_tracking_orchestrator(
|
||||
async fn tracking_embedded_fixture(
|
||||
root: &Path,
|
||||
) -> (
|
||||
WorkspaceApi,
|
||||
RuntimeIdentityMaterial,
|
||||
RuntimeWorkerRef,
|
||||
String,
|
||||
) {
|
||||
let (mut api, _) = assignment_test_api_with_recording_backend(root).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let source = create_test_assignment_body(&api, EMBEDDED_WORKER_RUNTIME_ID, &subject_id).await;
|
||||
bootstrap_test_assignment_session(&api, &source);
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Subject body replacement"))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
|
||||
(api, identity, source, ticket.id)
|
||||
}
|
||||
|
||||
async fn replace_tracking_orchestrator(
|
||||
api: &WorkspaceApi,
|
||||
runtime: &WorkdirlessFixtureRuntime,
|
||||
source: &RuntimeWorkerRef,
|
||||
) -> RuntimeWorkerRef {
|
||||
let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
|
||||
seed_worker_source_member(api, &replacement.runtime_id, &replacement.worker_id);
|
||||
let mut summary = runtime.workers.lock().unwrap()[0].clone();
|
||||
summary.worker = replacement.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap();
|
||||
assert_eq!(
|
||||
find_workspace_orchestrator(api).unwrap().worker,
|
||||
replacement
|
||||
);
|
||||
// Normal Subject release owns writer revocation and the singleton transition.
|
||||
// This fixture substitutes the Controller receiver, not the lifetime protocol.
|
||||
remove_test_assignment_body(api, source).await;
|
||||
let subject_id = ensure_test_assignment_subject(api);
|
||||
let replacement = create_test_assignment_body(api, &source.runtime_id, &subject_id).await;
|
||||
bootstrap_test_assignment_session(api, &replacement);
|
||||
assert_ne!(*source, replacement);
|
||||
assert_eq!(test_assignment_body(api).unwrap().worker, replacement);
|
||||
replacement
|
||||
}
|
||||
|
||||
@@ -238,7 +257,13 @@ async fn revoked_runtime_cannot_retry_assign_clear_or_record_tracking() {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2026-10-11T00:00:00Z' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap();
|
||||
for request in [
|
||||
assignment_request(&identity, &source.worker_id, &ticket, "assign-tracking"),
|
||||
assignment_request(
|
||||
&api,
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
&ticket,
|
||||
"assign-tracking",
|
||||
),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&source.worker_id),
|
||||
@@ -267,7 +292,7 @@ async fn revoked_runtime_cannot_retry_assign_clear_or_record_tracking() {
|
||||
#[tokio::test]
|
||||
async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_old_receipts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let (api, identity, source, ticket) = tracking_embedded_fixture(dir.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let app = build_router(api.clone());
|
||||
let clear_path =
|
||||
@@ -284,7 +309,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let replacement = replace_tracking_orchestrator(&api, &runtime, &source);
|
||||
let replacement = replace_tracking_orchestrator(&api, &source).await;
|
||||
for worker in [&source.worker_id, &replacement.worker_id] {
|
||||
let expected = if worker == &source.worker_id {
|
||||
StatusCode::UNAUTHORIZED
|
||||
@@ -293,7 +318,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
|
||||
};
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
assignment_request(&identity, worker, &ticket, "assign-tracking"),
|
||||
assignment_request(&api, &identity, worker, &ticket, "assign-tracking"),
|
||||
expected,
|
||||
)
|
||||
.await;
|
||||
@@ -313,6 +338,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
|
||||
tracking_response(
|
||||
app,
|
||||
assignment_request(
|
||||
&api,
|
||||
&identity,
|
||||
&replacement.worker_id,
|
||||
&ticket,
|
||||
@@ -330,19 +356,30 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_orchestrator_tags_without_backend_singleton_do_not_grant_assignment_authority() {
|
||||
async fn runtime_orchestrator_tags_without_operation_policy_do_not_grant_assignment_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let forged = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
|
||||
seed_worker_source_member(&api, &forged.runtime_id, &forged.worker_id);
|
||||
let mut summary = runtime.workers.lock().unwrap()[0].clone();
|
||||
summary.worker = forged.clone();
|
||||
let unprivileged = open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.create_subject(
|
||||
crate::subjektiv::SubjectRole::new("unprivileged-assignment-source").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let forged = create_test_assignment_body(&api, &source.runtime_id, &unprivileged.id).await;
|
||||
// Runtime display metadata cannot mint operation policy. Backend ownership
|
||||
// remains this real, unprivileged Subject, even if the adapter advertises tags.
|
||||
let mut workers = runtime.workers.lock().unwrap();
|
||||
let summary = workers
|
||||
.iter_mut()
|
||||
.find(|worker| worker.worker == forged)
|
||||
.unwrap();
|
||||
summary.tags = vec!["orchestrator".into(), "singleton".into()];
|
||||
summary.singleton_key = Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into());
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
drop(workers);
|
||||
let app = build_router(api.clone());
|
||||
for request in [
|
||||
assignment_request(&identity, &forged.worker_id, &ticket, "forged-assign"),
|
||||
assignment_request(&api, &identity, &forged.worker_id, &ticket, "forged-assign"),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&forged.worker_id),
|
||||
@@ -376,7 +413,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-account-owner").await;
|
||||
let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other");
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":test_assignment_principal(&api)})).unwrap();
|
||||
let request = |token: &str, method: &str, path: &str, body: Vec<u8>| {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
@@ -459,7 +496,7 @@ async fn assign_tracking(
|
||||
let value = tracking_response(build_router(api.clone()), runtime_source_request(
|
||||
identity, Some(&source.worker_id), "PUT",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"),
|
||||
serde_json::to_vec(&serde_json::json!({ "operation_id": "assign-tracking", "principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" } })).unwrap(),
|
||||
serde_json::to_vec(&serde_json::json!({ "operation_id": "assign-tracking", "principal": test_assignment_principal(&api) })).unwrap(),
|
||||
), StatusCode::OK).await;
|
||||
assert_eq!(
|
||||
value["assignment"]["assigned_by"],
|
||||
@@ -755,61 +792,14 @@ async fn tracking_fixture(
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, runtime_id);
|
||||
let memory = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||
.unwrap();
|
||||
let reservation = api
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
runtime_id,
|
||||
"tracking-orchestrator",
|
||||
"sha256:tracking-orchestrator",
|
||||
Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY),
|
||||
&memory,
|
||||
)
|
||||
.unwrap();
|
||||
let mut summary = WorkdirlessFixtureRuntime::worker_summary(
|
||||
WorkerCreateBinding {
|
||||
worker_id: reservation.worker_id,
|
||||
create_fingerprint: reservation.create_fingerprint,
|
||||
},
|
||||
&WorkerSpawnRequest {
|
||||
requested_worker_name: Some("Tracking Orchestrator".into()),
|
||||
singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()),
|
||||
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:orchestrator".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachments: Vec::new(),
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
);
|
||||
summary.tags = vec!["orchestrator".into(), "singleton".into()];
|
||||
let source = summary.worker.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
seed_worker_source_member(&api, runtime_id, &source.worker_id);
|
||||
api.config_store
|
||||
.complete_worker_create_reservation(TEST_WORKSPACE_ID, source.worker_id.parse().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(find_workspace_orchestrator(&api).unwrap().worker, source);
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let source = create_test_assignment_body(&api, runtime_id, &subject_id).await;
|
||||
assert_eq!(test_assignment_body(&api).unwrap().worker, source);
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Tracking authority"))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
|
||||
(api, runtime, identity, source, ticket.id)
|
||||
}
|
||||
|
||||
@@ -922,7 +912,7 @@ async fn tracking_unassign_and_reassign_keep_results_and_fence_old_assignment()
|
||||
.await,
|
||||
result
|
||||
);
|
||||
tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"assign-again", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await;
|
||||
tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"assign-again", "principal":test_assignment_principal(&api)})).unwrap()), StatusCode::OK).await;
|
||||
let next = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
|
||||
@@ -1003,7 +993,7 @@ async fn tracking_other_workspace_routes_and_unsigned_reads_are_rejected() {
|
||||
#[tokio::test]
|
||||
async fn replaced_orchestrator_cannot_update_or_replay_and_current_replacement_inherits_tracking() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let (api, identity, source, ticket) = tracking_embedded_fixture(dir.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let update = tracking_update(¤t, "old-orchestrator-result");
|
||||
let app = build_router(api.clone());
|
||||
@@ -1013,18 +1003,7 @@ async fn replaced_orchestrator_cannot_update_or_replay_and_current_replacement_i
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
|
||||
seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id);
|
||||
let mut summary = runtime.workers.lock().unwrap()[0].clone();
|
||||
summary.worker = replacement.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
// Seed the authoritative replacement, not merely a Runtime tag or model role.
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap();
|
||||
assert_eq!(
|
||||
find_workspace_orchestrator(&api).unwrap().worker,
|
||||
replacement
|
||||
);
|
||||
let replacement = replace_tracking_orchestrator(&api, &source).await;
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update),
|
||||
@@ -1111,7 +1090,7 @@ async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_r
|
||||
assert_eq!(replay, current);
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "assignment-replay-user").await;
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":test_assignment_principal(&api)})).unwrap();
|
||||
tracking_response(
|
||||
build_router(api.clone()),
|
||||
Request::builder()
|
||||
@@ -1261,7 +1240,7 @@ async fn assignment_receipt_cannot_be_replayed_under_a_new_runtime_binding() {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-assignment-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap();
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":test_assignment_principal(&api)})).unwrap();
|
||||
tracking_response(
|
||||
build_router(api.clone()),
|
||||
runtime_source_request(&identity, Some(&source.worker_id), "PUT", &path, body),
|
||||
@@ -1377,7 +1356,7 @@ async fn released_assignment_query_retains_principal_assign_audit_and_last_track
|
||||
assert_eq!(released["role"], "orchestrator");
|
||||
assert_eq!(
|
||||
released["principal"],
|
||||
serde_json::json!({"kind":"workspace_agent","agent_key":"workspace-orchestrator"})
|
||||
serde_json::json!(test_assignment_principal(&api))
|
||||
);
|
||||
assert_eq!(released["assigned_by"], current.assigned_by);
|
||||
assert_eq!(released["assigned_at"], current.assigned_at);
|
||||
@@ -1407,6 +1386,11 @@ async fn released_assignment_query_retains_principal_assign_audit_and_last_track
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Different Ticket"))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(
|
||||
&api,
|
||||
&ensure_test_assignment_subject(&api),
|
||||
&other_ticket.id,
|
||||
);
|
||||
assert_eq!(
|
||||
tracking_response(
|
||||
app,
|
||||
@@ -1441,7 +1425,7 @@ async fn same_principal_reassignment_retains_old_result_and_projects_replacement
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let assignment = tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"request-again", "expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await;
|
||||
let assignment = tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"request-again", "expected_assignment_id":current.assignment_id,"principal":test_assignment_principal(&api)})).unwrap()), StatusCode::OK).await;
|
||||
let next_id = assignment["assignment"]["assignment_id"].as_str().unwrap();
|
||||
assert_ne!(next_id, current.assignment_id);
|
||||
let old = tracking_response(
|
||||
@@ -1494,7 +1478,7 @@ async fn assignment_signed_runtime_without_worker_cannot_self_assign_service_rol
|
||||
let (api, _, identity, _, ticket) = tracking_fixture(dir.path()).await;
|
||||
let input = serde_json::json!({
|
||||
"operation_id": "runtime-only-assign",
|
||||
"principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" }
|
||||
"principal": test_assignment_principal(&api)
|
||||
});
|
||||
tracking_response(
|
||||
build_router(api.clone()),
|
||||
@@ -1595,7 +1579,7 @@ async fn public_assignment_answer_authenticates_source_replays_saved_result_and_
|
||||
.await;
|
||||
let assignment_path =
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()),StatusCode::OK).await;
|
||||
tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":test_assignment_principal(&api)})).unwrap()),StatusCode::OK).await;
|
||||
let mut stale = body.clone();
|
||||
stale["operation_key"] = "fresh-stale-answer".into();
|
||||
tracking_response(app.clone(), account_request(&stale), StatusCode::CONFLICT).await;
|
||||
@@ -1709,11 +1693,11 @@ async fn ticket_query_principal_and_handling_filter_before_single_item_page_with
|
||||
.create(ticket::NewTicket::new(format!("Unassigned newer {n}")))
|
||||
.unwrap();
|
||||
}
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap();
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":test_assignment_principal(&api),"limit":1})).unwrap();
|
||||
let found = api.authority.query_tickets(query).unwrap();
|
||||
assert_eq!(found.items.len(), 1);
|
||||
assert_eq!(found.items[0].id, ticket);
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap();
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":test_assignment_principal(&api),"limit":1})).unwrap();
|
||||
assert!(api.authority.query_tickets(query).unwrap().items.is_empty());
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"assigned_principal":{"kind":"worker","runtime_id":source.runtime_id,"worker_id":source.worker_id},"limit":1})).unwrap();
|
||||
assert!(api.authority.query_tickets(query).unwrap().items.is_empty());
|
||||
@@ -2062,3 +2046,491 @@ async fn closure_reason_filter_matches_current_judgment_before_paging_and_text_s
|
||||
reference.id
|
||||
);
|
||||
}
|
||||
|
||||
// FeatureStorage policy, body singleton ownership and signed Runtime HTTP are real.
|
||||
// Bodies are Workdir-free. Connected concurrent delegate/tool execution is tested
|
||||
// separately in subject_authorization_tests, not claimed by this identity test.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn two_subjects_signed_assignment_isolates_current_subjects_and_stale_receipts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
for branch in ["subject-a-work", "subject-b-work"] {
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir.path())
|
||||
.args(["branch", branch])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
let (mut api, _execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "parallel-subject-owner").await;
|
||||
let app = build_router(api.clone());
|
||||
let mut cases = Vec::new();
|
||||
for (name, branch) in [
|
||||
("subject-a", "subject-a-work"),
|
||||
("subject-b", "subject-b-work"),
|
||||
] {
|
||||
let subject = open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.create_subject_with_behavior(
|
||||
crate::subjektiv::SubjectRole::new(name).unwrap(),
|
||||
format!("Own only {name}'s Ticket."),
|
||||
)
|
||||
.unwrap();
|
||||
let mut input = ticket::NewTicket::new(name);
|
||||
set_test_ticket_target(&mut input, "test-repository", branch);
|
||||
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
|
||||
grant_test_assignment_ticket(&api, &subject.id, &ticket.id);
|
||||
let created = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||
Some(
|
||||
json!({"runtime_id":EMBEDDED_WORKER_RUNTIME_ID, "display_name":name,
|
||||
"profile":"builtin:companion", "initial_submit":[],
|
||||
"feature_connections":{"subjektiv":{"subject_id":subject.id}},
|
||||
"workdir_attachments":[]}),
|
||||
),
|
||||
&token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let body = RuntimeWorkerRef::new(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
created["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
let assign_path = format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/orchestrator",
|
||||
ticket.id
|
||||
);
|
||||
let assign = json!({"operation_id":format!("assign-{name}"),
|
||||
"principal":{"kind":"subject", "subject_id":subject.id}});
|
||||
let receipt = tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&body.worker_id),
|
||||
"PUT",
|
||||
&assign_path,
|
||||
serde_json::to_vec(&assign).unwrap(),
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(receipt["assignment"]["principal"], assign["principal"]);
|
||||
assert_eq!(receipt["assignment"]["role"], "orchestrator");
|
||||
let current = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket.id)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_eq!(
|
||||
current.tracking.as_ref().unwrap().status,
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &body)
|
||||
.unwrap();
|
||||
assert!(links.is_empty());
|
||||
let key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap();
|
||||
assert_eq!(
|
||||
api.store
|
||||
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &key)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.worker,
|
||||
body
|
||||
);
|
||||
cases.push((subject.id, ticket.id, body, current));
|
||||
}
|
||||
assert_ne!(cases[0].0, cases[1].0);
|
||||
assert_ne!(cases[0].2, cases[1].2);
|
||||
assert!(
|
||||
api.store
|
||||
.current_worker_singleton_owner(
|
||||
TEST_WORKSPACE_ID,
|
||||
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let (subject_a, ticket_a, body_a, current_a) = &cases[0];
|
||||
let (subject_b, ticket_b, body_b, current_b) = &cases[1];
|
||||
let update_a = tracking_update(current_a, "a-result");
|
||||
// Exact operation grant is required even for a live body of another Subject.
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &update_a),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
grant_test_assignment_ticket(&api, subject_b, ticket_a);
|
||||
// Granting the operation still cannot impersonate the assigned Subject.
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &update_a),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
let saved = tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_a.worker_id), ticket_a, &update_a),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_b, ¤t_b.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
*current_b
|
||||
);
|
||||
// Same Subject body may author reassignment if granted, but ownership of A
|
||||
// immediately moves to B. A's old done receipt remains evidence, not authority.
|
||||
let reassign = json!({"operation_id":"a-to-b", "expected_assignment_id":current_a.assignment_id,
|
||||
"principal":{"kind":"subject", "subject_id":subject_b}});
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket_a}/assignments/orchestrator");
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&body_a.worker_id),
|
||||
"PUT",
|
||||
&path,
|
||||
serde_json::to_vec(&reassign).unwrap(),
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let next = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_a)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_ne!(next.assignment_id, current_a.assignment_id);
|
||||
let fresh = tracking_update(&next, "old-body-new-delegation");
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_a.worker_id), ticket_a, &fresh),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
let mut forged_receipt = update_a.clone();
|
||||
forged_receipt.assignment_id = next.assignment_id.clone();
|
||||
forged_receipt.expected_last_event_id = next.last_event_id.clone().unwrap();
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(
|
||||
&identity,
|
||||
Some(&body_b.worker_id),
|
||||
ticket_a,
|
||||
&forged_receipt,
|
||||
),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
// A retained self-report receipt is never rewritten to point at the new row.
|
||||
assert_eq!(saved["assignment_id"], current_a.assignment_id);
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&body_b.worker_id),
|
||||
"DELETE",
|
||||
&assignment_clear_path(ticket_a, &next.assignment_id, "clear-a-from-b"),
|
||||
Vec::new(),
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let after_clear = tracking_update(&next, "after-clear");
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &after_clear),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
api.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_a)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_b, ¤t_b.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
*current_b
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(ticket_a).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(ticket_b).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
);
|
||||
assert_eq!(
|
||||
open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.subject(subject_a)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.behavior_md,
|
||||
"Own only subject-a's Ticket."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_operation_policy_revocation_rejects_saved_mutation_receipts_without_releasing_assignment()
|
||||
{
|
||||
use server_api::authorization::SubjectPermissionsUpdate;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let input = tracking_update(¤t, "result-before-policy-revocation");
|
||||
let app = build_router(api.clone());
|
||||
let saved = tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &input),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let before = api
|
||||
.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let store = open_subjektiv_store(&api).unwrap();
|
||||
let policy = store.subject_permissions(&subject_id).unwrap().unwrap();
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "revoke-assignment-operations".into(),
|
||||
expected_change_id: Some(policy.change_id),
|
||||
execution: vec![],
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let answer = json!({"assignment_id":current.assignment_id,
|
||||
"expected_last_event_id":saved["last_event_id"], "operation_key":"answer-after-revoke", "body":"No authority"});
|
||||
let answer_path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
|
||||
for request in [
|
||||
assignment_request(
|
||||
&api,
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
&ticket,
|
||||
"assign-tracking",
|
||||
),
|
||||
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &input),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&source.worker_id),
|
||||
"DELETE",
|
||||
&assignment_clear_path(&ticket, ¤t.assignment_id, "clear-after-revoke"),
|
||||
Vec::new(),
|
||||
),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&source.worker_id),
|
||||
"POST",
|
||||
&answer_path,
|
||||
serde_json::to_vec(&answer).unwrap(),
|
||||
),
|
||||
] {
|
||||
tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await;
|
||||
}
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(before.current);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_ticket_assignment_tracking_history(
|
||||
TEST_WORKSPACE_ID,
|
||||
&ticket,
|
||||
¤t.assignment_id
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_delegated_worker_authors_with_operation_grants_but_cannot_self_report_subject_handling()
|
||||
{
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, body, ticket) = tracking_fixture(dir.path()).await;
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let store = open_subjektiv_store(&api).unwrap();
|
||||
let policy = store.subject_permissions(&subject_id).unwrap().unwrap();
|
||||
let permissions: Vec<_> = [
|
||||
"ticket.assignment.assign",
|
||||
"ticket.assignment.clear",
|
||||
"ticket.assignment.answer",
|
||||
"ticket.assignment.update",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|operation| ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Ticket {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket.clone(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let policy = store
|
||||
.set_subject_permissions(
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "allow-generic-assignment-author".into(),
|
||||
expected_change_id: Some(policy.change_id),
|
||||
execution: policy.execution,
|
||||
delegation: permissions.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_router(api.clone());
|
||||
// Existing Worker control/FeatureStorage grant path, not a Ticket executor
|
||||
// or a role fixture. No implementation start or T-803 integration is claimed.
|
||||
let child = tracking_response(app.clone(), runtime_source_request(&identity, Some(&body.worker_id), "POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"),
|
||||
serde_json::to_vec(&json!({"runtime_id":body.runtime_id, "display_name":"Generic assignment author",
|
||||
"profile":"builtin:intake", "initial_submit":[], "workdir_attachments":[], "feature_connections":{},
|
||||
"control_operation_id":"spawn-generic-author",
|
||||
"execution_grant":{"permissions":permissions, "redelegation":[]}})).unwrap()), StatusCode::OK).await;
|
||||
let worker_id = child["worker_id"].as_str().unwrap();
|
||||
assert!(child["worker"]["singleton_key"].is_null());
|
||||
assert_eq!(child["worker"]["profile"], "builtin:intake");
|
||||
let saved = tracking_response(
|
||||
app.clone(),
|
||||
assignment_request(&api, &identity, worker_id, &ticket, "generic-assign"),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let current = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_eq!(
|
||||
saved["assignment"]["principal"],
|
||||
test_assignment_principal(&api)
|
||||
);
|
||||
assert_eq!(
|
||||
saved["assignment"]["assigned_by"],
|
||||
format!("worker:{}/{worker_id}", body.runtime_id)
|
||||
);
|
||||
// Operation policy alone never makes a background Worker the Subject body.
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
&ticket,
|
||||
&tracking_update(¤t, "child-cannot-report"),
|
||||
),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let answer = json!({"assignment_id":current.assignment_id,
|
||||
"expected_last_event_id":current.last_event_id, "operation_key":"generic-answer", "body":"Additional authorized input"});
|
||||
let answer_path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
|
||||
let receipt = tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
"POST",
|
||||
&answer_path,
|
||||
serde_json::to_vec(&answer).unwrap(),
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
"POST",
|
||||
&answer_path,
|
||||
serde_json::to_vec(&answer).unwrap()
|
||||
),
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
receipt
|
||||
);
|
||||
let clear = assignment_clear_path(&ticket, ¤t.assignment_id, "generic-clear");
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(&identity, Some(worker_id), "DELETE", &clear, Vec::new()),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "revoke-generic-author".into(),
|
||||
expected_change_id: Some(policy.change_id),
|
||||
execution: policy.execution,
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for request in [
|
||||
assignment_request(&api, &identity, worker_id, &ticket, "generic-assign"),
|
||||
runtime_source_request(&identity, Some(worker_id), "DELETE", &clear, Vec::new()),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
"POST",
|
||||
&answer_path,
|
||||
serde_json::to_vec(&answer).unwrap(),
|
||||
),
|
||||
] {
|
||||
tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await;
|
||||
}
|
||||
let retained = api
|
||||
.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!retained.current);
|
||||
assert_eq!(
|
||||
retained.tracking.unwrap().status,
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ async fn result_signed_http(
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
async fn assert_result_diagnostics(api: &WorkspaceApi, recipient: &RuntimeWorkerRef, result_id: &str) {
|
||||
async fn assert_result_diagnostics(
|
||||
api: &WorkspaceApi,
|
||||
recipient: &RuntimeWorkerRef,
|
||||
result_id: &str,
|
||||
) {
|
||||
let page = delivery_http(api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?source_event_id=implementation-result:{result_id}"), None, StatusCode::OK).await;
|
||||
let rows = page["items"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
@@ -43,7 +47,8 @@ async fn assert_result_diagnostics(api: &WorkspaceApi, recipient: &RuntimeWorker
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr() {
|
||||
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr()
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut api, execution, _) = delivery_real_fixture(dir.path(), false).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
|
||||
@@ -73,7 +78,15 @@ async fn implementation_result_signed_http_reaches_persistent_orchestrator_witho
|
||||
let receipt =
|
||||
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await;
|
||||
// Another authenticated Worker may report, but cannot replay this Workers receipt.
|
||||
result_signed_http(&api, &identity, &orchestrator, &ticket.id, &input, StatusCode::CONFLICT).await;
|
||||
result_signed_http(
|
||||
&api,
|
||||
&identity,
|
||||
&orchestrator,
|
||||
&ticket.id,
|
||||
&input,
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
delivery_wait_idle(&api, &orchestrator, 2).await;
|
||||
assert_eq!(
|
||||
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await,
|
||||
@@ -224,9 +237,22 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
|
||||
})
|
||||
.unwrap();
|
||||
// No mutation callback: a diagnostic read must expose, but not route, saved MR sources.
|
||||
let pending = delivery_http(&api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"), None, StatusCode::OK).await;
|
||||
let pending = delivery_http(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(pending["items"].as_array().unwrap().len(), 4);
|
||||
assert!(pending["items"].as_array().unwrap().iter().all(|row| row["stage"] == "awaiting_routing"));
|
||||
assert!(
|
||||
pending["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|row| row["stage"] == "awaiting_routing")
|
||||
);
|
||||
assert_eq!(pending["source_checks"][0]["origin_present"], true);
|
||||
assert_eq!(pending["source_checks"][0]["captured"], true);
|
||||
recover_ticket_notifications(&api);
|
||||
@@ -325,10 +351,26 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), attempts);
|
||||
assert_eq!(delivery_receipts(&api, &orchestrator).len(), 10);
|
||||
assert_result_diagnostics(&api, &orchestrator, receipt["result_id"].as_str().unwrap()).await;
|
||||
let diagnostics = delivery_http(&api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"), None, StatusCode::OK).await;
|
||||
let diagnostics = delivery_http(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let rows = diagnostics["items"].as_array().unwrap();
|
||||
for kind in ["mr_review_requested", "mr_review", "mr_review_revoked", "mr_review_cancelled", "mr_source_changed"] {
|
||||
let matching: Vec<_> = rows.iter().filter(|r| r["event_kind"] == kind && r["recipient_role"] == "orchestrator").collect();
|
||||
for kind in [
|
||||
"mr_review_requested",
|
||||
"mr_review",
|
||||
"mr_review_revoked",
|
||||
"mr_review_cancelled",
|
||||
"mr_source_changed",
|
||||
] {
|
||||
let matching: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|r| r["event_kind"] == kind && r["recipient_role"] == "orchestrator")
|
||||
.collect();
|
||||
assert!(!matching.is_empty(), "missing diagnostic for {kind}");
|
||||
for row in matching {
|
||||
assert_eq!(row["stage"], "runtime_accepted");
|
||||
@@ -337,9 +379,16 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
|
||||
}
|
||||
}
|
||||
for kind in ["mr_review_requested", "mr_review_revoked"] {
|
||||
assert!(rows.iter().any(|r| r["event_kind"] == kind && r["recipient_role"] == "worker" && r["stage"] == "suppressed" && r["reason"] == "self_notification"));
|
||||
assert!(rows.iter().any(|r| r["event_kind"] == kind
|
||||
&& r["recipient_role"] == "worker"
|
||||
&& r["stage"] == "suppressed"
|
||||
&& r["reason"] == "self_notification"));
|
||||
}
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), attempts, "diagnostic reads must not dispatch");
|
||||
assert_eq!(
|
||||
execution.attempts.lock().unwrap().len(),
|
||||
attempts,
|
||||
"diagnostic reads must not dispatch"
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket.id).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
|
||||
@@ -412,3 +412,32 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog
|
||||
assert_eq!(bytes, package_bytes("0.1.0"));
|
||||
shutdown_workspace_feature_storage(&reopened).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() {
|
||||
let dir=tempfile::tempdir().unwrap();
|
||||
let api=test_api(dir.path()).await;
|
||||
let owner=seed_test_workspace_owner_token(api.store.as_ref(),"plugin-config-owner").await;
|
||||
let reader=seed_test_api_token(api.store.as_ref(),"plugin-config-reader");
|
||||
let app=build_router(api.clone());
|
||||
let prefix=format!("/api/w/{TEST_WORKSPACE_ID}");
|
||||
let upload=request(app.clone(),Some(&owner),"POST",&format!("{prefix}/plugin-uploads"),package_bytes("0.1.0"),true,StatusCode::OK).await;
|
||||
let installed=json(app.clone(),&owner,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"configured","operation_key":"config-install"}),StatusCode::OK).await;
|
||||
let state=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await;
|
||||
let a=&installed["installation"]["artifacts"][0];
|
||||
let source=format!("{{ plugins.enabled.echo = {{ installation_id = {}; expected_last_operation = {}; package_digest = {}; artifact_digest = {}; tools = [\"example_echo\"]; config = {{}}; }}; }} as WorkspaceConfigSchema",installed["installation"]["installation_id"],installed["operation_id"],a["package_digest"],a["artifact_digest"]);
|
||||
let change=serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]});
|
||||
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::FORBIDDEN).await;
|
||||
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::FORBIDDEN).await;
|
||||
let preview=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::OK).await;
|
||||
let unchanged=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await;
|
||||
assert_eq!(unchanged["snapshot"]["digest"],state["snapshot"]["digest"]);
|
||||
let saved=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::CREATED).await;
|
||||
assert_eq!(saved,preview);
|
||||
assert_ne!(saved["snapshot"]["digest"],state["snapshot"]["digest"]);
|
||||
json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change,StatusCode::CONFLICT).await;
|
||||
json(app.clone(),&owner,&format!("{prefix}/plugins/{}/uninstall",installed["installation"]["installation_id"].as_str().unwrap()),serde_json::json!({"expected_last_operation":installed["operation_id"],"operation_key":"config-blocks-uninstall"}),StatusCode::CONFLICT).await;
|
||||
let current=api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
|
||||
let candidates=current.contract.schema_bundle.contributions.iter().filter(|c|c.provider_id=="builtin:plugins").count();
|
||||
assert_eq!(candidates,1);
|
||||
}
|
||||
|
||||
@@ -715,7 +715,7 @@ async fn worker_control_source_lookup_waits_off_tokio_and_retains_finite_host_ow
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free() {
|
||||
async fn ticket_tracking_authority_and_case_audit_use_local_state_without_runtime_lookup() {
|
||||
for operation in ["tracking", "case"] {
|
||||
let fixture = fixture().await;
|
||||
let ticket = browser_ticket_backend(&fixture.api)
|
||||
@@ -767,6 +767,9 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
|
||||
};
|
||||
let backend = browser_ticket_backend(&fixture.api).unwrap();
|
||||
let before = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
|
||||
// Tracking authority and case attribution are now local Subject/assignment
|
||||
// reads, not observations of a singleton Runtime Worker. Keep a held
|
||||
// Runtime endpoint to detect an accidental return to network authority.
|
||||
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let api = fixture.api.clone();
|
||||
let ticket_id = ticket.id.clone();
|
||||
@@ -819,8 +822,24 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
|
||||
},
|
||||
));
|
||||
assert!(futures::poll!(&mut decision).is_pending());
|
||||
let request = observed(reply.entered.take().unwrap()).await.unwrap();
|
||||
assert!(request.contains("/v1/workers/"), "{operation}: {request}");
|
||||
let outcome = observed(decision.as_mut()).await;
|
||||
if operation == "tracking" {
|
||||
let error = outcome.unwrap_err();
|
||||
assert!(
|
||||
error.error.to_string().contains("Subject body"),
|
||||
"{error:?}"
|
||||
);
|
||||
} else {
|
||||
outcome.unwrap();
|
||||
}
|
||||
assert_eq!(leases.load(Ordering::SeqCst), 0);
|
||||
assert!(
|
||||
matches!(
|
||||
reply.entered.as_mut().unwrap().try_recv(),
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
|
||||
),
|
||||
"local assignment authority/audit must not observe Runtime"
|
||||
);
|
||||
let store = fixture.store.clone();
|
||||
observed(
|
||||
fixture
|
||||
@@ -836,7 +855,6 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
|
||||
observed(tokio::time::sleep(StdDuration::from_millis(1))).await;
|
||||
assert!(!reply.upstream_released.load(Ordering::SeqCst));
|
||||
drop(decision);
|
||||
assert_eq!(leases.load(Ordering::SeqCst), 1);
|
||||
reply.finish();
|
||||
observed(fixture.host_executor.drain()).await.unwrap();
|
||||
observed(fixture.executor.drain()).await.unwrap();
|
||||
@@ -848,8 +866,8 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
|
||||
"tracking still requires live assignment authority"
|
||||
);
|
||||
} else {
|
||||
// Profile-authorized case changes do not depend on a live
|
||||
// Orchestrator lookup. Its failure only affects audit role context.
|
||||
// Case changes do not depend on a remote singleton lookup.
|
||||
// The local assignment-only audit falls back to an ordinary Worker.
|
||||
assert_eq!(after.meta.workflow_state, ticket::TicketState::Closed);
|
||||
assert_eq!(
|
||||
after.events.last().unwrap().attributes["source_actor_role"],
|
||||
@@ -890,6 +908,18 @@ async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_contr
|
||||
released,
|
||||
};
|
||||
observed(waiting).await.unwrap(); // actual SQL transaction owns the Feature connection
|
||||
// Force close to be queued before the sole DB worker can start it. Pending
|
||||
// admission alone must not be mistaken for an available second queue slot.
|
||||
let (started, running) = oneshot::channel();
|
||||
let (start_close, held) = mpsc::channel();
|
||||
fixture
|
||||
.executor
|
||||
.dispatch(move || {
|
||||
let _ = started.send(());
|
||||
let _ = held.recv_timeout(WATCHDOG);
|
||||
})
|
||||
.unwrap();
|
||||
observed(running).await.unwrap();
|
||||
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut close = Box::pin(crate::db_executor::with_operation_lifetime(
|
||||
Arc::new(CountJobLifetime(leases.clone())),
|
||||
@@ -899,6 +929,30 @@ async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_contr
|
||||
futures::poll!(&mut close).is_pending(),
|
||||
"Feature connection close must not wait on Tokio"
|
||||
);
|
||||
assert!(matches!(
|
||||
fixture.executor.dispatch(|| {}),
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
start_close.send(()).unwrap();
|
||||
let fenced = observed(async {
|
||||
loop {
|
||||
match fixture
|
||||
.api
|
||||
.feature_storage
|
||||
.open(&fixture.api.subjektiv_registration)
|
||||
{
|
||||
Err(crate::FeatureStorageError::ShuttingDown) => break,
|
||||
Ok(_) => tokio::task::yield_now().await,
|
||||
Err(error) => panic!("unexpected Feature admission failure: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
tokio::select! {
|
||||
_ = fenced => {},
|
||||
result = &mut close => panic!("close completed while its connection is held: {result:?}"),
|
||||
}
|
||||
// Observing the real shutdown fence proves close was dequeued and started;
|
||||
// only now can the one waiting slot be filled deterministically.
|
||||
runtime_progress_while_locked(&lock).await;
|
||||
assert_eq!(
|
||||
observed(fixture.host_executor.run(|| Ok::<_, Error>("control")))
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Included as a child module of server::tests by the parent change.
|
||||
// Component regression: real Subject connections/singleton ownership, policy grants,
|
||||
// Ticket transactions, routing, recovery, request persistence and ACK admission.
|
||||
// DeterministicExecutionBackend records Runtime inputs instead of running a real
|
||||
// Controller/model. Runtime acceptance here is NOT live-model processing evidence.
|
||||
use super::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_notification_recovery_drains_two_recipients_beyond_global_scan_limit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let (api, execution) = assignment_test_api_with_recording_backend(dir.path()).await;
|
||||
seed_test_delivery_binding(&api);
|
||||
let subject_store = open_subjektiv_store(&api).unwrap();
|
||||
let mut recipients = Vec::new();
|
||||
for role in ["recovery-alpha", "recovery-beta"] {
|
||||
let subject = subject_store
|
||||
.create_subject_with_behavior(
|
||||
crate::subjektiv::SubjectRole::new(role).unwrap(),
|
||||
"Handle assigned Tickets; delivery does not prove processing.".into(),
|
||||
)
|
||||
.unwrap();
|
||||
// Normal Subject-connected creation, not a synthetic worker registry row
|
||||
// or a monkey-patched assigned_subject_body/candidate selector.
|
||||
let body = create_test_assignment_body(&api, EMBEDDED_WORKER_RUNTIME_ID, &subject.id).await;
|
||||
bootstrap_test_assignment_session(&api, &body);
|
||||
assert_eq!(
|
||||
current_assignment_subject_source(&api, &body).unwrap(),
|
||||
subject.id
|
||||
);
|
||||
recipients.push((body, subject.id));
|
||||
}
|
||||
// Recovery dispatches recipients in BTreeSet<(runtime_id, worker_id)> order.
|
||||
// UUIDs need not have a predictable creation order: derive alpha/beta from
|
||||
// the ACTUAL body keys, then commit beta's entire block before alpha's.
|
||||
recipients.sort_by_key(|(body, _)| (body.runtime_id.clone(), body.worker_id.clone()));
|
||||
let [(alpha, alpha_subject), (beta, beta_subject)] = recipients.as_slice() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_ne!(alpha_subject, beta_subject);
|
||||
assert_ne!(alpha, beta);
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
assert!(execution.take_input_request_ids().is_empty());
|
||||
|
||||
let mut tickets = BTreeMap::new();
|
||||
for (name, body, subject) in [
|
||||
("beta", beta, beta_subject),
|
||||
("alpha", alpha, alpha_subject),
|
||||
] {
|
||||
for index in 0..20 {
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new(format!(
|
||||
"Recovery {name} {index:02}"
|
||||
)))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(&api, subject, &ticket.id);
|
||||
// Commit through the production store transaction, but do not fire a
|
||||
// mutation wakeup: ONLY production recovery may drain this backlog.
|
||||
api.store
|
||||
.set_current_ticket_role_assignment(
|
||||
&TicketRoleAssignmentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket.id.clone(),
|
||||
assignment_id: format!("recovery-{name}-{index:02}"),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: subject.clone(),
|
||||
},
|
||||
assigned_by: "fixture-owner".into(),
|
||||
assigned_at: TEST_CREATED_AT.into(),
|
||||
},
|
||||
None,
|
||||
&format!("recovery-{name}-{index:02}-event"),
|
||||
&format!("recovery-{name}-{index:02}-assign"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
tickets.insert(ticket.id.clone(), (body.clone(), subject.clone()));
|
||||
}
|
||||
}
|
||||
let pending = api
|
||||
.store
|
||||
.list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pending.len(),
|
||||
40,
|
||||
"all forty inputs must be current and eligible"
|
||||
);
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.filter(|input| tickets[&input.ticket_id].0 == *alpha)
|
||||
.count(),
|
||||
20
|
||||
);
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.filter(|input| tickets[&input.ticket_id].0 == *beta)
|
||||
.count(),
|
||||
20
|
||||
);
|
||||
assert!(
|
||||
pending[..20]
|
||||
.iter()
|
||||
.all(|input| tickets[&input.ticket_id].0 == *beta),
|
||||
"the global first page must belong to the recipient dispatched second"
|
||||
);
|
||||
let expected = pending
|
||||
.into_iter()
|
||||
.map(|input| (input.request_id.clone(), input))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert_eq!(expected.len(), 40);
|
||||
for input in expected.values() {
|
||||
let (body, _) = &tickets[&input.ticket_id];
|
||||
assert_eq!(
|
||||
assigned_subject_body(&api, &input.ticket_id, &input.assignment_id)
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
Some(body)
|
||||
);
|
||||
authorize_assignment_delivery(&api, body, &input.ticket_id).unwrap();
|
||||
}
|
||||
assert!(
|
||||
execution.take_inputs().is_empty(),
|
||||
"fixture setup must not pre-deliver"
|
||||
);
|
||||
|
||||
let mut observed = BTreeMap::new();
|
||||
let mut per_body = BTreeMap::<String, usize>::new();
|
||||
let mut progress = Vec::new();
|
||||
for _ in 0..5 {
|
||||
// Exercise the actual global discovery -> recipient selection -> scoped
|
||||
// attention -> durable reserve/render/admit -> Runtime -> ACK pipeline.
|
||||
notification_delivery::recover_ticket_notifications(&api);
|
||||
let inputs = execution.take_inputs();
|
||||
let request_ids = execution.take_input_request_ids();
|
||||
assert_eq!(inputs.len(), request_ids.len());
|
||||
for ((worker, content), request_id) in inputs.into_iter().zip(request_ids) {
|
||||
let request_id = request_id.expect("recovery must supply a stable request identity");
|
||||
let input = expected
|
||||
.get(&request_id)
|
||||
.expect("unexpected Runtime request");
|
||||
let (body, _) = &tickets[&input.ticket_id];
|
||||
assert_eq!(
|
||||
worker.worker_id.to_string(),
|
||||
body.worker_id,
|
||||
"cross-Subject delivery"
|
||||
);
|
||||
assert!(content.contains(&input.assignment_id));
|
||||
assert!(content.contains(&input.input_event_id));
|
||||
*per_body.entry(body.worker_id.clone()).or_default() += 1;
|
||||
assert!(
|
||||
observed.insert(request_id, content).is_none(),
|
||||
"ACKed input sent twice"
|
||||
);
|
||||
}
|
||||
progress.push((
|
||||
*per_body.get(&alpha.worker_id).unwrap_or(&0),
|
||||
*per_body.get(&beta.worker_id).unwrap_or(&0),
|
||||
));
|
||||
}
|
||||
// Before the recipient-scoped scan fix, 26cc5e930's global scan offers all
|
||||
// 40, alpha scans/offers beta's 20 then post-filters to empty; beta scans
|
||||
// alpha's 20 and also filters to empty. Every recovery repeats (0, 0).
|
||||
assert_eq!(
|
||||
progress.last(),
|
||||
Some(&(20, 20)),
|
||||
"both connected Subjects must drain, progress by recovery round: {progress:?}"
|
||||
);
|
||||
assert_eq!(observed.len(), 40);
|
||||
assert!(
|
||||
api.store
|
||||
.list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Inspect actual persisted receipts, not a reimplementation of selection SQL.
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let mut acknowledged = BTreeSet::new();
|
||||
for (request_id, input) in &expected {
|
||||
let (body, subject) = &tickets[&input.ticket_id];
|
||||
let (content, source): (String, String) = conn.query_row(
|
||||
"SELECT r.content,a.source_json FROM ticket_assignment_delivery_requests r JOIN ticket_assignment_delivery_acknowledgements a ON a.workspace_id=r.workspace_id AND a.delivery_id=r.delivery_id WHERE r.workspace_id=?1 AND r.delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, input.delivery_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).unwrap();
|
||||
let source: Value = serde_json::from_str(&source).unwrap();
|
||||
assert_eq!(source["subject_id"], *subject);
|
||||
assert_eq!(source["worker_id"], body.worker_id);
|
||||
assert_eq!(source["runtime_id"], body.runtime_id);
|
||||
assert_eq!(
|
||||
source["authentication_binding_id"],
|
||||
orchestrator_delivery_target(&api, body)
|
||||
.unwrap()
|
||||
.authentication_binding_id
|
||||
);
|
||||
assert_eq!(
|
||||
&content, &observed[request_id],
|
||||
"persisted request bytes differ from Runtime input"
|
||||
);
|
||||
acknowledged.insert(input.delivery_id.clone());
|
||||
}
|
||||
assert_eq!(acknowledged.len(), 40);
|
||||
let ack_count: usize = conn.query_row(
|
||||
"SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1",
|
||||
[TEST_WORKSPACE_ID], |row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(ack_count, 40);
|
||||
|
||||
// Lose the process-local attention cache, then recover repeatedly. SQLite
|
||||
// ACKs, not local accepted/pending memory or a fake Runtime deduplicator,
|
||||
// must close new-send admission for every delivered input.
|
||||
for (body, _) in &recipients {
|
||||
*orchestrator_attention_state(&api, body).lock().unwrap() =
|
||||
OrchestratorAttentionState::default();
|
||||
}
|
||||
for _ in 0..3 {
|
||||
notification_delivery::recover_ticket_notifications(&api);
|
||||
assert!(
|
||||
execution.take_inputs().is_empty(),
|
||||
"recovery resent ACKed history"
|
||||
);
|
||||
assert!(execution.take_input_request_ids().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,8 @@ impl TicketSurface {
|
||||
.await?;
|
||||
if name == "ShowTicket" {
|
||||
let mut page = serde_json::Map::new();
|
||||
for (tool_field, native_field) in [("event_cursor", "cursor"), ("event_limit", "limit")] {
|
||||
for (tool_field, native_field) in [("event_cursor", "cursor"), ("event_limit", "limit")]
|
||||
{
|
||||
if let Some(value) = input.as_object_mut().unwrap().remove(tool_field) {
|
||||
page.insert(native_field.into(), value);
|
||||
}
|
||||
@@ -451,12 +452,29 @@ async fn reviewer_reads_saved_related_tickets_by_key_and_id_and_pages_their_thre
|
||||
let related_before = backend.show(related.id.clone().into()).unwrap();
|
||||
let bound_key = bound.meta.resource_key.as_deref().unwrap();
|
||||
let related_key = related.resource_key.as_deref().unwrap();
|
||||
// A relation and Reviewer profile do not grant access to its target.
|
||||
// Exercise the signed denial first, then grant only the related read.
|
||||
let transport = ticket_client(&api, identity, &source);
|
||||
// One client must retain its review binding while following related Tickets.
|
||||
let client = reviewer_client(transport.clone(), &bound_id);
|
||||
let surface =
|
||||
TicketSurface::with_access(dir.path(), client, wip, TicketFeatureAccess::review())
|
||||
.await;
|
||||
let denied = surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id": related_key}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(denied.to_string().contains("403"), "{denied}");
|
||||
grant_test_ticket_operations(
|
||||
&api,
|
||||
&ensure_test_assignment_subject(&api),
|
||||
&related.id,
|
||||
&["ticket.read"],
|
||||
);
|
||||
for (reference, expected_key, expected_title, expected_body) in [
|
||||
(
|
||||
bound_key,
|
||||
@@ -684,13 +702,26 @@ async fn implementation_result_tool_and_wip_keep_host_owned_retry_identity_witho
|
||||
);
|
||||
assert_eq!(receipt["request"]["body"], input["body"]);
|
||||
assert_eq!(report_counts(&api), (1, 1));
|
||||
let queried = surface
|
||||
let query = serde_json::json!({"ticket":ticket,"result_id":receipt["result_id"]});
|
||||
let denied = surface
|
||||
.call(
|
||||
"TicketImplementationResultQuery",
|
||||
serde_json::json!({"ticket":ticket,"result_id":receipt["result_id"]}),
|
||||
context("query"),
|
||||
query.clone(),
|
||||
context("query-denied"),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(denied.to_string().contains("403"), "{denied}");
|
||||
// Writing a result does not implicitly authorize reading it.
|
||||
grant_test_ticket_operations(
|
||||
&api,
|
||||
&ensure_test_assignment_subject(&api),
|
||||
&ticket,
|
||||
&["ticket.implementation_result.read"],
|
||||
);
|
||||
let queried = surface
|
||||
.call("TicketImplementationResultQuery", query, context("query"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(queried, receipt);
|
||||
let mut changed = input.clone();
|
||||
|
||||
@@ -595,7 +595,10 @@ pub(super) async fn commit(
|
||||
if candidate.base_digest != state.snapshot.digest
|
||||
|| candidate.base_toolchain_fingerprint != state.contract.fingerprint
|
||||
|| candidate.base_projection_digest != state.projection_digest
|
||||
|| candidate.contract.schema_bundle.fingerprint != schema_fingerprint
|
||||
|| crate::plugin_settings::base_schema_bundle(&candidate.contract.schema_bundle)
|
||||
.map_err(pre_error)?
|
||||
.fingerprint
|
||||
!= schema_fingerprint
|
||||
{
|
||||
return Err(fail(409, "stale_validator"));
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ pub use notification_routing::{
|
||||
mod assignment_tracking;
|
||||
#[path = "implementation_result.rs"]
|
||||
mod implementation_result;
|
||||
#[path = "subject_assignment_migration.rs"]
|
||||
mod subject_assignment_migration;
|
||||
pub use assignment_delivery::TicketAssignmentDelivery;
|
||||
pub use assignment_tracking::TicketOrchestratorAssignmentSourceContext;
|
||||
pub use ticket::{
|
||||
@@ -127,7 +129,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 106;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 108;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -465,6 +467,16 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: crate::plugin_catalog::MIGRATION_NAME,
|
||||
apply: crate::plugin_catalog::migrate,
|
||||
},
|
||||
Migration {
|
||||
version: 107,
|
||||
name: "Immutable Worker Plugin plans and retained pins",
|
||||
apply: migrate_plugin_plans_v106_to_v107,
|
||||
},
|
||||
Migration {
|
||||
version: 108,
|
||||
name: "Subject Ticket responsibility and current-body notification routing",
|
||||
apply: migrate_subject_assignments_v107_to_v108,
|
||||
},
|
||||
];
|
||||
|
||||
// Both schema-103 identities shipped. Keep the alternate implementation and accept
|
||||
@@ -964,6 +976,9 @@ impl TicketAssignmentRole {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TicketAssignmentPrincipal {
|
||||
Subject {
|
||||
subject_id: String,
|
||||
},
|
||||
User {
|
||||
account_id: String,
|
||||
},
|
||||
@@ -979,6 +994,7 @@ pub enum TicketAssignmentPrincipal {
|
||||
impl TicketAssignmentPrincipal {
|
||||
fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Subject { .. } => "subject",
|
||||
Self::User { .. } => "user",
|
||||
Self::Worker { .. } => "worker",
|
||||
Self::WorkspaceAgent { .. } => "workspace_agent",
|
||||
@@ -2251,6 +2267,13 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<TicketAssignmentDelivery>>;
|
||||
/// Recipient-scoped scan; other Subjects cannot consume this batch or its offer order.
|
||||
fn list_pending_subject_ticket_assignment_deliveries(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
subject_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<TicketAssignmentDelivery>>;
|
||||
/// Frozen payload/request identity for exact response-loss retry.
|
||||
fn reserve_ticket_assignment_delivery(
|
||||
&self,
|
||||
@@ -2267,6 +2290,14 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
content: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<String>;
|
||||
/// Recheck the current target and ACK immediately before sending pinned bytes.
|
||||
/// None means a positive ACK is already saved, not an unknown acceptance.
|
||||
fn admit_ticket_assignment_delivery_send(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<Option<String>>;
|
||||
fn ack_ticket_assignment_delivery(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -2297,6 +2328,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
content: &str,
|
||||
target: &TicketWorkerNotificationTarget,
|
||||
) -> Result<String>;
|
||||
fn admit_ticket_worker_notification_send(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
target: &TicketWorkerNotificationTarget,
|
||||
) -> Result<Option<String>>;
|
||||
fn ack_ticket_worker_notification(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -9390,6 +9427,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
) -> Result<String> {
|
||||
notification_routing::prepare_worker(self, workspace_id, delivery_id, content, target)
|
||||
}
|
||||
fn admit_ticket_worker_notification_send(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
target: &TicketWorkerNotificationTarget,
|
||||
) -> Result<Option<String>> {
|
||||
notification_routing::admit_worker_send(self, workspace_id, delivery_id, target)
|
||||
}
|
||||
fn ack_ticket_worker_notification(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -9453,7 +9498,21 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let result = assignment_delivery::list_in_tx(&tx, workspace_id, limit)?;
|
||||
let result = assignment_delivery::list_in_tx(&tx, workspace_id, limit, None)?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
fn list_pending_subject_ticket_assignment_deliveries(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
subject_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<TicketAssignmentDelivery>> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let result =
|
||||
assignment_delivery::list_in_tx(&tx, workspace_id, limit, Some(subject_id))?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
@@ -9476,6 +9535,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
) -> Result<String> {
|
||||
assignment_delivery::prepare_content(self, workspace_id, delivery_id, content, source)
|
||||
}
|
||||
fn admit_ticket_assignment_delivery_send(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<Option<String>> {
|
||||
assignment_delivery::admit_send(self, workspace_id, delivery_id, source)
|
||||
}
|
||||
fn ack_ticket_assignment_delivery(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -9871,7 +9938,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { .. } => {
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { .. }
|
||||
| TicketAssignmentPrincipal::Subject { .. } => {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Workspace agent principal cannot occupy the Worker role".to_string(),
|
||||
));
|
||||
@@ -13289,7 +13357,7 @@ impl SqliteWorkspaceStore {
|
||||
)));
|
||||
}
|
||||
if let Some((assignment_id,source))=fence {
|
||||
assignment_tracking::require_runtime_source(&tx,workspace_id,&source.runtime_id,&source.worker_id,&source.agent_key,&source.authentication_binding_id)?;
|
||||
assignment_tracking::require_runtime_source(&tx,workspace_id,&source.runtime_id,&source.worker_id,&source.subject_id,&source.authentication_binding_id)?;
|
||||
assignment_delivery::require_current(&tx,workspace_id,ticket_id,assignment_id)?;
|
||||
}
|
||||
let worker = worker_id.map(|id| RuntimeWorkerRef::new(runtime_id, id));
|
||||
@@ -13413,6 +13481,9 @@ impl SqliteWorkspaceStore {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||
TicketAssignmentPrincipal::Subject { subject_id } => {
|
||||
(Some(subject_id.as_str()), None, None)
|
||||
}
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
(Some(agent_key.as_str()), None, None)
|
||||
}
|
||||
@@ -13619,6 +13690,9 @@ fn read_ticket_role_assignment_record(
|
||||
runtime_id: runtime_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
|
||||
worker_id: worker_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
|
||||
},
|
||||
"subject" => TicketAssignmentPrincipal::Subject {
|
||||
subject_id: principal_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
|
||||
},
|
||||
"workspace_agent" => TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: principal_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
|
||||
},
|
||||
@@ -13687,8 +13761,8 @@ fn validate_ticket_assignment_role_principal(
|
||||
let valid = match role {
|
||||
TicketAssignmentRole::Orchestrator => matches!(
|
||||
principal,
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key }
|
||||
if agent_key == "workspace-orchestrator"
|
||||
TicketAssignmentPrincipal::Subject { subject_id }
|
||||
if !subject_id.trim().is_empty() && subject_id.len() <= 1024 && !subject_id.chars().any(char::is_control)
|
||||
),
|
||||
TicketAssignmentRole::Worker => {
|
||||
matches!(principal, TicketAssignmentPrincipal::Worker { .. })
|
||||
@@ -13791,6 +13865,9 @@ fn clear_current_ticket_role_assignment_with_source_in_tx(
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||
TicketAssignmentPrincipal::Subject { subject_id } => {
|
||||
(Some(subject_id.as_str()), None, None)
|
||||
}
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
(Some(agent_key.as_str()), None, None)
|
||||
}
|
||||
@@ -15392,8 +15469,13 @@ fn migrate_ticket_cases_v90_to_v91(conn: &Connection) -> Result<()> {
|
||||
// closure reasons and immutable case history. It opens its own transaction.
|
||||
// Leave the Workspace marker at 90 until our composed constraints commit;
|
||||
// a failed second phase is resumable through the idempotent domain migrator.
|
||||
ticket::migrate_sqlite_ticket_schema(conn)
|
||||
.map_err(|error| Error::Store(format!("Ticket case migration failed: {error}")))?;
|
||||
let component = conn.unchecked_transaction()?;
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&component,
|
||||
ticket::HistoricalTicketSchemaTarget::Cases10,
|
||||
)
|
||||
.map_err(|error| Error::Store(format!("Ticket case migration failed: {error}")))?;
|
||||
component.commit()?;
|
||||
let invalid: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open', 'closed')",
|
||||
[],
|
||||
@@ -15439,8 +15521,11 @@ fn migrate_ticket_tracking_v91_to_v92(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&tx,
|
||||
ticket::HistoricalTicketSchemaTarget::Tracking11,
|
||||
)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (92, 'Ticket-owned assignment tracking')", [])?;
|
||||
verify_workspace_resource_constraints(&tx)?;
|
||||
tx.commit()?;
|
||||
@@ -15467,8 +15552,11 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&tx,
|
||||
ticket::HistoricalTicketSchemaTarget::Posts12,
|
||||
)
|
||||
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
|
||||
assignment_delivery::install_post_inputs(&tx)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')",
|
||||
@@ -15495,8 +15583,11 @@ fn migrate_notification_routing_v94_to_v95(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
.map_err(|e| Error::Store(format!("Ticket content facts migration failed: {e}")))?;
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&tx,
|
||||
ticket::HistoricalTicketSchemaTarget::Content13,
|
||||
)
|
||||
.map_err(|e| Error::Store(format!("Ticket content facts migration failed: {e}")))?;
|
||||
notification_routing::install(&tx)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(95,?1)",
|
||||
@@ -15768,6 +15859,29 @@ fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_subject_assignments_v107_to_v108(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 107 {
|
||||
return Err(Error::Store(
|
||||
"Subject assignment migration requires schema 107".into(),
|
||||
));
|
||||
}
|
||||
// Refuse preexisting drift before the new tail can commit any changes.
|
||||
// Migration 107 and its historical identity remain frozen.
|
||||
verify_schema_history(conn, 107)?;
|
||||
crate::plugin_catalog::verify(conn)?;
|
||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
subject_assignment_migration::install(&tx)?;
|
||||
notification_routing::install_subject_recipients(&tx)?;
|
||||
subject_assignment_migration::verify(&tx)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(108,?1)",
|
||||
["Subject Ticket responsibility and current-body notification routing"],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure(conn)?;
|
||||
assignment_delivery::install(conn)?;
|
||||
@@ -15777,6 +15891,8 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
notification_routing::install_business_events(conn)?;
|
||||
notification_routing::upgrade_result_reports(conn)?;
|
||||
subject_deletion_store::install_notification_erasure(conn)?;
|
||||
subject_assignment_migration::install(conn)?;
|
||||
notification_routing::install_subject_recipients(conn)?;
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"fresh Workspace Ticket schema verification failed: {error}"
|
||||
@@ -15784,12 +15900,26 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
fn migrate_plugin_plans_v106_to_v107(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 106 {
|
||||
return Err(Error::Store(
|
||||
"Plugin plan migration requires schema 106".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,'Immutable Worker Plugin plans and retained pins')", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Fresh composition uses the posts contract. Retained fixtures pass frozen host
|
||||
// and component DDL to the shared cross-domain constraint composition below.
|
||||
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
|
||||
conn.execute_batch(include_str!("activation_schema.sql"))?;
|
||||
conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
|
||||
conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16831,6 +16961,8 @@ fn apply_migrations_with_secret_source(
|
||||
|
||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||
crate::plugin_catalog::verify(conn)?;
|
||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||
subject_assignment_migration::verify(conn)?;
|
||||
verify_workspace_runtime_binding_schema(conn)?;
|
||||
verify_workspace_runtime_verification_schema(conn)?;
|
||||
verify_workspace_deletion_schema(conn)?;
|
||||
@@ -16867,6 +16999,7 @@ mod test_support;
|
||||
mod tests {
|
||||
include!("assignment_tracking_tests.rs");
|
||||
include!("assignment_delivery_tests.rs");
|
||||
include!("subject_assignment_migration_tests.rs");
|
||||
include!("notification_routing_tests.rs");
|
||||
include!("notification_diagnostics_tests.rs");
|
||||
include!("job_notification_tests.rs");
|
||||
@@ -19525,6 +19658,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()},
|
||||
WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()},
|
||||
WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()},
|
||||
WorkspaceSchemaMigrationStep {version:107, name:"Immutable Worker Plugin plans and retained pins".into()},
|
||||
WorkspaceSchemaMigrationStep {version:108, name:"Subject Ticket responsibility and current-body notification routing".into()},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -19641,6 +19776,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(104, "Converge Passkey results and Job input retention".to_string()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".to_string()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.to_string()),
|
||||
(107, "Immutable Worker Plugin plans and retained pins".to_string()),
|
||||
(108, "Subject Ticket responsibility and current-body notification routing".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -22928,7 +23065,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(103, "Passkey metadata and atomic addition results".into()),
|
||||
(104, "Converge Passkey results and Job input retention".into()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
|
||||
(107, "Immutable Worker Plugin plans and retained pins".into()),
|
||||
(108, "Subject Ticket responsibility and current-body notification routing".into())]);
|
||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
@@ -23659,8 +23798,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
let first = manual_worker_assignment("first", "first-assignment");
|
||||
let orchestrator = TicketRoleAssignmentRecord {
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "fixture-subject".into(),
|
||||
},
|
||||
..first.clone()
|
||||
};
|
||||
@@ -24744,8 +24883,8 @@ INSERT INTO worker_registry (
|
||||
ticket_id: ticket.id.clone(),
|
||||
assignment_id: "orchestrator-1".to_string(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".to_string(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "fixture-subject".to_string(),
|
||||
},
|
||||
assigned_by: "user".to_string(),
|
||||
assigned_at: "2026-09-01T00:00:00Z".to_string(),
|
||||
@@ -27114,32 +27253,54 @@ INSERT INTO worker_registry (
|
||||
use crate::backend_job::{JobInputDisposition, JobInputRetirementReason};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("migration.sqlite");
|
||||
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||
// Build the actual retained boundary, never latest DDL relabelled as 102.
|
||||
prepare_retained_schema(&path, 102);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
// Obtain a canonical pending Job through the current API, then copy only
|
||||
// columns that existed at the retained boundary into the historical file.
|
||||
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||
seed_resource_job_workspace(&store).await;
|
||||
let original = resource_job_request("upgrade", "subject", "candidate");
|
||||
let before = store
|
||||
.reserve_backend_job("resource-workspace", &original, RESOURCE_JOB_NOW)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
// This fixture deliberately reconstructs schema 102 from fresh.
|
||||
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?;
|
||||
// Remove the newer Subject fence composition, not just its receipt.
|
||||
let triggers=conn.prepare("SELECT name,tbl_name FROM sqlite_schema WHERE type='trigger' AND tbl_name LIKE 'notification_%' AND name NOT LIKE 'subject_deleted_%' AND sql LIKE '%subject_deletion_jobs%'")?
|
||||
.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
for (name,table) in triggers {conn.execute_batch(&format!("DROP TRIGGER {name}; CREATE TRIGGER {name} BEFORE DELETE ON {table} BEGIN SELECT RAISE(ABORT,'Notification evidence is immutable'); END;"))?;}
|
||||
for name in ["subject_deleted_job_insert","subject_deleted_job_result","subject_deleted_attempt_insert","subject_deleted_attempt_result","subject_deleted_worker_reservation","subject_deleted_notification_source","subject_deleted_notification_payload","subject_deleted_notification_send","subject_deletion_worker_prior_insert","subject_deletion_worker_identity_immutable"] {conn.execute_batch(&format!("DROP TRIGGER {name}"))?;}
|
||||
conn.execute_batch("DROP TABLE subject_deletion_removals; DROP TABLE subject_deletion_allocations; DROP TABLE subject_deletion_jobs; DROP TABLE subject_deletion_workers; DROP TABLE subject_deletions;")?;
|
||||
conn.execute_batch("DROP TRIGGER backend_job_input_retirement; DROP TRIGGER backend_job_identity_immutable;
|
||||
ALTER TABLE backend_jobs DROP COLUMN input_disposition;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=106;
|
||||
CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(())
|
||||
}).unwrap();
|
||||
for table in [
|
||||
"accounts",
|
||||
"workspaces",
|
||||
"backend_jobs",
|
||||
"backend_job_attempts",
|
||||
] {
|
||||
let columns = conn
|
||||
.prepare(&format!("PRAGMA table_info({table})"))
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
let names = columns.join(",");
|
||||
let rows = store
|
||||
.with_conn(|source| {
|
||||
Ok(fixture_query_rows(
|
||||
source,
|
||||
&format!("SELECT {names} FROM {table}"),
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
let placeholders = vec!["?"; columns.len()].join(",");
|
||||
for row in rows {
|
||||
conn.execute(
|
||||
&format!("INSERT INTO {table} ({names}) VALUES ({placeholders})"),
|
||||
rusqlite::params_from_iter(row),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
drop(store);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 102);
|
||||
verify_schema_history(&conn, 102).unwrap();
|
||||
assert!(!column_exists(&conn, "backend_jobs", "input_disposition").unwrap());
|
||||
conn.execute_batch("CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;").unwrap();
|
||||
assert!(migrate_job_input_retention_v102_to_v103(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 102);
|
||||
assert!(!column_exists(&conn, "backend_jobs", "input_disposition").unwrap());
|
||||
@@ -28450,3 +28611,7 @@ mod frozen_runtime_binding_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "plugin_catalog/migration_tests.rs"]
|
||||
mod plugin_catalog_migration_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plugin_plan/migration_tests.rs"]
|
||||
mod plugin_plan_migration_tests;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Workspace composition of Ticket-owned Subject responsibility (component 14).
|
||||
//!
|
||||
//! Ticket alone owns principal tables/CHECKs, domain assignment guards and the
|
||||
//! component migration ledger. This adapter composes that migration in the host
|
||||
//! transaction, then installs only Workspace delivery/start guards. It never edits
|
||||
//! sqlite_schema, rebuilds Ticket tables, or reinstalls Ticket-owned triggers.
|
||||
//!
|
||||
//! There is no trustworthy conversion of `workspace-orchestrator` to a Subject.
|
||||
//! A current `subjektiv:<id>` body or child execution grant cannot attest ownership
|
||||
//! of a historical stable-agent assignment. Original principals/current pointers,
|
||||
//! tracking, inputs, results, receipt fingerprints and source JSON stay evidence,
|
||||
//! not execution authority. Newly authorized Subject assignment is explicit.
|
||||
//!
|
||||
//! No Runtime/FeatureStorage calls, routing, grants, restore or receipt replay
|
||||
//! occur here. The caller owns the surrounding transaction and Workspace version;
|
||||
//! on any error it must roll back the whole transaction, including Ticket work.
|
||||
use super::*;
|
||||
|
||||
pub(super) const LEGACY_DIAGNOSTIC: &str = "Legacy Orchestrator identity retained without a trustworthy historical Subject mapping; nonoperational, explicit authorized Subject assignment required. Saved history and receipts do not grant execution authority.";
|
||||
|
||||
const HOST_GUARDS: [&str; 2] = [
|
||||
"ticket_legacy_orchestrator_delivery_no_insert",
|
||||
"ticket_legacy_orchestrator_start_no_insert",
|
||||
];
|
||||
|
||||
/// Parent may already have composed component 14 in this same transaction; the
|
||||
/// Ticket entrypoint then verifies its current schema without applying it again.
|
||||
/// Host guard installation is a single migration, not an operational repair API.
|
||||
pub(super) fn install(conn: &Connection) -> Result<()> {
|
||||
if conn.is_autocommit() {
|
||||
return Err(Error::Store(
|
||||
"Subject assignment migration requires a caller-owned transaction".into(),
|
||||
));
|
||||
}
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(conn).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"Ticket Subject component composition failed: {error}"
|
||||
))
|
||||
})?;
|
||||
conn.execute_batch(HOST_GUARDS_SQL)?;
|
||||
// Component 14 currently supplies this same diagnostic. Ensure it exists for
|
||||
// retained legacy rows without duplicating its line or touching their CAS,
|
||||
// tracking values, historical source JSON, events, inputs or receipts.
|
||||
conn.execute(
|
||||
"UPDATE ticket_worker_assignments
|
||||
SET tracking_migration_diagnostic = CASE
|
||||
WHEN tracking_migration_diagnostic IS NULL OR tracking_migration_diagnostic=''
|
||||
THEN ?1 ELSE tracking_migration_diagnostic || char(10) || ?1 END
|
||||
WHERE role='orchestrator' AND principal_kind!='subject'
|
||||
AND instr(char(10) || COALESCE(tracking_migration_diagnostic,'') || char(10),
|
||||
char(10) || ?1 || char(10))=0",
|
||||
[LEGACY_DIAGNOSTIC],
|
||||
)?;
|
||||
verify(conn)
|
||||
}
|
||||
|
||||
/// Read-only verification delegates the owning table/domain-guard contract to
|
||||
/// Ticket. Workspace verifies only its two complete trigger definitions/targets,
|
||||
/// followed by host database integrity and FK validation. No repair is performed.
|
||||
pub(super) fn verify(conn: &Connection) -> Result<()> {
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"Ticket Subject schema verification failed: {error}"
|
||||
))
|
||||
})?;
|
||||
let canonical = Connection::open_in_memory()?;
|
||||
canonical.execute_batch("CREATE TABLE ticket_assignment_deliveries(workspace_id TEXT,assignment_id TEXT);
|
||||
CREATE TABLE ticket_assignment_operation_fences(workspace_id TEXT,orchestrator_assignment_id TEXT);")?;
|
||||
canonical.execute_batch(HOST_GUARDS_SQL)?;
|
||||
for name in HOST_GUARDS {
|
||||
let expected = trigger_definition(&canonical, name)?;
|
||||
let actual = trigger_definition(conn, name)?;
|
||||
if actual != expected {
|
||||
return Err(Error::Store(format!(
|
||||
"Workspace Subject guard {name} differs from canonical SQL or target"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let integrity: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
|
||||
if integrity != "ok" {
|
||||
return Err(Error::Store(format!(
|
||||
"Workspace Subject migration integrity check failed: {integrity}"
|
||||
)));
|
||||
}
|
||||
let violations: i64 =
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(Error::Store(
|
||||
"Workspace Subject migration foreign-key violation".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_definition(conn: &Connection, name: &str) -> Result<(String, String)> {
|
||||
conn.query_row(
|
||||
"SELECT tbl_name,sql FROM main.sqlite_schema WHERE type='trigger' AND name=?1",
|
||||
[name],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| Error::Store(format!("Missing Workspace Subject guard {name}")))
|
||||
}
|
||||
|
||||
const HOST_GUARDS_SQL: &str = r#"
|
||||
CREATE TRIGGER ticket_legacy_orchestrator_delivery_no_insert
|
||||
BEFORE INSERT ON ticket_assignment_deliveries
|
||||
WHEN EXISTS(SELECT 1 FROM ticket_worker_assignments a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.assignment_id
|
||||
AND a.role='orchestrator' AND a.principal_kind!='subject')
|
||||
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
|
||||
CREATE TRIGGER ticket_legacy_orchestrator_start_no_insert
|
||||
BEFORE INSERT ON ticket_assignment_operation_fences
|
||||
WHEN NOT EXISTS(SELECT 1 FROM ticket_worker_assignments a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.orchestrator_assignment_id
|
||||
AND a.role='orchestrator' AND a.principal_kind='subject')
|
||||
BEGIN SELECT RAISE(ABORT,'Ticket startup fence requires Subject responsibility'); END;
|
||||
"#;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,10 @@ fn page_sql(
|
||||
if let Some(principal) = &query.assigned_principal {
|
||||
use ticket::TicketAssignmentPrincipalSnapshot::*;
|
||||
terms.push(match principal {
|
||||
Subject { subject_id } => format!(
|
||||
"a.principal_kind='subject' AND a.principal_id={}",
|
||||
bind(subject_id.clone())
|
||||
),
|
||||
WorkspaceAgent { agent_key } => format!(
|
||||
"a.principal_kind='workspace_agent' AND a.principal_id={}",
|
||||
bind(agent_key.clone())
|
||||
@@ -342,6 +346,7 @@ fn enrich_page(
|
||||
let mut statement = conn.prepare(&format!("SELECT a.ticket_id,json_object(
|
||||
'workspace_id',a.workspace_id,'ticket_id',a.ticket_id,'assignment_id',a.assignment_id,'role',a.role,
|
||||
'principal',json(CASE a.principal_kind
|
||||
WHEN 'subject' THEN json_object('kind','subject','subject_id',a.principal_id)
|
||||
WHEN 'workspace_agent' THEN json_object('kind','workspace_agent','agent_key',a.principal_id)
|
||||
WHEN 'user' THEN json_object('kind','user','account_id',a.principal_id)
|
||||
WHEN 'worker' THEN json_object('kind','worker','runtime_id',a.runtime_id,'worker_id',a.worker_id) END),
|
||||
|
||||
@@ -78,12 +78,12 @@ impl Fixture {
|
||||
fn assign(&self, id: &str, assignment: &str, status: Option<&str>, current: bool) {
|
||||
self.authority.store.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
|
||||
VALUES('w',?1,?2,'orchestrator','workspace_agent','workspace-orchestrator','owner-account','1')",params![id,assignment])?;
|
||||
VALUES('w',?1,?2,'orchestrator','subject','subject-a','owner-account','1')",params![id,assignment])?;
|
||||
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?1,tracking_updated_at=?2 WHERE workspace_id='w' AND assignment_id=?3",
|
||||
params![status.map(|s| serde_json::json!({"status":s,"waiting_reason":null,"progress":null,"next_action":null,"result":null}).to_string()),status.map(|_|"1"),assignment])?;
|
||||
if current {
|
||||
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at)
|
||||
VALUES('w',?1,'orchestrator',?2,'workspace_agent','workspace-orchestrator','1')",params![id,assignment])?;
|
||||
VALUES('w',?1,'orchestrator',?2,'subject','subject-a','1')",params![id,assignment])?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
@@ -100,9 +100,9 @@ impl Fixture {
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
fn agent() -> ticket::TicketAssignmentPrincipalSnapshot {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
fn assigned_subject() -> ticket::TicketAssignmentPrincipalSnapshot {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Subject {
|
||||
subject_id: "subject-a".into(),
|
||||
}
|
||||
}
|
||||
fn ids(response: &TicketBrowseQueryResponse) -> Vec<String> {
|
||||
@@ -264,7 +264,7 @@ async fn ticket_browse_all_filters_are_applied_before_limit_and_batch_fields_mat
|
||||
query: Some("NEEDLE".into()),
|
||||
states: vec![TicketState::Closed],
|
||||
tag_ids: vec!["b".into(), "a".into(), "a".into()],
|
||||
assigned_principal: Some(agent()),
|
||||
assigned_principal: Some(assigned_subject()),
|
||||
handling_status: Some(ticket::TicketAssignmentTrackingStatus::Done),
|
||||
closure_reason: Some(TicketClosureReason::Other("custom reason".into())),
|
||||
limit: Some(1),
|
||||
@@ -372,7 +372,7 @@ async fn ticket_browse_assignment_current_missing_unknown_and_cleared_semantics(
|
||||
f.authority.ticket_assignment_tracking(&unknown).unwrap()
|
||||
);
|
||||
let mut assigned = ids(&f.run(TicketBrowseQueryRequest {
|
||||
assigned_principal: Some(agent()),
|
||||
assigned_principal: Some(assigned_subject()),
|
||||
..Default::default()
|
||||
}));
|
||||
assigned.sort();
|
||||
|
||||
@@ -83,7 +83,7 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
|
||||
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references);
|
||||
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
|
||||
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
|
||||
assert_eq!(markers.len(),old_markers.len()+2);
|
||||
assert_eq!(markers.len(),old_markers.len()+3);
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_content_changes",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent content change facts");
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs",[],|r|r.get::<_,i64>(0))?,0,"migration does not turn historical edits into new inputs");
|
||||
let initial: (String,Option<String>,Option<String>,String,String) = conn.query_row("SELECT e.body,t.created_by,e.author,e.kind,e.event_ref FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='authored' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?;
|
||||
|
||||
@@ -266,7 +266,7 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,13);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,14);
|
||||
assert_eq!(fixture_auth_rows(conn),auth_before);
|
||||
for (table, rows) in preserved_tables.iter().zip(&preserved) {
|
||||
let mut migrated = stored_fixture_rows(conn, table);
|
||||
@@ -500,7 +500,7 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)?,
|
||||
13
|
||||
14
|
||||
);
|
||||
assert_eq!(
|
||||
stored_fixture_rows(conn, "typed_ticket_case_history"),
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{Error, Result};
|
||||
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
|
||||
/// excluded so retries and audit remain available after the Workspace row is gone.
|
||||
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"plugin_execution_plans",
|
||||
"plugin_references",
|
||||
"plugin_operations",
|
||||
"plugin_uploads",
|
||||
@@ -508,6 +509,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
// commit; update guards remain installed throughout.
|
||||
let mut ticket_delete_guards = Vec::new();
|
||||
for (table, trigger) in [
|
||||
("plugin_execution_plans", "plugin_plan_replay_retained"),
|
||||
("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"),
|
||||
("notification_job_targets", "notification_job_targets_no_delete"),
|
||||
("notification_job_legacy_receipts", "notification_job_legacy_no_delete"),
|
||||
@@ -1049,8 +1051,8 @@ mod tests {
|
||||
ticket_id: initial.meta.id.clone(),
|
||||
assignment_id: assignment_id.clone(),
|
||||
role: crate::store::TicketAssignmentRole::Orchestrator,
|
||||
principal: crate::store::TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: crate::store::TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "deletion-fixture-subject".into(),
|
||||
},
|
||||
assigned_by: "owner".into(),
|
||||
assigned_at: "2026-01-01T00:00:00Z".into(),
|
||||
@@ -1094,7 +1096,7 @@ mod tests {
|
||||
actor: "orchestrator".into(),
|
||||
runtime_id: "fixture-runtime".into(),
|
||||
worker_id: "fixture-worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
subject_id: "deletion-fixture-subject".into(),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
Reference in New Issue
Block a user