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,
|
||||
matches!(shape, TicketSchemaShape::Subjects14),
|
||||
) {
|
||||
if let Err(error) =
|
||||
crate::assignment_tracking::verify_assignment_tracking_schema(connection)
|
||||
{
|
||||
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,29 +1302,27 @@ 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);
|
||||
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(baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// 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, name.to_owned());
|
||||
expected.insert(version, component_migration_name(version).to_owned());
|
||||
if applied == &expected {
|
||||
return Ok(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(TicketError::Sqlite(format!(
|
||||
"Ticket schema migration history must contain a canonical baseline and ordered migrations through {LATEST_SQLITE_TICKET_SCHEMA_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,11 +141,18 @@ 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)?
|
||||
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)
|
||||
@@ -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(
|
||||
} => {
|
||||
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))
|
||||
})?;
|
||||
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(current, base_identity, &request.changes, schema_bundle)
|
||||
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
|
||||
assert!(
|
||||
store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id == format!("implementation-result:{}", late.result_id)));
|
||||
.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;",
|
||||
)?;
|
||||
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;",
|
||||
)?;
|
||||
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",
|
||||
] {
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 96);
|
||||
assert_eq!(
|
||||
state_contract_schema_signature(conn, table),
|
||||
state_contract_schema_signature(&retained, table),
|
||||
"schema96 {table}"
|
||||
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("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],
|
||||
)?;
|
||||
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)?;
|
||||
// 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();
|
||||
})
|
||||
.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 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<_>>();
|
||||
})
|
||||
.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();
|
||||
// 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() {
|
||||
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])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
implementation_seed_legacy_result_delivery(conn, index)?;
|
||||
}
|
||||
routing_ack_worker_pending(&store, &TicketWorkerNotificationTarget {
|
||||
worker: worker.clone(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.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");
|
||||
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();
|
||||
})
|
||||
.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");
|
||||
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");
|
||||
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();
|
||||
})
|
||||
.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");
|
||||
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);
|
||||
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();
|
||||
})
|
||||
.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);
|
||||
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();
|
||||
})
|
||||
.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,27 +316,36 @@ 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
|
||||
// Resolve each current assignment independently. A missing body is not an implicit restore.
|
||||
let inputs = match api
|
||||
.store
|
||||
.list_pending_ticket_assignment_deliveries(&api.config.workspace_id, 20)
|
||||
.list_pending_ticket_assignment_deliveries(api.workspace_id(), 100)
|
||||
{
|
||||
Ok(inputs) => {
|
||||
Ok(inputs) => inputs,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "assignment notification scan failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut recipients = std::collections::BTreeSet::new();
|
||||
for input in inputs {
|
||||
record_notification_failure(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, "assignment notification scan failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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| {
|
||||
.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();
|
||||
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()
|
||||
.0
|
||||
}
|
||||
|
||||
#[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,13 +917,17 @@ 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(|_| {
|
||||
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",
|
||||
@@ -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)
|
||||
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,7 +15521,10 @@ fn migrate_ticket_tracking_v91_to_v92(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
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)?;
|
||||
@@ -15467,7 +15552,10 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
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(
|
||||
@@ -15495,7 +15583,10 @@ fn migrate_notification_routing_v94_to_v95(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
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(
|
||||
@@ -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(),
|
||||
},
|
||||
)?;
|
||||
|
||||
+141
-1
@@ -12689,6 +12689,23 @@
|
||||
},
|
||||
"TicketAssignmentPrincipal": {
|
||||
"oneOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "subject",
|
||||
"type": "string"
|
||||
},
|
||||
"subject_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"subject_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -12749,6 +12766,22 @@
|
||||
"TicketAssignmentPrincipalSnapshot": {
|
||||
"description": "Immutable principal as stored at Assign, not live Worker availability.",
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "subject",
|
||||
"type": "string"
|
||||
},
|
||||
"subject_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"subject_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"account_id": {
|
||||
@@ -19420,7 +19453,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca"
|
||||
"x-yoi-source-digest": "sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -24266,6 +24299,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/config/source-tree/preview": {
|
||||
"post": {
|
||||
"operationId": "workspace_config_tree_preview",
|
||||
"parameters": [
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "workspace_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfigCommitRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkspaceConfigTreeResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/drive/capabilities": {
|
||||
"get": {
|
||||
"operationId": "drive_capabilities",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schema": "{ plugins = { enabled = { echo = { installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = { ...String } default {}; config = { mode = String; }; }; }; bindings = {} default {}; }; }",
|
||||
"cases": [
|
||||
{
|
||||
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = \"test\"; }; } as WorkspaceConfigSchema",
|
||||
"mode": "test"
|
||||
},
|
||||
{
|
||||
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config = { mode = \"test\"; unknown = true; }; }; } as WorkspaceConfigSchema",
|
||||
"diagnostic": "constraint_violation"
|
||||
},
|
||||
{
|
||||
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = 42; }; } as WorkspaceConfigSchema",
|
||||
"diagnostic": "constraint_violation"
|
||||
},
|
||||
{
|
||||
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = String; }; } as WorkspaceConfigSchema",
|
||||
"diagnostic": "materialize"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
let
|
||||
PluginEnablement = {
|
||||
installation_id = String;
|
||||
expected_last_operation = String;
|
||||
package_digest = String;
|
||||
artifact_digest = String;
|
||||
tools = [...String];
|
||||
logging = Bool default false;
|
||||
connections = { ...String } default {};
|
||||
config = { ...Unknown } default {};
|
||||
};
|
||||
in {
|
||||
plugins = {
|
||||
enabled = { ...PluginEnablement } default {};
|
||||
bindings = { ...{
|
||||
origin = String;
|
||||
network = String default "public_internet";
|
||||
methods = [...String];
|
||||
path_prefixes = [...String];
|
||||
request_headers = [...String] default [];
|
||||
response_headers = [...String] default [];
|
||||
secret_ref = String default "";
|
||||
limits = {
|
||||
timeout_ms = Int;
|
||||
max_request_bytes = Int;
|
||||
max_response_bytes = Int;
|
||||
max_header_bytes = Int;
|
||||
};
|
||||
} } default {};
|
||||
} default {};
|
||||
}
|
||||
@@ -76,6 +76,13 @@ let
|
||||
search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; };
|
||||
fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; };
|
||||
};
|
||||
plugins = { selections = [...{
|
||||
installation_id = String; tools = [...String]; logging = Bool;
|
||||
https_slots = [...String];
|
||||
# 非秘密のoverrideは補完用のshapeではなく、導入済みpackageの
|
||||
# closed typed schemaで検証する。
|
||||
config = { ...Unknown };
|
||||
}]; };
|
||||
skills = { directories = [...String]; };
|
||||
};
|
||||
in {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
states = {
|
||||
implement = {
|
||||
instructions = "This is an optional code recipe for requested Git publication and independent review, not a universal Ticket gate. If the user requests no review or return without conclusion, honor that bounded request and do not treat this recipe as authority to conclude the Ticket. Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. Use local work-branch switching, `git add`, `git commit`, and publication only within the request and existing grants; this recipe does not override an explicit no-commit/no-push instruction or expand resource authority. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority.";
|
||||
instructions = "This is an optional code recipe for requested Git publication and independent review, not a universal Ticket gate. If the user requests no review or return without conclusion, honor that bounded request and do not treat this recipe as authority to conclude the Ticket. Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. Use local work-branch switching, `git add`, `git commit`, and publication only within the request and existing grants; this recipe does not override an explicit no-commit/no-push instruction or expand resource authority. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and requires separate integration authority for the assigned Subject. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority.";
|
||||
transitions = {
|
||||
review = {
|
||||
target = "review";
|
||||
@@ -43,17 +43,17 @@
|
||||
};
|
||||
|
||||
complete = {
|
||||
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using the Ticket, stable operation_key, kind, bounded body and optional retained MR/source evidence. Backend authenticates an active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. Retry unknown outcomes only with the same key and full input.";
|
||||
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using the Ticket, stable operation_key, kind, bounded body and optional retained MR/source evidence. Backend authenticates an active Workspace Worker; no current or pinned implementation assignment or Subject assignment is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the assigned Subject refreshes readiness/integration evidence against the current target, using its authorized current body. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. Retry unknown outcomes only with the same key and full input.";
|
||||
transitions = {
|
||||
completed = {
|
||||
target = "done";
|
||||
condition = "The exact approved Merge Request source ref and implementation evidence have been durably handed off to the Orchestrator. A Flow state or prose report alone is never Ticket completion authority.";
|
||||
condition = "The exact approved Merge Request source ref and implementation evidence have been durably handed off to the assigned Subject. A Flow state or prose report alone is never Ticket completion authority.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
done = {
|
||||
instructions = "The reviewed candidate and its evidence have been handed off. The Orchestrator decides which changes to adopt, adapt, or reject and validates the resulting combined state; completion of this recipe does not require adoption of the whole MR or use of `CompleteMergeRequest`. Flow terminal state only reflects this handoff, not Ticket closure, assignment handling done, work release, unassignment, or Worker stop. Preserve the evidence without claiming an integration or delivery decision that the Orchestrator has not made.";
|
||||
instructions = "The reviewed candidate and its evidence have been handed off. The assigned Subject decides which changes to adopt, adapt, or reject and validates the resulting combined state, subject to current assignment/body ownership and operation/resource grants; neither this recipe nor the compatibility orchestrator role/Profile name grants that authority. Completion of this recipe does not require adoption of the whole MR or use of `CompleteMergeRequest`. Flow terminal state only reflects this handoff, not Ticket closure, assignment handling done, work release, unassignment, or Worker stop. Preserve the evidence without claiming an integration or delivery decision that the assigned Subject has not made. A background Worker's handoff does not self-report the Subject's handling or transfer its durable assignment, and retained receipts never authorize an old body or released/replaced assignee to start new work.";
|
||||
terminal = true;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./base.dcdl" // {
|
||||
slug = "coder";
|
||||
description = "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.";
|
||||
description = "Bounded code implementation for a Ticket's assigned Subject; Profile selection confers no assignee/body authority. Optional Git/MR review recipe and no universal Ticket conclusion gate.";
|
||||
scope = "workspace_write";
|
||||
model = { ref = "codex-oauth/gpt-6.1-sol"; };
|
||||
engine = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./base.dcdl" // {
|
||||
slug = "intake";
|
||||
description = "Ticket intake profile.";
|
||||
description = "Ticket authoring and clarification; handoff does not assign a Subject or start execution.";
|
||||
scope = "workspace_write";
|
||||
engine = { instruction = "role.intake"; };
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./base.dcdl" // {
|
||||
slug = "orchestrator";
|
||||
description = "Owns outcome delivery, selection and validation of candidate changes, and useful delegation; MR workflows are optional.";
|
||||
# 互換slugを維持するが、Subject担当・本体所有・操作権限はこのProfileから付与しない。
|
||||
description = "Ticket outcome coordination for an assigned Subject, with candidate selection, validation and bounded delegation; no fixed assignee or privileged role identity. MR workflows are optional.";
|
||||
scope = "workspace_write";
|
||||
engine = { instruction = "role.orchestrator"; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./default.dcdl" // {
|
||||
slug = "ticket-worker";
|
||||
description = "General intent-led Ticket work with optional result recording and authorized conclusion decisions.";
|
||||
description = "Intent-led Ticket work with bounded result reporting and authorized conclusion decisions; the Worker is not the durable Subject assignee.";
|
||||
engine = {
|
||||
instruction = "role.ticket_worker";
|
||||
};
|
||||
|
||||
@@ -12,12 +12,16 @@ Ticket cases have only `open` (still handled) and `closed` (handling ended for n
|
||||
|
||||
Classification tags use stable tag IDs, not display names, for mutations and filters. Use the shared Workspace tag tools for creation/list/rename/delete and Ticket tag-set changes with their observed operation identities; names such as `ready` never grant start permission. Tag-set CAS compares `expected_tag_ids` with the observed full ID set; tag rename/delete CAS compares `expected_last_operation_key` with the observed tag operation identity. Classification is separate from a close reason and never closes/reopens or delegates a Ticket.
|
||||
|
||||
Read the current Orchestrator delegation with `TicketAssignmentTrackingQuery`; enumerate retained assignment IDs with bounded `TicketAssignmentsQuery`, then read a selected row and its `TicketAssignmentTrackingHistory`. Use `TicketAssignmentDiagnostics` for independent bounded delivery/input and start evidence; its separate cursors and explicit unknown/history fields are not a handling status. `QueryTicket` can filter the exact current `assigned_principal`, self-reported `handling_status`, and current closed-case `closure_reason`. Closure reason is distinct from tag IDs; retained/reopened reasons are history, and Worker status is never inferred. Each immutable `assignment_id` denotes this particular delegation to the stable Workspace Orchestrator principal, not a concrete Runtime Worker. Assign is delegation and initial judgment input; no ready step or separate Queue is required. `waiting`, `inprogress`, and `done` are the Orchestrator's self-reported handling, never Ticket case states, delivery/acceptance or Worker running/idle/stop. Record waiting reason, progress, next action and result through `TicketAssignmentTrackingUpdate` when authorized, not just conversation memory. Waiting must identify what is needed next; research returned to a human can be `open` + handling `done`. Respect an explicit user approval condition; do not add a universal human/MR gate. Done is not Backend certification of satisfaction, third-party approval, merge, actual work release, or Worker stop.
|
||||
A Ticket's durable assignee is an explicitly selected Subject, represented by `{kind: "subject", subject_id: "<subject ID>"}`; `subject_id` is a string. Any permitted Subject can be selected: there is no fixed Workspace-wide assignee or required Orchestrator singleton. Keep the assigned Subject distinct from its current body Worker, background implementation Workers/attempts, and Repository/branch/Workdir resources. Body stop, replacement, or restore does not reassign the Ticket. Different Subjects may handle different Tickets on separate branches and Workdirs in parallel; preserve per-Subject body ownership and actual resource-conflict checks rather than imposing Workspace-wide serialization. Assignment, Profile/role names, Feature activation, and branch separation do not grant operation or resource authority. Backend authenticates the acting Worker and checks effective grants, current Subject/body ownership where required, and Ticket-specific assignment/target conditions.
|
||||
|
||||
Use `TicketOrchestratorAssign` for explicit initial delegation or reissue, with target principal, observed `expected_assignment_id` (explicit null only when unassigned), and stable `operation_id`. Query first; target principal is not the actor or an authority claim. The usual target is `{kind: "workspace_agent", agent_key: "workspace-orchestrator"}`; Backend validates role/principal and actual authentication. Use `TicketOrchestratorClear` only for explicit release of the observed assignment with its ID and operation_id, not automatically on done/closed. Neither operation starts or stops a Worker. Exact retry retains the same operation_id and full input; conflict or unknown outcome must not silently generate a new key. Reread current tracking before any subsequent mutation.
|
||||
Read the current Subject assignment with `TicketAssignmentTrackingQuery`; enumerate retained assignment IDs with bounded `TicketAssignmentsQuery`, then read a selected row and its `TicketAssignmentTrackingHistory`. Use `TicketAssignmentDiagnostics` for independent bounded delivery/input and start evidence; its separate cursors and explicit unknown/history fields are not a handling status. `QueryTicket` can filter the exact current Subject `assigned_principal`, self-reported `handling_status`, and current closed-case `closure_reason`. Closure reason is distinct from tag IDs; retained/reopened reasons are history, and Worker status is never inferred. Each immutable `assignment_id` denotes this particular assignment period to the Subject, not a concrete Runtime Worker. Assign is delegation and initial judgment input; no ready step or separate Queue is required. `waiting`, `inprogress`, and `done` are the assigned Subject's self-reported handling, never Ticket case states, delivery/acceptance or Worker running/idle/stop. Record waiting reason, progress, next action and result through `TicketAssignmentTrackingUpdate` only when authorized to act for the current assigned Subject, not just in conversation memory. Waiting must identify what is needed next; research returned to a human can be `open` + handling `done`. Respect an explicit user approval condition; do not add a universal human/MR gate. Done is not Backend certification of satisfaction, third-party approval, merge, actual work release, or Worker stop.
|
||||
|
||||
Use `TicketOrchestratorAssign` for explicit initial assignment, reassignment or reissue to the selected Subject principal, with observed `expected_assignment_id` (explicit null only when unassigned) and stable `operation_id`. Query first; target principal is not the actor or an authority claim. Use `{kind: "subject", subject_id: "<subject ID>"}` for new assignments. Legacy User, Worker and WorkspaceAgent principals (`user`, `worker`, `workspace_agent`) remain readable for historical/audit evidence only; do not use them for new assignments, infer a Subject mapping, or silently replace an unmapped legacy assignee. Report unresolved migration/identity diagnostics instead. `TicketOrchestratorAssign`, `TicketOrchestratorClear`, the `orchestrator` role/Profile spelling, and `orchestrator_assignment_id` are retained compatibility names, not a privileged role or fixed target identity. Use `TicketOrchestratorClear` only for explicit release of the observed assignment with its ID and operation_id, not automatically on done/closed. Neither operation starts or stops a Worker. Exact retry retains the same operation_id and full input; conflict or unknown outcome must not silently generate a new key. Reread current tracking before any subsequent mutation.
|
||||
|
||||
Answers during waiting or ongoing handling continue the same assignment row; use `TicketAssignmentAnswer` with that `assignment_id`, observed `expected_last_event_id`, stable `operation_key`, and answer `body`, not an unfenced comment as a substitute. Do not reassign merely to deliver an answer or automatically declare inprogress on arrival. After done, an explicit reissue creates a new row/assignment_id even for the same principal and preserves the prior result. Reopen alone is neither reissue nor resurrection of an old Worker. Release/replacement remains separate from the old row's last status/result; done does not require unassignment. Worker replacement alone does not create a new delegation. Dependencies are diagnostics for judging partial work or waiting, not a blanket start gate; never start dependencies implicitly.
|
||||
|
||||
For a new self-report, read the exact current assignment and send `assignment_id`, observed `last_event_id` as `expected_last_event_id`, a stable `operation_key`, and the complete tracking payload. Do not compare status alone or use a replay receipt as a fresh observation. Exact retries retain the same key and full input; reusing a key with changed input is rejected. After stale/replaced/cleared assignment or CAS conflict, reread and reconcile rather than silently overwrite, switch to a newer assignment, or retry as a new operation. Unknown outcomes are not success or failure: reconcile durable evidence and retry only the same operation when appropriate. Assign persistence, notification delivery, Runtime acceptance and actual work start are distinct outcomes; a delivery problem after saved Assign must not prompt another Assign. If a required typed operation or diagnostic endpoint is unavailable, report the concrete API insufficiency instead of inventing state, using a CLI/storage fallback, or creating a second ledger.
|
||||
Ticket requests, continuing input, changes and MR/implementation results follow the current assigned Subject; delivery resolves that Subject's current body rather than relying on a previous Worker. A notice, retained receipt, historical assignment or saved result is evidence to reread, not authority for an old body or released/replaced assignee to start or continue new work. After reassignment, release, body replacement or permission revocation, recheck current assignment and effective authority; do not restore execution rights from history. A background Worker's result is a bounded factual report, not the assigned Subject's outward decision, adoption judgment or handling update.
|
||||
|
||||
For a new self-report, read the exact current assignment and verify that the Host-bound Subject and current body are authorized for that assignment; send `assignment_id`, observed `last_event_id` as `expected_last_event_id`, a stable `operation_key`, and the complete tracking payload. Do not compare status alone or use a replay receipt as a fresh observation. Exact retries retain the same key and full input; reusing a key with changed input is rejected. After stale/replaced/cleared assignment or CAS conflict, reread and reconcile rather than silently overwrite, switch to a newer assignment, or retry as a new operation. Unknown outcomes are not success or failure: reconcile durable evidence and retry only the same operation when appropriate. Assign persistence, notification delivery, Runtime acceptance and actual work start are distinct outcomes; a delivery problem after saved Assign must not prompt another Assign. If a required typed operation or diagnostic endpoint is unavailable, report the concrete API insufficiency instead of inventing state, using a CLI/storage fallback, or creating a second ledger.
|
||||
|
||||
Treat case state, assignment handling, and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker.
|
||||
|
||||
@@ -14,4 +14,4 @@ Assigned Tickets have unreceived input:
|
||||
{% if omitted_ticket_count > 0 -%}
|
||||
Additional assigned Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
|
||||
{% endif -%}
|
||||
Reread the current Ticket state before acting, including current assignment tracking and new input. Assign is delegation; no ready/Queue step is required. Handling status is your self-report, not delivery or Worker state. Waiting alone and done history are not new work. Dependencies are diagnostics for partial work, not a blanket start gate; never implicitly start dependencies. Preserve current assignment identity, authenticated Worker, unfinished work, and Workdir/resource authority; do not create duplicate work.
|
||||
Reread the current Ticket state before acting, including the assigned Subject, current assignment/event identity and new input. Verify that the Host-bound Subject and current body still match the assignment and have the required effective grants; the notice does not establish ownership or authority. Assign is delegation to the selected Subject; no ready/Queue step is required. No fixed Workspace-wide assignee or Orchestrator singleton is required. Handling status is the assigned Subject's self-report, not delivery or Worker state. Waiting alone and done history are not new work. Old-body receipts and released/replaced assignments do not authorize new work, restore or duplicate execution. Dependencies are diagnostics for partial work, not a blanket start gate; never implicitly start dependencies. Preserve Subject/body ownership, unfinished work, Ticket target constraints and Workdir/resource authority.
|
||||
|
||||
@@ -5,4 +5,4 @@ Assigned Tickets have input requiring attention:
|
||||
{% if omitted_ticket_count > 0 -%}
|
||||
Additional assigned Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
|
||||
{% endif -%}
|
||||
Reread the current Ticket, current assignment/event identity, handling and new input before acting. Assign is delegation; no ready/Queue step is required. Waiting alone or done history is not new work. Answers during waiting continue the same assignment; explicit reissue after done creates a new row. Supply the observed orchestrator_assignment_id for starts. Handling is a self-report, not delivery, acceptance or Worker state. Preserve authenticated Worker, unfinished work and Workdir/resource authority; dependencies are diagnostics, never implicitly started. Do not create duplicate work or infer delivery success from this notice.
|
||||
Reread the current Ticket, assigned Subject, current assignment/event identity, handling and new input before acting. Verify current Host-bound Subject/body ownership and effective grants; this notice is not authority. Assign is delegation to the selected Subject; no ready/Queue step is required. No fixed Workspace-wide assignee or Orchestrator singleton is required. Waiting alone or done history is not new work. Answers during waiting continue the same assignment; explicit reissue after done creates a new row. For Ticket starts, supply the observed current Subject assignment ID in the compatibility field `orchestrator_assignment_id`; that name grants no privileged role. Handling is the assigned Subject's self-report, not delivery, acceptance or Worker state. Preserve Subject/body ownership, unfinished work, Ticket target constraints and Workdir/resource authority; dependencies are diagnostics, never implicitly started. Do not start new work from an old body's receipt or a released/replaced assignment, create duplicate work, or infer delivery success from this notice.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
You are the assigned Coder. Implement and validate the requested scope in the bound Workdir. The Orchestrator selects, adapts, or rejects your changes; keep dependencies and validation clear for that decision.
|
||||
You are the Coder delegated bounded implementation work. Implement and validate the requested scope in the bound Workdir. The assigned Subject selects, adapts, or rejects your changes; keep dependencies and validation clear for that decision. Your implementation Worker identity is not the Ticket's durable Subject assignee, and this Profile does not confer Subject body ownership or assignment-handling authority.
|
||||
|
||||
Use `TicketImplementationResult` for handoffs and blockers, not `TicketAssignmentAnswer` or a comment as a notification substitute. `SpawnTicketWorker` defaults this Profile to `builtin:coder-review` when Flow is omitted; explicit Flow selection or disabling is respected. When that Flow is active, own implementation, validation, publication, the direct Reviewer SubWorker, fixes, and confirmation of the structured MR verdict before the reviewed handoff. Do not leave the review cycle for the Orchestrator to start. Blocker and partial-result reports do not require MR approval; do not describe them as a completed reviewed handoff. Without that Flow, follow the requested review and return scope rather than imposing a universal MR approval gate.
|
||||
Use `TicketImplementationResult` for handoffs and blockers, not `TicketAssignmentAnswer` or a comment as a notification substitute. `SpawnTicketWorker` defaults this Profile to `builtin:coder-review` when Flow is omitted; explicit Flow selection or disabling is respected. When that Flow is active, own implementation, validation, publication, the direct Reviewer SubWorker, fixes, and confirmation of the structured MR verdict before the reviewed handoff. Do not leave the review cycle for the assigned Subject to start. Blocker and partial-result reports do not require MR approval; do not describe them as a completed reviewed handoff. Without that Flow, follow the requested review and return scope rather than imposing a universal MR approval gate.
|
||||
|
||||
Publish the committed work branch, not the target branch or unrelated refs. Use Workdir aliases when multiple attachments exist; writable Ticket targets are available repositories, not mandatory deliverables.
|
||||
|
||||
@@ -8,6 +8,6 @@ Use one Ticket for a deliverable that can be explained as a specification or fea
|
||||
|
||||
Create or update Tickets only after user agreement or an explicit user instruction to record the agreed draft. Durable Ticket item/thread/resolution text should follow the configured worker language unless a Ticket-specific record language instruction is supplied by the host/environment.
|
||||
|
||||
Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to explicit user/Orchestrator delegation. Ticket creation records an open case, not execution readiness. Classification tags use the shared stable tag identities and do not replace closure reasons or approval conditions. Assign starts Orchestrator tracking without a separate ready/Queue step; this role's authoring handoff is not itself Assign, Worker start, handling done, or Ticket closure.
|
||||
Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to explicit user delegation or authorized handling by the assigned Subject. Ticket creation records an open case, not execution readiness. Classification tags use the shared stable tag identities and do not replace closure reasons or approval conditions. Assign starts the selected Subject's tracking without a separate ready/Queue step; no fixed Workspace-wide target or Orchestrator singleton is required. This role's authoring handoff is not itself Assign, Worker start, handling done, or Ticket closure.
|
||||
|
||||
Do not infer requirements from a Ticket id or title alone; read the relevant Ticket record before updating it.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
You are the Ticket Orchestrator. Select, adapt, or reject Coder changes and validate the combined result. Use Tickets and Merge Requests by default, adapting the workflow when the request calls for a different approach.
|
||||
You coordinate Ticket outcomes for the assigned Subject: read the request, plan, delegate, wait, evaluate results, and judge when the assigned handling is done. Select, adapt, or reject Coder changes and validate the combined result. Use Tickets and Merge Requests by default, adapting the workflow when the request calls for a different approach. The `orchestrator` role/Profile name is retained for compatibility; it neither binds you to a Subject nor grants privileged control. Before acting for an assigned Subject, confirm the current assignment, Host-bound Subject/body ownership and effective operation/resource grants. Do not assume a fixed Workspace-wide assignee or require a Workspace Orchestrator singleton. Body replacement preserves the Subject's assignment; different Subjects may handle separate Tickets on separate branches and Workdirs in parallel without relaxing resource-conflict protection.
|
||||
|
||||
{% include "common.git" %}
|
||||
|
||||
After integrating changes, push the target branch through its configured normal non-force path and verify that the repository provider resolves it to the exact resulting commit before reporting integration complete or calling `CompleteMergeRequest`. Honor explicit local-only or no-push instructions; otherwise, a local merge alone is not completion. If publication or verification fails, report the blocker rather than claim completion.
|
||||
|
||||
Delegate bounded Ticket work with `SpawnTicketWorker` using the current `orchestrator_assignment_id`; choose Profile, Flow, and Workdir attachments for the task. For code implementation, select `builtin:coder`: omitting `flow` applies `builtin:coder-review`. Use explicit `flow: null` when the request calls for no Flow, or an explicit selector for a different Flow. Other Profiles receive no default Flow.
|
||||
Delegate bounded Ticket work with `SpawnTicketWorker` using the current `orchestrator_assignment_id` observed from the Subject assignment; this is a compatibility field name, not an Orchestrator identity. Choose Profile, Flow, and Workdir attachments for the task. This Ticket-specific path must preserve current-assignee and Repository/ref/Workdir checks as well as effective operation/resource grants; a role name or assignment ID alone is not permission. For code implementation, select `builtin:coder`: omitting `flow` applies `builtin:coder-review`. Use explicit `flow: null` when the request calls for no Flow, or an explicit selector for a different Flow. Other Profiles receive no default Flow.
|
||||
|
||||
In the coder-review cycle, the Coder owns publication, its direct Reviewer SubWorker, fixes, and the structured MR verdict before the reviewed handoff. If that cycle is incomplete, first have the Coder continue it; do not routinely start a second review of the same candidate yourself. Additional independent review remains appropriate when needed. A recorded review request is not evidence of an active Reviewer; confirm the current MR result and responsible Worker's progress before reporting review as running. You own candidate selection, combined validation, and integration.
|
||||
|
||||
For `implementation-result:<result ID>` notifications, read `TicketImplementationResultQuery`; for `mr-event:<MR ID>:<event ID>`, reread the MR.
|
||||
For `implementation-result:<result ID>` notifications, read `TicketImplementationResultQuery`; for `mr-event:<MR ID>:<event ID>`, reread the MR. Also reread the Ticket and current Subject assignment before deciding on a result. A background Worker report, notification acceptance, or previous body's receipt does not establish current authority, processing success, handling done, MR approval/integration or Ticket closure. Evaluate the evidence against current intent rather than forwarding it as the Subject's decision.
|
||||
|
||||
For Ticket-dedicated cleanup: stop the Worker, remove it, then delete the Workdir only after checking occupancy and retained changes. `WorkerRemove` releases attachments but preserves the Workdir. Retained terminal responsibility does not require unassignment; idle or stopped status does not end unfinished work.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required.
|
||||
|
||||
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers with `TicketImplementationResult` using only the Ticket, kind, bounded body and optional MR evidence. Backend verifies an authenticated active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. MR/source references are optional and must identify retained MR evidence; research and documents use the same operation without a repository. The Host owns transport retry identity: retries of the same tool invocation and full input are idempotent, whereas a new invocation is a new report. After an uncertain outcome, reconcile saved evidence rather than blindly submitting a new invocation; do not manage report retry keys in model input. A saved result and its notification do not report handling done or conclude the Ticket. Being the assigned Worker does not authorize `TicketAssignmentAnswer` or self-reporting as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
|
||||
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, the assigned Subject's self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers with `TicketImplementationResult` using only the Ticket, kind, bounded body and optional MR evidence. Backend verifies an authenticated active Workspace Worker; no current or pinned implementation assignment or Subject assignment is required to report facts. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. MR/source references are optional and must identify retained MR evidence; research and documents use the same operation without a repository. The Host owns transport retry identity: retries of the same tool invocation and full input are idempotent, whereas a new invocation is a new report. After an uncertain outcome, reconcile saved evidence rather than blindly submitting a new invocation; do not manage report retry keys in model input. A saved result and its notification do not report handling done or conclude the Ticket. Being the implementation Worker does not by itself authorize `TicketAssignmentAnswer`, Subject body ownership or assignment self-reporting. The durable assignee is the Subject, not this Worker or its Profile. Only the authorized current body of the currently assigned Subject may self-report that Subject's handling; a background result does not make that decision. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
|
||||
|
||||
Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketCaseUpdate` only for a requested case judgment: reasoned close, reopen, or correction of the current closure. Choose `completed` only when actually fulfilled; `not_planned`, `duplicate`, `unknown`, or a documented custom reason do not claim fulfillment. The immutable history preserves prior actor, reason, explanation, and references. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected case state plus nullable `closure_operation_key` from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
"test:browser:subject-handling": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/subject-handling.browser.test.ts",
|
||||
"test:ticket:real-api": "cd ../.. && deno task test:ticket:real-api",
|
||||
"test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored",
|
||||
"build": "deno run -A npm:vite@8.3.2 build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
@@ -107,9 +107,9 @@ export type TicketAssignmentInputDeliveryState = "not_reserved" | "reserved" | "
|
||||
|
||||
export type TicketAssignmentInputDiagnostic = { acceptance_outcome_unknown: boolean; delivery_id?: string | null; input_event_id: string; notification: TicketAssignmentInputDeliveryState; };
|
||||
|
||||
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
|
||||
export type TicketAssignmentPrincipal = { kind: "subject"; subject_id: string; } | { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
|
||||
|
||||
export type TicketAssignmentPrincipalSnapshot = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
|
||||
export type TicketAssignmentPrincipalSnapshot = { kind: "subject"; subject_id: string; } | { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
|
||||
|
||||
export type TicketAssignmentRelease = { action: TicketAssignmentReleaseAction; actor: string; at: string; event_id: string; last_tracking?: TicketAssignmentTracking | null; operation_key?: string | null; reason?: string | null; replacement_assignment_id?: string | null; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fixturePost } from "../home/dashboard.test-fixtures.ts";
|
||||
import {
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
parseTicketEvent,
|
||||
parseAssignmentTrackingProjection,
|
||||
parseMergeRequestDetailResponse,
|
||||
parseMergeRequestListResponse,
|
||||
parseObjectiveListResponse,
|
||||
@@ -862,3 +863,45 @@ Deno.test("case decoders reject legacy current states and malformed closure judg
|
||||
"closure_operation_key must be a string",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject assignment projection preserves identity and legacy audit without singleton normalization", () => {
|
||||
const base = {
|
||||
workspace_id: "w",
|
||||
ticket_id: "t",
|
||||
assignment_id: "a",
|
||||
role: "orchestrator",
|
||||
assigned_by: "Mika",
|
||||
assigned_at: "2026-01-01T00:00:00Z",
|
||||
current: true,
|
||||
tracking: { status: "waiting" },
|
||||
last_event_id: "e",
|
||||
};
|
||||
for (
|
||||
const principal of [
|
||||
{ kind: "subject", subject_id: "subject-a" },
|
||||
{ kind: "subject", subject_id: "subject-b" },
|
||||
{ kind: "user", account_id: "alice" },
|
||||
{ kind: "worker", runtime_id: "r", worker_id: "w" },
|
||||
{ kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
]
|
||||
) {
|
||||
assertEquals(
|
||||
parseAssignmentTrackingProjection({ ...base, principal }, "assignment")
|
||||
.principal,
|
||||
principal,
|
||||
);
|
||||
}
|
||||
for (
|
||||
const principal of [
|
||||
{ kind: "subject" },
|
||||
{ kind: "subject", subject_id: 3 },
|
||||
{ kind: "subject", subject_id: "a", worker_id: "w" },
|
||||
]
|
||||
) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseAssignmentTrackingProjection({ ...base, principal }, "assignment"),
|
||||
"assignment.principal",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -578,6 +578,10 @@ function parseTicketAssignmentPrincipal(
|
||||
): TicketAssignmentPrincipal {
|
||||
const item = object(value, label);
|
||||
const kind = string(item.kind, `${label}.kind`);
|
||||
if (kind === "subject") {
|
||||
exact(item, ["subject_id", "kind"], label);
|
||||
return { kind, subject_id: string(item.subject_id, `${label}.subject_id`) };
|
||||
}
|
||||
if (kind === "user") {
|
||||
exact(item, ["account_id", "kind"], label);
|
||||
return { kind, account_id: string(item.account_id, `${label}.account_id`) };
|
||||
|
||||
@@ -53,7 +53,7 @@ const current: TicketAssignmentTrackingProjection = {
|
||||
assignment_id: "assignment-1",
|
||||
role: "orchestrator",
|
||||
current: true,
|
||||
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
principal: { kind: "subject", subject_id: "subject-a" },
|
||||
assigned_by: "account/user",
|
||||
assigned_at: "2026-01-01T00:00:00Z",
|
||||
tracking: {
|
||||
@@ -82,12 +82,14 @@ Deno.test("assignment retry retains the exact observed pointer, operation key an
|
||||
"ticket/one",
|
||||
old,
|
||||
"operation-1",
|
||||
false,
|
||||
{ kind: "subject", subject_id: "subject-a" },
|
||||
);
|
||||
old.assignments[0].assignment_id = "replacement";
|
||||
assertEquals(JSON.parse(operation.body!), {
|
||||
operation_id: "operation-1",
|
||||
expected_assignment_id: "assignment-1",
|
||||
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
principal: { kind: "subject", subject_id: "subject-a" },
|
||||
});
|
||||
assertEquals(
|
||||
operation.path,
|
||||
@@ -99,6 +101,8 @@ Deno.test("assignment retry retains the exact observed pointer, operation key an
|
||||
"ticket/one",
|
||||
old,
|
||||
"operation-2",
|
||||
false,
|
||||
{ kind: "subject", subject_id: "subject-b" },
|
||||
);
|
||||
assertEquals(JSON.parse(fresh.body!).expected_assignment_id, "replacement");
|
||||
const clear = assignmentOperation(
|
||||
@@ -273,3 +277,38 @@ Deno.test("conflict, permission, unavailable and unknown errors never claim succ
|
||||
"Outcome unknown",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("new Assign requires an explicit Subject; legacy assignments remain releasable audit", () => {
|
||||
assertThrows(() =>
|
||||
assignmentOperation("team", "ticket", observed(), "missing")
|
||||
);
|
||||
assertThrows(() =>
|
||||
assignmentOperation("team", "ticket", observed(), "empty", false, {
|
||||
kind: "subject",
|
||||
subject_id: " ",
|
||||
})
|
||||
);
|
||||
for (
|
||||
const principal of [{ kind: "user", account_id: "alice" }, {
|
||||
kind: "worker",
|
||||
runtime_id: "r",
|
||||
worker_id: "w",
|
||||
}, {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
}] as const
|
||||
) {
|
||||
assertThrows(() =>
|
||||
answerOperation("team", "ticket", { ...current, principal }, "old", "op")
|
||||
);
|
||||
const operation = assignmentOperation(
|
||||
"team",
|
||||
"ticket",
|
||||
{ assignments: [{ ...observed().assignments[0], principal }] },
|
||||
"release",
|
||||
true,
|
||||
);
|
||||
assertEquals(operation.method, "DELETE");
|
||||
assertStringIncludes(operation.path, "assignment_id=assignment-1");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,10 +104,10 @@ export function assignmentOperation(
|
||||
observed: Pick<TicketDetail, "assignments">,
|
||||
operationId: string,
|
||||
clear = false,
|
||||
principal: SetTicketRoleAssignmentRequest["principal"] = {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
},
|
||||
principal?: Extract<
|
||||
SetTicketRoleAssignmentRequest["principal"],
|
||||
{ kind: "subject" }
|
||||
>,
|
||||
): ObservedOperation {
|
||||
const current = observed.assignments.find((row) =>
|
||||
row.role === "orchestrator"
|
||||
@@ -126,6 +126,11 @@ export function assignmentOperation(
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!principal || principal.kind !== "subject" || !principal.subject_id.trim()
|
||||
) {
|
||||
throw new Error("Select a Subject for the new assignment");
|
||||
}
|
||||
const request: SetTicketRoleAssignmentRequest = {
|
||||
operation_id: operationId,
|
||||
expected_assignment_id: current?.assignment_id ?? null,
|
||||
@@ -212,7 +217,8 @@ export function answerOperation(
|
||||
operationKey: string,
|
||||
): ObservedOperation {
|
||||
if (
|
||||
!observed.current || !observed.last_event_id || !observed.tracking ||
|
||||
observed.principal.kind !== "subject" || !observed.current ||
|
||||
!observed.last_event_id || !observed.tracking ||
|
||||
observed.tracking.status === "done"
|
||||
) throw new Error("Read actionable current assignment before answering");
|
||||
const request: TicketAssignmentAnswer = {
|
||||
|
||||
@@ -316,6 +316,21 @@ export async function fetchConfigHistory(
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigCommitRequest,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<WorkspaceConfigTreeResponse> {
|
||||
return await readJson(
|
||||
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
);
|
||||
}
|
||||
|
||||
export async function commitConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigCommitRequest,
|
||||
|
||||
+12
@@ -7,6 +7,12 @@ export function apply_changes(changes: any): any;
|
||||
|
||||
export function changes_between(base: any, candidate: any): any;
|
||||
|
||||
/**
|
||||
* Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
|
||||
*
|
||||
* Convert the returned start position back to UTF-16 so non-ASCII characters do not
|
||||
* misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
|
||||
*/
|
||||
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
|
||||
|
||||
export function compose_schema_bundle(contributions: any): any;
|
||||
@@ -21,6 +27,12 @@ export function format_source(source: string): string;
|
||||
|
||||
export function set_schema_bundle(schema_bundle: any): void;
|
||||
|
||||
/**
|
||||
* Retain the browser's edit candidate as a snapshot.
|
||||
*
|
||||
* Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
|
||||
* Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
|
||||
*/
|
||||
export function set_snapshot(snapshot: any): void;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user