feat: integrate durable Ticket assignment tracking with current develop
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
//! Workspace adapter: Ticket owns the assignment/tracking schema and mutations.
|
||||
//! Authenticated source is constructed by Backend after singleton resolution;
|
||||
//! grants, work releases and startup recovery never derive from tracking status.
|
||||
use super::*;
|
||||
|
||||
/// Backend-authenticated attribution. Readable assigned_by/actor is audit
|
||||
/// payload, not this authority context. Never decoded from model/API input.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TicketOrchestratorAssignmentSourceContext {
|
||||
Runtime {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
agent_key: String,
|
||||
authentication_binding_id: String,
|
||||
},
|
||||
Account {
|
||||
account_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn require_runtime_source(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
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()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn require_assignment_source(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
source: &TicketOrchestratorAssignmentSourceContext,
|
||||
) -> Result<()> {
|
||||
match source {
|
||||
TicketOrchestratorAssignmentSourceContext::Runtime {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
} => require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
),
|
||||
TicketOrchestratorAssignmentSourceContext::Account {
|
||||
account_id,
|
||||
user_id,
|
||||
} => {
|
||||
// Backend already authenticates Account/Workspace permission. Do not
|
||||
// infer a model role or require a Runtime for account authoring.
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM accounts WHERE account_id=?1)",
|
||||
[account_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists || user_id.trim().is_empty() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Assignment account source is not a valid Backend actor".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
update: &TicketAssignmentTrackingUpdate,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<TicketAssignmentTrackingProjection> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
// Every call, including a receipt replay, fences the authenticated
|
||||
// Runtime binding and singleton snapshot under the write lock. Assignment
|
||||
// replacement/removal may be replayed, Worker replacement may not.
|
||||
require_runtime_source(
|
||||
&tx,
|
||||
workspace_id,
|
||||
&source.runtime_id,
|
||||
&source.worker_id,
|
||||
&source.agent_key,
|
||||
&source.authentication_binding_id,
|
||||
)?;
|
||||
let result = ticket::sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
&tx,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
update,
|
||||
source,
|
||||
)
|
||||
.map_err(|error| Error::TicketAssignmentConflict(error.to_string()))?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn get(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
) -> Result<Option<TicketAssignmentTrackingProjection>> {
|
||||
store.with_conn(|conn| {
|
||||
ticket::sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, assignment_id)
|
||||
.map_err(|error| Error::Store(error.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn current(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<TicketAssignmentTrackingProjection>> {
|
||||
store.with_conn(|conn| {
|
||||
ticket::sqlite_current_ticket_assignment_tracking(conn, workspace_id, ticket_id)
|
||||
.map_err(|error| Error::Store(error.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn history(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
) -> Result<Vec<TicketAssignmentTrackingHistoryEntry>> {
|
||||
store.with_conn(|conn| {
|
||||
ticket::sqlite_ticket_assignment_tracking_history(
|
||||
conn,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
assignment_id,
|
||||
)
|
||||
.map_err(|error| Error::Store(error.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn history_page(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
after_event_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<ticket::TicketAssignmentTrackingHistoryPage> {
|
||||
store.with_conn(|conn| {
|
||||
ticket::sqlite_ticket_assignment_tracking_history_page(
|
||||
conn,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
assignment_id,
|
||||
after_event_id,
|
||||
limit,
|
||||
)
|
||||
.map_err(|error| match error {
|
||||
ticket::TicketError::Conflict(message) => Error::InvalidInput(message),
|
||||
other => Error::Store(other.to_string()),
|
||||
})
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -238,6 +238,20 @@ impl SqliteWorkspaceAuthority {
|
||||
})
|
||||
}
|
||||
|
||||
/// Assignment self-report enrichment is a separate projection so retained
|
||||
/// responsibility and existing Ticket constructors stay distinct from status.
|
||||
pub fn ticket_assignment_tracking(
|
||||
&self,
|
||||
reference: &str,
|
||||
) -> Result<Vec<ticket::TicketAssignmentTrackingProjection>> {
|
||||
let ticket = self
|
||||
.ticket_backend
|
||||
.show(TicketIdOrSlug::from(reference))
|
||||
.map_err(Error::from)?;
|
||||
self.store
|
||||
.list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)
|
||||
}
|
||||
|
||||
pub fn with_source_commit_resolver(
|
||||
mut self,
|
||||
source_commit_resolver: Arc<dyn TicketSourceCommitResolver>,
|
||||
@@ -905,7 +919,11 @@ impl SqliteWorkspaceAuthority {
|
||||
&blocker.blocking_ticket,
|
||||
)?;
|
||||
}
|
||||
let assignment_tracking = self
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?;
|
||||
Ok(TicketDetail {
|
||||
assignment_tracking,
|
||||
id: ticket.meta.id,
|
||||
resource_key,
|
||||
title: ticket.meta.title,
|
||||
@@ -2330,6 +2348,7 @@ fn ticket_query_item(
|
||||
}
|
||||
}
|
||||
TicketQueryItem {
|
||||
assignment_tracking: detail.assignment_tracking.clone(),
|
||||
id: summary.id,
|
||||
resource_key: summary.resource_key,
|
||||
title: summary.title,
|
||||
|
||||
@@ -328,31 +328,7 @@ CREATE TABLE server_secret_objects (
|
||||
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE ticket_assignment_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')),
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
assignment_id TEXT,
|
||||
expected_assignment_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
request_fingerprint TEXT,
|
||||
claim_state TEXT NOT NULL DEFAULT 'committed'
|
||||
CHECK(claim_state IN ('pending', 'failed', 'committed')),
|
||||
failure_reason TEXT,
|
||||
binding_recovery_json TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
CHECK(claim_state = 'committed' OR assignment_id IS NULL),
|
||||
CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL))
|
||||
);
|
||||
-- Ticket-owned assignment schema is composed by store.rs.
|
||||
CREATE TABLE ticket_assignment_ticket_tombstones (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
@@ -368,72 +344,9 @@ CREATE TABLE ticket_assignment_worker_tombstones (
|
||||
PRIMARY KEY (workspace_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE ticket_current_worker_assignments (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
assignment_id TEXT NOT NULL,
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, role, assignment_id),
|
||||
UNIQUE (workspace_id, assignment_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id, role, assignment_id)
|
||||
REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, ticket_id)
|
||||
REFERENCES typed_tickets(workspace_id, ticket_id)
|
||||
ON DELETE CASCADE,
|
||||
CHECK(
|
||||
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
||||
OR
|
||||
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
|
||||
)
|
||||
);
|
||||
CREATE TABLE ticket_worker_assignment_events (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
event_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')),
|
||||
assignment_id TEXT,
|
||||
previous_assignment_id TEXT,
|
||||
actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
operation_id TEXT,
|
||||
reason TEXT,
|
||||
PRIMARY KEY (workspace_id, event_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE ticket_worker_assignments (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
assignment_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
assigned_by TEXT NOT NULL,
|
||||
assigned_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, role, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
CHECK(
|
||||
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
||||
OR
|
||||
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE workspace_runtime_bindings (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
@@ -1165,14 +1078,10 @@ CREATE INDEX idx_repository_ssh_credentials_workspace_status
|
||||
ON repository_ssh_credentials(workspace_id, status, credential_id);
|
||||
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host
|
||||
ON repository_ssh_host_trusts(workspace_id, hostname, port);
|
||||
CREATE INDEX idx_ticket_assignment_operations_ticket
|
||||
ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC);
|
||||
CREATE INDEX idx_ticket_worker_assignment_events_ticket
|
||||
ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC);
|
||||
CREATE INDEX idx_ticket_worker_assignments_principal
|
||||
ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC);
|
||||
CREATE INDEX idx_ticket_worker_assignments_ticket
|
||||
ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC);
|
||||
|
||||
|
||||
|
||||
|
||||
CREATE INDEX idx_workspace_runtime_bindings_workspace
|
||||
ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id);
|
||||
CREATE INDEX idx_typed_ticket_relations_workspace_target
|
||||
@@ -1202,11 +1111,8 @@ CREATE INDEX idx_workspace_resource_keys_reverse
|
||||
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
|
||||
CREATE INDEX repositories_workspace_provider_idx
|
||||
ON repositories(workspace_id, provider);
|
||||
CREATE INDEX ticket_current_principal_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id);
|
||||
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
|
||||
WHERE role IN ('orchestrator', 'worker');
|
||||
|
||||
|
||||
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
||||
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
||||
|
||||
@@ -2808,7 +2808,7 @@ async fn authorize_workspace_api_request(
|
||||
next: axum::middleware::Next,
|
||||
) -> Response {
|
||||
if !request.uri().path().starts_with("/api/")
|
||||
&& ticket_case_workspace_id(request.uri().path()).is_none()
|
||||
&& canonical_ticket_operation_workspace_id(request.uri().path()).is_none()
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
@@ -3031,7 +3031,7 @@ async fn dispatch_workspace_request(
|
||||
if let Some(workspace_id) = workspace_id
|
||||
&& (path.starts_with("/api/w/")
|
||||
|| path.starts_with("/api/runtime/v1/workspaces/")
|
||||
|| ticket_case_workspace_id(&path).is_some())
|
||||
|| canonical_ticket_operation_workspace_id(&path).is_some())
|
||||
&& let Err(response) =
|
||||
authorize_scoped_workspace_request(&api, workspace_id, &mut request).await
|
||||
{
|
||||
@@ -3105,9 +3105,9 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp
|
||||
static_file_or_spa_response(static_root, path).await
|
||||
}
|
||||
|
||||
// The canonical case route is deliberately not an /api/w alias. Recognize only
|
||||
// this operation so its authentication/scope is identical without broadening CRUD.
|
||||
fn ticket_case_workspace_id(path: &str) -> Option<&str> {
|
||||
// Canonical case/tracking routes are deliberately not /api/w aliases. Recognize
|
||||
// only these operations so authentication/scope are identical without exposing CRUD.
|
||||
fn canonical_ticket_operation_workspace_id(path: &str) -> Option<&str> {
|
||||
let mut segments = path.strip_prefix("/workspaces/")?.split('/');
|
||||
let workspace_id = segments.next()?;
|
||||
let resource = segments.next()?;
|
||||
@@ -3116,13 +3116,20 @@ fn ticket_case_workspace_id(path: &str) -> Option<&str> {
|
||||
(!workspace_id.is_empty()
|
||||
&& resource == "tickets"
|
||||
&& !ticket_id.is_empty()
|
||||
&& operation == "case"
|
||||
&& segments.next().is_none())
|
||||
&& match operation {
|
||||
"case" => segments.next().is_none(),
|
||||
"assignment-tracking" => match segments.next() {
|
||||
None => true,
|
||||
Some("history") => segments.next().is_none(),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
})
|
||||
.then_some(workspace_id)
|
||||
}
|
||||
|
||||
fn scoped_workspace_id(path: &str) -> Option<&str> {
|
||||
if let Some(workspace_id) = ticket_case_workspace_id(path) {
|
||||
if let Some(workspace_id) = canonical_ticket_operation_workspace_id(path) {
|
||||
return Some(workspace_id);
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("/api/runtime/v1/workspaces/") {
|
||||
@@ -6232,6 +6239,7 @@ async fn attach_server_request_context(
|
||||
.extensions()
|
||||
.get::<crate::worker_source::VerifiedRuntimeRequestSource>()
|
||||
.map(|source| server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: source.authentication_binding_id.clone(),
|
||||
runtime_id: source.runtime_id.clone(),
|
||||
worker_id: source.worker_id.clone(),
|
||||
});
|
||||
@@ -6601,6 +6609,11 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::ticket_case_update(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::ticket_assignment_tracking_get(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::ticket_assignment_tracking_update(service.clone()))
|
||||
.merge(server_api::server_api_axum::ticket_assignment_tracking_history(service.clone()))
|
||||
.merge(server_api::server_api_axum::workspace_ticket_tag_list(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -10538,6 +10551,75 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn ticket_assignment_tracking_get(
|
||||
&self,
|
||||
_context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
ticket_id: String,
|
||||
query: server_api::TicketAssignmentTrackingQuery,
|
||||
) -> std::result::Result<server_api::TicketAssignmentTrackingList, server_api::RepositoryApiError>
|
||||
{
|
||||
let api = self.workspace_api()?;
|
||||
validate_workspace_scope(api, &workspace_id)
|
||||
.map_err(ApiError::from)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(api, &workspace_id, &ticket_id)
|
||||
.map_err(ApiError::from)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
let values = if let Some(assignment_id) = query.assignment_id {
|
||||
api.store
|
||||
.get_ticket_assignment_tracking(&workspace_id, &ticket_id, &assignment_id)
|
||||
.map(|value| value.into_iter().collect::<Vec<_>>())
|
||||
} else {
|
||||
api.authority.ticket_assignment_tracking(&ticket_id)
|
||||
}
|
||||
.map_err(ApiError::from)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
Ok(server_api::TicketAssignmentTrackingList(values))
|
||||
}
|
||||
|
||||
async fn ticket_assignment_tracking_update(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
ticket_id: String,
|
||||
request: server_api::TicketAssignmentTrackingUpdateRequest,
|
||||
) -> std::result::Result<
|
||||
server_api::TicketAssignmentTrackingRecord,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
let Json(value) = scoped_ticket_assignment_tracking_update_with_context(
|
||||
State(self.workspace_api()?.clone()),
|
||||
contract_request_headers(&context)?,
|
||||
&context,
|
||||
AxumPath((workspace_id, ticket_id)),
|
||||
Json(request.0),
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
Ok(server_api::TicketAssignmentTrackingRecord(value))
|
||||
}
|
||||
|
||||
async fn ticket_assignment_tracking_history(
|
||||
&self,
|
||||
_context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
ticket_id: String,
|
||||
query: server_api::TicketAssignmentTrackingHistoryQuery,
|
||||
) -> std::result::Result<
|
||||
server_api::TicketAssignmentTrackingHistoryPage,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
let Json(value) = scoped_ticket_assignment_tracking_history(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath((workspace_id, ticket_id)),
|
||||
Query(query),
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn ticket_assignment_list(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
@@ -10557,6 +10639,7 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
|
||||
async fn ticket_assignment_set(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
id: String,
|
||||
role: String,
|
||||
@@ -10565,8 +10648,10 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
server_api::TicketRoleAssignmentMutationResponse,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
let Json(response) = scoped_set_ticket_assignment(
|
||||
let Json(response) = scoped_set_ticket_assignment_with_context(
|
||||
State(self.workspace_api()?.clone()),
|
||||
contract_request_headers(&context)?,
|
||||
&context,
|
||||
AxumPath((workspace_id, id, role)),
|
||||
Json(request),
|
||||
)
|
||||
@@ -10577,6 +10662,7 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
|
||||
async fn ticket_assignment_clear(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
id: String,
|
||||
role: String,
|
||||
@@ -10585,8 +10671,10 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
server_api::TicketRoleAssignmentMutationResponse,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
let Json(response) = scoped_clear_ticket_assignment(
|
||||
let Json(response) = scoped_clear_ticket_assignment_with_context(
|
||||
State(self.workspace_api()?.clone()),
|
||||
contract_request_headers(&context)?,
|
||||
&context,
|
||||
AxumPath((workspace_id, id, role)),
|
||||
Query(query),
|
||||
)
|
||||
@@ -13667,10 +13755,85 @@ async fn finalize_manual_ticket_worker_claim(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn scoped_set_ticket_assignment(
|
||||
state: State<WorkspaceApi>,
|
||||
path: AxumPath<(String, String, String)>,
|
||||
request: Json<server_api::SetTicketRoleAssignmentRequest>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
apply_set_ticket_assignment(state, path, request, "workspace-web".into(), None).await
|
||||
}
|
||||
|
||||
fn authenticated_assignment_source(
|
||||
api: &WorkspaceApi,
|
||||
headers: &HeaderMap,
|
||||
context: &server_api::ServerRequestContext,
|
||||
) -> Result<(
|
||||
String,
|
||||
crate::store::TicketOrchestratorAssignmentSourceContext,
|
||||
)> {
|
||||
if let Some(source) = authenticated_ticket_worker_source(headers, context)? {
|
||||
if !find_workspace_orchestrator(api).is_some_and(|worker| worker.worker == source) {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Ticket assignment mutations require the registered current Workspace Orchestrator"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let authentication_binding_id = context
|
||||
.runtime_source
|
||||
.as_ref()
|
||||
.map(|source| source.authentication_binding_id.clone())
|
||||
.ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied(
|
||||
"Assignment requires the Runtime binding used to authenticate this Worker"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let actor = format!("worker:{}/{}", source.runtime_id, source.worker_id);
|
||||
return Ok((
|
||||
actor,
|
||||
crate::store::TicketOrchestratorAssignmentSourceContext::Runtime {
|
||||
runtime_id: source.runtime_id,
|
||||
worker_id: source.worker_id,
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
authentication_binding_id,
|
||||
},
|
||||
));
|
||||
}
|
||||
let actor = context.actor.as_ref().ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied(
|
||||
"Ticket assignment mutations require an authenticated actor".into(),
|
||||
)
|
||||
})?;
|
||||
let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id))
|
||||
.map_err(|error| Error::Config(format!("failed to encode assignment actor: {error}")))?;
|
||||
Ok((
|
||||
format!("workspace-user:{identity}"),
|
||||
crate::store::TicketOrchestratorAssignmentSourceContext::Account {
|
||||
account_id: actor.account_id.clone(),
|
||||
user_id: actor.user_id.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_set_ticket_assignment_with_context(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
context: &server_api::ServerRequestContext,
|
||||
path: AxumPath<(String, String, String)>,
|
||||
request: Json<server_api::SetTicketRoleAssignmentRequest>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.0.0)?;
|
||||
let (author, source) = authenticated_assignment_source(&api, &headers, context)?;
|
||||
apply_set_ticket_assignment(State(api), path, request, author, Some(source)).await
|
||||
}
|
||||
|
||||
async fn apply_set_ticket_assignment(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>,
|
||||
Json(request): Json<server_api::SetTicketRoleAssignmentRequest>,
|
||||
assigned_by: String,
|
||||
source: Option<crate::store::TicketOrchestratorAssignmentSourceContext>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
let ticket = api.authority.ticket(&id)?;
|
||||
@@ -13688,33 +13851,30 @@ async fn scoped_set_ticket_assignment(
|
||||
assignment_id: new_id("tasg"),
|
||||
role,
|
||||
principal,
|
||||
assigned_by: "workspace-web".to_string(),
|
||||
assigned_by,
|
||||
assigned_at,
|
||||
};
|
||||
let assignment = match role {
|
||||
TicketAssignmentRole::Orchestrator => {
|
||||
if api
|
||||
.store
|
||||
.get_active_ticket_role_assignment(
|
||||
&workspace_id,
|
||||
&ticket.id,
|
||||
TicketAssignmentRole::Worker,
|
||||
if let Some(source) = source.as_ref() {
|
||||
api.store.set_current_ticket_orchestrator_assignment(
|
||||
&record,
|
||||
expected_assignment_id.as_deref(),
|
||||
&new_id("tasev"),
|
||||
&operation_id,
|
||||
expected_assignment_id.is_some(),
|
||||
source,
|
||||
)?
|
||||
} else {
|
||||
// Only the cfg(test) direct-handler adapter lacks a Backend source.
|
||||
api.store.set_current_ticket_role_assignment(
|
||||
&record,
|
||||
expected_assignment_id.as_deref(),
|
||||
&new_id("tasev"),
|
||||
&operation_id,
|
||||
expected_assignment_id.is_some(),
|
||||
)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Orchestrator assignment conflicts with an active Worker assignment"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
api.store.set_current_ticket_role_assignment(
|
||||
&record,
|
||||
expected_assignment_id.as_deref(),
|
||||
&new_id("tasev"),
|
||||
&operation_id,
|
||||
expected_assignment_id.is_some(),
|
||||
)?
|
||||
}
|
||||
TicketAssignmentRole::Worker => {
|
||||
if expected_assignment_id.is_some() {
|
||||
@@ -13747,10 +13907,33 @@ async fn scoped_set_ticket_assignment(
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn scoped_clear_ticket_assignment(
|
||||
state: State<WorkspaceApi>,
|
||||
path: AxumPath<(String, String, String)>,
|
||||
query: Query<server_api::ClearTicketRoleAssignmentQuery>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
apply_clear_ticket_assignment(state, path, query, "workspace-web".into(), None).await
|
||||
}
|
||||
|
||||
async fn scoped_clear_ticket_assignment_with_context(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
context: &server_api::ServerRequestContext,
|
||||
path: AxumPath<(String, String, String)>,
|
||||
query: Query<server_api::ClearTicketRoleAssignmentQuery>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.0.0)?;
|
||||
let (author, source) = authenticated_assignment_source(&api, &headers, context)?;
|
||||
apply_clear_ticket_assignment(State(api), path, query, author, Some(source)).await
|
||||
}
|
||||
|
||||
async fn apply_clear_ticket_assignment(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>,
|
||||
Query(query): Query<server_api::ClearTicketRoleAssignmentQuery>,
|
||||
author: String,
|
||||
source: Option<crate::store::TicketOrchestratorAssignmentSourceContext>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
let ticket = api.authority.ticket(&id)?;
|
||||
@@ -13769,17 +13952,36 @@ async fn scoped_clear_ticket_assignment(
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("unassign requires assignment_id".to_string())
|
||||
})?;
|
||||
let cleared = api.store.clear_current_ticket_role_assignment(
|
||||
&workspace_id,
|
||||
&ticket.id,
|
||||
role,
|
||||
&assignment_id,
|
||||
&new_id("tasev"),
|
||||
&operation_id,
|
||||
"workspace-web",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
Some("role assignment removed from Ticket detail"),
|
||||
)?;
|
||||
let event_id = new_id("tasev");
|
||||
let occurred_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||
let reason = Some("role assignment removed from Ticket detail");
|
||||
let cleared = if role == TicketAssignmentRole::Orchestrator
|
||||
&& let Some(source) = source.as_ref()
|
||||
{
|
||||
api.store.clear_current_ticket_orchestrator_assignment(
|
||||
&workspace_id,
|
||||
&ticket.id,
|
||||
&assignment_id,
|
||||
&event_id,
|
||||
&operation_id,
|
||||
&author,
|
||||
&occurred_at,
|
||||
reason,
|
||||
source,
|
||||
)?
|
||||
} else {
|
||||
api.store.clear_current_ticket_role_assignment(
|
||||
&workspace_id,
|
||||
&ticket.id,
|
||||
role,
|
||||
&assignment_id,
|
||||
&event_id,
|
||||
&operation_id,
|
||||
&author,
|
||||
&occurred_at,
|
||||
reason,
|
||||
)?
|
||||
};
|
||||
if !cleared {
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"assignment `{assignment_id}` is not current for role `{}`",
|
||||
@@ -16512,6 +16714,82 @@ async fn scoped_ticket_case_update(
|
||||
apply_ticket_case_update(State(api), source, None, path, request).await
|
||||
}
|
||||
|
||||
async fn scoped_ticket_assignment_tracking_update_with_context(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
context: &server_api::ServerRequestContext,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
Json(update): Json<ticket::TicketAssignmentTrackingUpdate>,
|
||||
) -> ApiResult<Json<ticket::TicketAssignmentTrackingProjection>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
let source = authenticated_ticket_worker_source(&headers, context)?.ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied(
|
||||
"Tracking is self-reported by a verified Runtime Worker".into(),
|
||||
)
|
||||
})?;
|
||||
// Tracking authority is retained responsibility plus the registered singleton,
|
||||
// NOT Ticket openness, active work, Worker state or a model-supplied role.
|
||||
if !find_workspace_orchestrator(&api).is_some_and(|worker| worker.worker == source) {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Tracking updates require the registered current Workspace Orchestrator".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let authentication_binding_id = context
|
||||
.runtime_source
|
||||
.as_ref()
|
||||
.map(|source| source.authentication_binding_id.clone())
|
||||
.ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied(
|
||||
"Tracking requires the Runtime binding actually used to authenticate the request"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let source_context = ticket::TicketAssignmentTrackingSourceContext {
|
||||
actor: format!("worker:{}/{}", source.runtime_id, source.worker_id),
|
||||
runtime_id: source.runtime_id,
|
||||
worker_id: source.worker_id,
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
authentication_binding_id,
|
||||
};
|
||||
// The Ticket-owned mutation checks current assignment/CAS and exact replay in
|
||||
// one transaction. A historical receipt never restores operational grants.
|
||||
let value = api.store.update_ticket_assignment_tracking(
|
||||
&workspace_id,
|
||||
&ticket_id,
|
||||
&update,
|
||||
&source_context,
|
||||
)?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
async fn scoped_ticket_assignment_tracking_history(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
Query(query): Query<server_api::TicketAssignmentTrackingHistoryQuery>,
|
||||
) -> ApiResult<Json<server_api::TicketAssignmentTrackingHistoryPage>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
let limit = query.limit.unwrap_or(50);
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(
|
||||
Error::InvalidInput("tracking history limit must be between 1 and 100".into()).into(),
|
||||
);
|
||||
}
|
||||
let page = api.store.page_ticket_assignment_tracking_history(
|
||||
&workspace_id,
|
||||
&ticket_id,
|
||||
&query.assignment_id,
|
||||
query.after_event_id.as_deref(),
|
||||
limit,
|
||||
)?;
|
||||
Ok(Json(server_api::TicketAssignmentTrackingHistoryPage {
|
||||
items: page.items,
|
||||
next_event_id: page.next_event_id,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_ticket_case_update_with_context(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
@@ -16519,6 +16797,20 @@ async fn scoped_ticket_case_update_with_context(
|
||||
path: AxumPath<(String, String)>,
|
||||
request: Json<ticket::TicketCaseUpdate>,
|
||||
) -> ApiResult<Json<ticket::Ticket>> {
|
||||
let source = authenticated_ticket_worker_source(&headers, context)?;
|
||||
if source.is_none() && context.actor.is_none() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Ticket case decisions require an authenticated actor".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
apply_ticket_case_update(State(api), source, context.actor.as_ref(), path, request).await
|
||||
}
|
||||
|
||||
fn authenticated_ticket_worker_source(
|
||||
headers: &HeaderMap,
|
||||
context: &server_api::ServerRequestContext,
|
||||
) -> Result<Option<WorkerMutationSource>> {
|
||||
// Authentication already verified membership and the request's signature/body.
|
||||
// Hints are redundant assertions, never authority or a required identity source.
|
||||
let source = if let Some(source) = context.runtime_source.as_ref() {
|
||||
@@ -16554,13 +16846,7 @@ async fn scoped_ticket_case_update_with_context(
|
||||
.into());
|
||||
}
|
||||
}
|
||||
if source.is_none() && context.actor.is_none() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Ticket case decisions require an authenticated actor".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
apply_ticket_case_update(State(api), source, context.actor.as_ref(), path, request).await
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
async fn apply_ticket_case_update(
|
||||
@@ -28090,6 +28376,7 @@ async fn runtime_resource_fetch_contract(
|
||||
message: "Runtime request proof is invalid".to_owned(),
|
||||
})?;
|
||||
server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: source.authentication_binding_id,
|
||||
runtime_id: source.runtime_id,
|
||||
worker_id: source.worker_id,
|
||||
}
|
||||
@@ -32442,6 +32729,10 @@ impl IntoResponse for ApiError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod assignment_tracking_tests {
|
||||
include!("server_assignment_tracking_tests.rs");
|
||||
}
|
||||
|
||||
mod ticket_case_signed_tests {
|
||||
include!("server_ticket_case_signed_tests.rs");
|
||||
}
|
||||
@@ -37892,6 +38183,7 @@ mod tests {
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
|
||||
// Unit fixture stands below proof middleware, so inject its verified subject.
|
||||
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
token_id_hash: None,
|
||||
runtime_id: fixture.controller.runtime_id.clone(),
|
||||
worker_id: Some(fixture.controller.worker_id.clone()),
|
||||
@@ -38216,6 +38508,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
|
||||
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
token_id_hash: None,
|
||||
runtime_id: controller.runtime_id.clone(),
|
||||
worker_id: Some(controller.worker_id.clone()),
|
||||
@@ -40250,6 +40543,7 @@ mod tests {
|
||||
actor: None,
|
||||
worker_source: None,
|
||||
runtime_source: Some(server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
runtime_id: created.runtime_id.clone(),
|
||||
worker_id: Some(created.worker_id.clone()),
|
||||
}),
|
||||
@@ -47969,8 +48263,10 @@ mod tests {
|
||||
input.workflow_state = Some(TicketState::Open);
|
||||
set_test_ticket_target(&mut input, "test-repository", "develop");
|
||||
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
|
||||
let old_coder = seed_live_test_coder_assignment(&api, &ticket.id, "old-coder").await;
|
||||
// The retained delegation precedes actual Coder work. A new Assign
|
||||
// during that work would be a different operation and must be refused.
|
||||
assign_test_orchestrator(&api, &ticket.id);
|
||||
let old_coder = seed_live_test_coder_assignment(&api, &ticket.id, "old-coder").await;
|
||||
let old_orchestrator_id = format!("orchestrator-{}", ticket.id);
|
||||
let Json(started) = scoped_start_workspace_orchestrator(
|
||||
State(api.clone()),
|
||||
@@ -48795,6 +49091,9 @@ mod tests {
|
||||
assigned_by: "test-user".to_string(),
|
||||
assigned_at: TEST_CREATED_AT.to_string(),
|
||||
};
|
||||
// Seed an existing delegation before starting the Worker; both may
|
||||
// remain current, but a fresh delegation during Worker work is guarded.
|
||||
assign_test_orchestrator(&api, &ticket_id);
|
||||
api.store
|
||||
.set_current_ticket_worker_assignment(
|
||||
&assignment,
|
||||
@@ -48804,7 +49103,6 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assign_test_orchestrator(&api, &ticket_id);
|
||||
let path = || ScopedRecordPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
id: ticket_id.clone(),
|
||||
@@ -52879,6 +53177,7 @@ mod tests {
|
||||
worker_id: "legacy-worker".to_string(),
|
||||
}),
|
||||
Some(server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
runtime_id: "runtime-source".to_string(),
|
||||
worker_id: Some("runtime-worker".to_string()),
|
||||
}),
|
||||
@@ -52891,6 +53190,7 @@ mod tests {
|
||||
contract_workdir_removal_source_actor(&context(
|
||||
None,
|
||||
Some(server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
runtime_id: "runtime-source".to_string(),
|
||||
worker_id: Some("runtime-worker".to_string()),
|
||||
}),
|
||||
@@ -52903,6 +53203,7 @@ mod tests {
|
||||
contract_workdir_removal_source_actor(&context(
|
||||
None,
|
||||
Some(server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
runtime_id: "runtime-only".to_string(),
|
||||
worker_id: None,
|
||||
}),
|
||||
@@ -52914,6 +53215,7 @@ mod tests {
|
||||
let error = contract_workdir_removal_source_actor(&context(
|
||||
None,
|
||||
Some(server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
runtime_id: "runtime-only".to_string(),
|
||||
worker_id: None,
|
||||
}),
|
||||
@@ -61118,6 +61420,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
||||
actor: None,
|
||||
worker_source: None,
|
||||
runtime_source: Some(server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
runtime_id: dedicated.worker.runtime_id.clone(),
|
||||
worker_id: Some(dedicated.worker.worker_id.clone()),
|
||||
}),
|
||||
@@ -61141,6 +61444,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
||||
actor: None,
|
||||
worker_source: None,
|
||||
runtime_source: Some(server_api::ServerRuntimeSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
runtime_id: forged.worker.runtime_id.clone(),
|
||||
worker_id: Some(forged.worker.worker_id.clone()),
|
||||
}),
|
||||
|
||||
@@ -721,6 +721,7 @@ async fn drive_verified_upload_revoked_during_reception_cannot_commit_or_replay(
|
||||
token_id_hash: None,
|
||||
runtime_id: workers[0].runtime_id.clone(),
|
||||
worker_id: Some(workers[0].worker_id.clone()),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
},
|
||||
));
|
||||
let response = app
|
||||
@@ -867,6 +868,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
|
||||
context.runtime_source = Some(ServerRuntimeSource {
|
||||
runtime_id: workers[0].runtime_id.clone(),
|
||||
worker_id: Some(workers[1].worker_id.clone()),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
drive::root(&api, &context, TEST_WORKSPACE_ID)
|
||||
|
||||
@@ -53,6 +53,7 @@ fn context(worker: &RuntimeWorkerRef) -> server_api::ServerRequestContext {
|
||||
runtime_source: Some(server_api::ServerRuntimeSource {
|
||||
runtime_id: worker.runtime_id.clone(),
|
||||
worker_id: Some(worker.worker_id.clone()),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
}),
|
||||
origin: None,
|
||||
transport_headers: vec![],
|
||||
|
||||
@@ -632,12 +632,14 @@ async fn request_context_uses_verified_subject_not_headers_or_account_fallback()
|
||||
assert!(WorkerOperationContext::from_request(&api, &headers_only).is_err());
|
||||
let mut runtime_only = operation_request_context(Some(server_api::ServerRuntimeSource {
|
||||
runtime_id: "runtime-a".into(),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
worker_id: None,
|
||||
}));
|
||||
runtime_only.actor = Some(test_browser_request_actor());
|
||||
assert!(WorkerOperationContext::from_request(&api, &runtime_only).is_err());
|
||||
let mut mismatched = operation_request_context(Some(server_api::ServerRuntimeSource {
|
||||
runtime_id: "runtime-a".into(),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
worker_id: Some("controller-a".into()),
|
||||
}));
|
||||
mismatched.actor = Some(test_browser_request_actor());
|
||||
@@ -646,6 +648,7 @@ async fn request_context_uses_verified_subject_not_headers_or_account_fallback()
|
||||
seed_worker_source_member(&api, "runtime-a", "controller-a");
|
||||
let context = operation_request_context(Some(server_api::ServerRuntimeSource {
|
||||
runtime_id: "runtime-a".into(),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
worker_id: Some("controller-a".into()),
|
||||
}));
|
||||
assert!(
|
||||
@@ -665,6 +668,7 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_
|
||||
let context = || {
|
||||
operation_request_context(Some(server_api::ServerRuntimeSource {
|
||||
runtime_id: "offline-runtime".into(),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
worker_id: Some("controller".into()),
|
||||
}))
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -265,10 +265,20 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?,91);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,10);
|
||||
assert_eq!(current_schema_version(conn)?,92);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,11);
|
||||
assert_eq!(fixture_auth_rows(conn),auth_before);
|
||||
for (table, rows) in preserved_tables.iter().zip(&preserved) { assert_eq!(&stored_fixture_rows(conn,table),rows,"preserve {table}"); }
|
||||
for (table, rows) in preserved_tables.iter().zip(&preserved) {
|
||||
let mut migrated = stored_fixture_rows(conn, table);
|
||||
if *table == "ticket_worker_assignments" {
|
||||
// Component 11 adds handling columns to the same identity row.
|
||||
// Compare the ten retained identity/principal/Assign columns,
|
||||
// and separately prove the historical Worker acquired no tracking.
|
||||
for row in &mut migrated { row.truncate(10); }
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments WHERE tracking_json IS NOT NULL", [], |row| row.get::<_,i64>(0))?, 0);
|
||||
}
|
||||
assert_eq!(&migrated, rows, "preserve {table}");
|
||||
}
|
||||
if let Some(rows)=&tags_before {assert_eq!(&classification_rows(conn),rows);}
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_work_releases WHERE assignment_id='assignment'",[],|row|row.get::<_,i64>(0))?,1);
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|row|row.get::<_,i64>(0))?,0);
|
||||
@@ -483,14 +493,14 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, 91);
|
||||
assert_eq!(current_schema_version(conn)?, 92);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)?,
|
||||
10
|
||||
11
|
||||
);
|
||||
assert_eq!(
|
||||
stored_fixture_rows(conn, "typed_ticket_case_history"),
|
||||
|
||||
@@ -12,6 +12,9 @@ use crate::store::ControlPlaneStore;
|
||||
pub struct VerifiedRuntimeRequestSource {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: Option<String>,
|
||||
/// The binding actually used for signature verification. Mutation boundaries
|
||||
/// compare this identity with current trust inside their write transaction.
|
||||
pub authentication_binding_id: String,
|
||||
/// Existing proof JTI correlation, retained only as a fixed-length diagnostic fingerprint.
|
||||
pub token_id_hash: Option<String>,
|
||||
}
|
||||
@@ -273,5 +276,6 @@ pub(crate) async fn verify_runtime_request_source_proof_with_clock(
|
||||
token_id_hash: Some(diagnostic_id_hash(&claims.jti)),
|
||||
runtime_id: claims.iss,
|
||||
worker_id: claims.worker_id,
|
||||
authentication_binding_id: trusted.binding_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignment_events",
|
||||
"ticket_worker_assignments",
|
||||
"typed_ticket_assignment_tracking_history",
|
||||
"typed_ticket_assignment_tracking_operations",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
@@ -464,15 +466,21 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
}
|
||||
let workspace_id = operation.response.workspace_id.clone();
|
||||
|
||||
// Case judgments and legacy snapshots are immutable under ordinary
|
||||
// Ticket operations, including Ticket deletion and FK cascades. Only
|
||||
// this owner-authorized whole-Workspace purge may discard them.
|
||||
// Ticket case/assignment judgments and their operation receipts are
|
||||
// immutable under ordinary domain operations, including Ticket
|
||||
// deletion and FK cascades. Only this owner-authorized whole-Workspace
|
||||
// purge may discard them.
|
||||
// SQLite transactional DDL holds the writer lock: no concurrent writer
|
||||
// can observe the suspended guards, and any error rolls back both
|
||||
// data and schema. Restore the exact schema-owned definitions before
|
||||
// commit; update guards remain installed throughout.
|
||||
let mut case_delete_guards = Vec::new();
|
||||
let mut ticket_delete_guards = Vec::new();
|
||||
for (table, trigger) in [
|
||||
("ticket_worker_assignments", "typed_ticket_assignment_identity_no_delete"),
|
||||
("ticket_worker_assignment_events", "typed_ticket_assignment_events_no_delete"),
|
||||
("ticket_assignment_operations", "typed_ticket_assignment_receipt_no_delete"),
|
||||
("typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_history_no_delete"),
|
||||
("typed_ticket_assignment_tracking_operations", "typed_ticket_assignment_tracking_operations_no_delete"),
|
||||
("typed_ticket_case_history", "typed_ticket_case_history_no_delete"),
|
||||
("typed_ticket_legacy_workflow_snapshot", "typed_ticket_legacy_workflow_no_delete"),
|
||||
] {
|
||||
@@ -482,7 +490,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
tx.execute_batch(&format!("DROP TRIGGER \"{trigger}\";"))?;
|
||||
case_delete_guards.push(definition);
|
||||
ticket_delete_guards.push(definition);
|
||||
}
|
||||
for table in WORKSPACE_DELETION_PURGE_TABLES {
|
||||
tx.execute(
|
||||
@@ -499,7 +507,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
"Workspace disappeared before deletion finalized".to_string(),
|
||||
));
|
||||
}
|
||||
for definition in case_delete_guards {
|
||||
for definition in ticket_delete_guards {
|
||||
tx.execute_batch(&definition)?;
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -978,6 +986,57 @@ mod tests {
|
||||
.create_with_snapshot(ticket::NewTicket::new("Case evidence"))
|
||||
.unwrap()
|
||||
.1;
|
||||
let assignment_id = format!("delegation-{workspace}");
|
||||
store
|
||||
.set_current_ticket_role_assignment(
|
||||
&crate::store::TicketRoleAssignmentRecord {
|
||||
workspace_id: workspace.into(),
|
||||
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(),
|
||||
},
|
||||
assigned_by: "owner".into(),
|
||||
assigned_at: "2026-01-01T00:00:00Z".into(),
|
||||
},
|
||||
None,
|
||||
"assigned",
|
||||
"assign",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
// Seed domain handling evidence under a transaction. This fixture
|
||||
// exercises owner purge, not singleton authentication or delivery.
|
||||
store
|
||||
.with_transaction(|tx| {
|
||||
ticket::sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
tx,
|
||||
workspace,
|
||||
&initial.meta.id,
|
||||
&ticket::TicketAssignmentTrackingUpdate {
|
||||
assignment_id: assignment_id.clone(),
|
||||
expected_last_event_id: "assigned".into(),
|
||||
operation_key: "reported".into(),
|
||||
tracking: ticket::TicketAssignmentTracking {
|
||||
status: ticket::TicketAssignmentTrackingStatus::Done,
|
||||
waiting_reason: None,
|
||||
progress: None,
|
||||
next_action: None,
|
||||
result: Some("Returned result".into()),
|
||||
},
|
||||
},
|
||||
&ticket::TicketAssignmentTrackingSourceContext {
|
||||
actor: "orchestrator".into(),
|
||||
runtime_id: "fixture-runtime".into(),
|
||||
worker_id: "fixture-worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
backend
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
@@ -1022,7 +1081,7 @@ mod tests {
|
||||
}
|
||||
let assert_immutable = |workspace: &str| {
|
||||
store.with_conn(|conn| {
|
||||
for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot"] {
|
||||
for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations"] {
|
||||
assert!(conn.execute(&format!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err());
|
||||
assert!(conn.execute(&format!("UPDATE {table} SET ticket_id=ticket_id WHERE workspace_id=?1"), [workspace]).is_err());
|
||||
}
|
||||
@@ -1052,8 +1111,13 @@ mod tests {
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"ticket_worker_assignments",
|
||||
"ticket_worker_assignment_events",
|
||||
"ticket_assignment_operations",
|
||||
"typed_ticket_assignment_tracking_history",
|
||||
"typed_ticket_assignment_tracking_operations",
|
||||
];
|
||||
let other_before: Vec<String> = store
|
||||
let other_before: Vec<Option<String>> = store
|
||||
.with_conn(|conn| {
|
||||
tables
|
||||
.iter()
|
||||
@@ -1095,7 +1159,12 @@ mod tests {
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 1, "rollback must restore {table}");
|
||||
let expected = if table == "typed_ticket_assignment_tracking_history" {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
assert_eq!(count, expected, "rollback must restore {table}");
|
||||
}
|
||||
conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?;
|
||||
Ok(())
|
||||
@@ -1132,7 +1201,7 @@ mod tests {
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 0, "purge must remove {table}");
|
||||
let after: String = conn.query_row(
|
||||
let after: Option<String> = conn.query_row(
|
||||
&format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"),
|
||||
[],
|
||||
|row| row.get(row.as_ref().column_count() - 1),
|
||||
|
||||
Reference in New Issue
Block a user