chore: merge develop into hare/develop
This commit is contained in:
@@ -619,11 +619,34 @@ async fn assignment_delivery_rendered_content_is_immutable_across_restart_and_co
|
||||
|
||||
#[test]
|
||||
fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_legacy_history() {
|
||||
use crate::authority::{SqliteWorkspaceAuthority, TicketAuthority};
|
||||
use server_api::{TicketQueryRequest, TicketShowRequest};
|
||||
use ticket::{SqliteTicketBackend, TicketBackend, TicketIdOrSlug};
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("schema92.db");
|
||||
// A genuine retained Ticket-10/Workspace-91 fixture goes through its actual
|
||||
// applied tracking migration; no current DB markers or columns are lowered.
|
||||
let conn = tracking_schema91_fixture(&path);
|
||||
// Public browse needs the resource-key rows a real old Workspace already
|
||||
// stored. Seed that old input before migration, never patch its projection.
|
||||
let ids: Vec<String> = conn
|
||||
.prepare("SELECT ticket_id FROM typed_tickets ORDER BY ticket_id")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap();
|
||||
for id in ids {
|
||||
allocate_resource_key(
|
||||
&conn,
|
||||
"w",
|
||||
WorkspaceResourceKind::Ticket,
|
||||
&id,
|
||||
"2026-10-10T00:00:00Z",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
migrate_ticket_tracking_v91_to_v92(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 92);
|
||||
let assignments = stored_fixture_rows(&conn, "ticket_worker_assignments");
|
||||
@@ -668,6 +691,176 @@ fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_l
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap();
|
||||
let backend = SqliteTicketBackend::open_verified(&path, "w").unwrap();
|
||||
let principal = ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
};
|
||||
for (id, status, legacy_state) in [
|
||||
(
|
||||
"pending",
|
||||
Some(TicketAssignmentTrackingStatus::Waiting),
|
||||
"queued",
|
||||
),
|
||||
(
|
||||
"progress",
|
||||
Some(TicketAssignmentTrackingStatus::Inprogress),
|
||||
"inprogress",
|
||||
),
|
||||
("closed-reopen", None, "queued"),
|
||||
("historical-done", None, "done"),
|
||||
] {
|
||||
let stored = reopened
|
||||
.get_ticket_assignment_tracking("w", id, &format!("a-{id}"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored.tracking.as_ref().map(|t| t.status), status, "{id}");
|
||||
assert_eq!(stored.principal, principal, "{id}");
|
||||
assert!(stored.current, "{id}");
|
||||
assert!(stored.last_event_id.is_some(), "{id}");
|
||||
assert!(stored.migration_diagnostic.is_some(), "{id}");
|
||||
let detail = authority.ticket(id).unwrap();
|
||||
assert_eq!(detail.assignment_tracking, vec![stored], "{id}");
|
||||
assert_eq!(
|
||||
authority.ticket_assignment_tracking(id).unwrap(),
|
||||
detail.assignment_tracking,
|
||||
"{id}"
|
||||
);
|
||||
let shown = authority
|
||||
.show_ticket(id, TicketShowRequest::default())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
shown.assignment_tracking, detail.assignment_tracking,
|
||||
"{id}"
|
||||
);
|
||||
let saved_ticket = backend.show(TicketIdOrSlug::Id(id.into())).unwrap();
|
||||
assert_eq!(
|
||||
detail.state.as_str(),
|
||||
saved_ticket.meta.workflow_state.as_str(),
|
||||
"{id}"
|
||||
);
|
||||
assert_eq!(detail.closure_history, saved_ticket.case_history, "{id}");
|
||||
assert_eq!(
|
||||
detail.legacy_handoff, saved_ticket.legacy_workflow_snapshot,
|
||||
"{id}"
|
||||
);
|
||||
assert_eq!(
|
||||
detail.legacy_handoff.as_ref().unwrap().workflow_state,
|
||||
legacy_state,
|
||||
"{id}"
|
||||
);
|
||||
assert_eq!(shown.closure_history, detail.closure_history, "{id}");
|
||||
assert_eq!(shown.legacy_handoff, detail.legacy_handoff, "{id}");
|
||||
assert_eq!(
|
||||
detail.state.as_str(),
|
||||
if id == "historical-done" {
|
||||
"closed"
|
||||
} else {
|
||||
"open"
|
||||
},
|
||||
"{id}"
|
||||
);
|
||||
if id == "closed-reopen" {
|
||||
assert_eq!(detail.closure_history.len(), 2);
|
||||
assert_eq!(
|
||||
detail.closure_history[0].action,
|
||||
ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::Unknown
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
detail.closure_history[1].action,
|
||||
ticket::TicketCaseAction::Reopen
|
||||
);
|
||||
}
|
||||
}
|
||||
let assigned = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
assigned_principal: Some(principal.clone()),
|
||||
limit: Some(100),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let mut assigned_ids = assigned
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assigned_ids.sort_unstable();
|
||||
assert_eq!(
|
||||
assigned_ids,
|
||||
vec![
|
||||
"closed-reopen",
|
||||
"historical-done",
|
||||
"invalid-fence",
|
||||
"no-snapshot",
|
||||
"pending",
|
||||
"progress",
|
||||
"replaced"
|
||||
]
|
||||
);
|
||||
assert!(!assigned.page.has_more);
|
||||
for item in &assigned.items {
|
||||
assert_eq!(
|
||||
item.assignment_tracking,
|
||||
reopened
|
||||
.list_current_ticket_assignment_tracking("w", &item.id)
|
||||
.unwrap(),
|
||||
"{}",
|
||||
item.id
|
||||
);
|
||||
let detail = authority.ticket(&item.id).unwrap();
|
||||
assert_eq!(item.legacy_handoff, detail.legacy_handoff, "{}", item.id);
|
||||
assert_eq!(item.state, detail.state, "{}", item.id);
|
||||
}
|
||||
for (status, expected) in [
|
||||
(TicketAssignmentTrackingStatus::Waiting, vec!["pending"]),
|
||||
(TicketAssignmentTrackingStatus::Inprogress, vec!["progress"]),
|
||||
(TicketAssignmentTrackingStatus::Done, vec![]),
|
||||
] {
|
||||
let filtered = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
assigned_principal: Some(principal.clone()),
|
||||
handling_status: Some(status),
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
filtered
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
expected,
|
||||
"{status:?}"
|
||||
);
|
||||
assert!(!filtered.page.has_more, "{status:?}");
|
||||
assert!(filtered.page.next_cursor.is_none(), "{status:?}");
|
||||
}
|
||||
assert!(
|
||||
authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
assigned_principal: Some(
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "other-agent".into(),
|
||||
}
|
||||
),
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
.items
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap(),
|
||||
pending
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -28,6 +28,7 @@ use subjektiv::api::*;
|
||||
mod backend_job_profile;
|
||||
mod subjektiv_job;
|
||||
mod subjektiv_journal;
|
||||
mod subjektiv_journal_browser;
|
||||
mod subjektiv_memory_journal;
|
||||
mod worker_operations;
|
||||
mod worker_protocol;
|
||||
@@ -3133,7 +3134,11 @@ async fn authorize_scoped_workspace_request(
|
||||
));
|
||||
}
|
||||
}
|
||||
if mutating || request.headers().contains_key(axum::http::header::UPGRADE) {
|
||||
// The Journal browser uses POST only to carry bounded typed read selectors. Its
|
||||
// action set is read-only and follows the same user read authority as Memory GET.
|
||||
if (mutating && !subjektiv_journal_browser::is_read_path(&request_path, workspace_id))
|
||||
|| request.headers().contains_key(axum::http::header::UPGRADE)
|
||||
{
|
||||
let workspace = api
|
||||
.store
|
||||
.get_workspace(workspace_id)
|
||||
@@ -3309,7 +3314,8 @@ async fn authorize_workspace_api_request(
|
||||
}
|
||||
}
|
||||
if !public_server_api
|
||||
&& (mutating || request.headers().contains_key(axum::http::header::UPGRADE))
|
||||
&& ((mutating && !subjektiv_journal_browser::is_read_path(&request_path, &workspace_id))
|
||||
|| request.headers().contains_key(axum::http::header::UPGRADE))
|
||||
{
|
||||
let workspace = match api.store.get_workspace(&workspace_id).await {
|
||||
Ok(workspace) => workspace,
|
||||
@@ -13148,8 +13154,10 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
post(scoped_submit_backend_job_result),
|
||||
)
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/release", post(subjektiv_journal::release_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/compaction", post(subjektiv_journal::compaction_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/startup", get(subjektiv_journal::startup_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/recording", post(subjektiv_journal::recording_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/journal-browser", post(subjektiv_journal_browser::route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/memory", post(subjektiv_memory_journal::route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/job", post(subjektiv_journal::job_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/job/finish", post(subjektiv_journal::finish_route))
|
||||
@@ -36909,6 +36917,9 @@ mod db_execution_tests;
|
||||
mod tests {
|
||||
mod assignment_delivery_tests {
|
||||
include!("server_assignment_delivery_tests.rs");
|
||||
mod ticket_web_roundtrip_tests {
|
||||
include!("server_ticket_web_roundtrip_tests.rs");
|
||||
}
|
||||
}
|
||||
|
||||
mod assignment_start_tests {
|
||||
@@ -36916,6 +36927,10 @@ mod tests {
|
||||
}
|
||||
mod assignment_tracking_tests {
|
||||
include!("server_assignment_tracking_tests.rs");
|
||||
mod tools_backend_tests {
|
||||
use super::*;
|
||||
include!("server_ticket_tools_backend_tests.rs");
|
||||
}
|
||||
}
|
||||
|
||||
mod ticket_case_signed_tests {
|
||||
@@ -36938,6 +36953,7 @@ mod tests {
|
||||
mod embedded_owned_execution_tests;
|
||||
mod external_checkout_tests;
|
||||
mod protocol_backpressure_tests;
|
||||
mod restored_parent_control_tests;
|
||||
mod subject_spawn_tests;
|
||||
mod subjektiv_jobs_tests;
|
||||
mod subjektiv_journal_tests;
|
||||
|
||||
@@ -598,6 +598,57 @@ pub(super) async fn startup_route(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn compaction_route(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
source: Option<axum::Extension<crate::worker_source::VerifiedRuntimeRequestSource>>,
|
||||
Json(input): Json<JournalCompactionRequest>,
|
||||
) -> ApiResult<Json<journal::startup::JournalStartupSnapshot>> {
|
||||
let worker = verified_worker(source)?;
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let lease = api
|
||||
.store
|
||||
.require_current_worker_singleton_owner(&path.workspace_id, &worker)?
|
||||
.ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied(
|
||||
"Journal compaction requires the current main Subject Worker".into(),
|
||||
)
|
||||
})?;
|
||||
let (subject, role) = subjektiv_scope_from_singleton_key(&lease.key)?;
|
||||
if role != SubjektivWorkerAuthority::Subject {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Journal compaction is not a Job capability".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let store = store(&api)?;
|
||||
let attribution = store
|
||||
.session_attribution(&input.session_id)
|
||||
.map_err(|error| Error::Store(error.to_string()))?
|
||||
.filter(|attr| {
|
||||
attr.subject_id == subject
|
||||
&& attr.runtime_id == worker.runtime_id
|
||||
&& attr.worker_id == worker.worker_id
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied(
|
||||
"foreign or unattributed compaction Session".into(),
|
||||
)
|
||||
})?;
|
||||
let host =
|
||||
JournalHostContext::validated_session(&store, attribution).map_err(domain)?;
|
||||
store
|
||||
.journal_compaction_snapshot(&host)
|
||||
.map(Json)
|
||||
.map_err(domain)
|
||||
.map_err(ApiError::from)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct SubjectReleaseInput {
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! User-authenticated, read-only Web Journal API. Saved Journal and immutable Memory edges
|
||||
//! never depend on a live source Worker, original history reads, or model execution.
|
||||
use super::*;
|
||||
use subjektiv::journal::memory_links::*;
|
||||
use subjektiv::journal::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub(super) enum BrowserRequest {
|
||||
Sessions {
|
||||
after: Option<String>,
|
||||
limit: usize,
|
||||
},
|
||||
Entries {
|
||||
journal_id: Option<String>,
|
||||
query: Option<String>,
|
||||
after: Option<String>,
|
||||
limit: usize,
|
||||
},
|
||||
Entry {
|
||||
journal_id: String,
|
||||
entry_id: String,
|
||||
},
|
||||
Text {
|
||||
journal_id: String,
|
||||
entry_id: Option<String>,
|
||||
field: JournalTextField,
|
||||
expected_token: Option<String>,
|
||||
byte_offset: usize,
|
||||
max_bytes: usize,
|
||||
},
|
||||
Corrections {
|
||||
journal_id: String,
|
||||
entry_id: String,
|
||||
after: Option<String>,
|
||||
limit: usize,
|
||||
},
|
||||
MemorySources {
|
||||
selector: MemoryJournalSelector,
|
||||
source_offset: usize,
|
||||
limit: usize,
|
||||
},
|
||||
RelatedMemory {
|
||||
journal_id: String,
|
||||
entry_id: String,
|
||||
after: Option<RelatedMemoryCursor>,
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub(super) enum BrowserResponse {
|
||||
Sessions(JournalPage<BrowserSession>),
|
||||
Entries(JournalPage<JournalEntrySummary>),
|
||||
Entry(BrowserEntry),
|
||||
Text(JournalTextPage),
|
||||
Corrections(JournalPage<JournalEntrySummary>),
|
||||
MemorySources(BrowserMemorySources),
|
||||
RelatedMemory(BrowserRelatedMemories),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(super) enum SourceState {
|
||||
Missing,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct SourceStatus {
|
||||
/// `missing` requires confirmed metadata absence; `unavailable` includes unknown readability.
|
||||
pub state: SourceState,
|
||||
/// Only supplied for a retained, exact Session mapping, under this user's Workspace authority.
|
||||
/// A navigation option is not an attestation that all original bytes still exist.
|
||||
pub history_href: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserSession {
|
||||
pub journal: JournalMetadata,
|
||||
pub unreflected_sources: bool,
|
||||
pub original_source: SourceStatus,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserEntry {
|
||||
pub journal: JournalMetadata,
|
||||
pub entry: JournalEntrySummary,
|
||||
pub correction_targets: Vec<JournalEntrySummary>,
|
||||
pub original_source: SourceStatus,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserMemorySource {
|
||||
pub link: MemoryJournalSourceLink,
|
||||
pub entry: Option<JournalEntrySummary>,
|
||||
pub original_source: SourceStatus,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserMemorySources {
|
||||
pub selector: MemoryJournalSelector,
|
||||
pub sources: Vec<BrowserMemorySource>,
|
||||
pub total_sources: usize,
|
||||
pub next_source_index: Option<usize>,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserRelatedMemory {
|
||||
pub related: RelatedMemory,
|
||||
pub claim: String,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserRelatedMemories {
|
||||
pub items: Vec<BrowserRelatedMemory>,
|
||||
pub next: Option<RelatedMemoryCursor>,
|
||||
}
|
||||
|
||||
fn domain(error: JournalError) -> Error {
|
||||
match error {
|
||||
JournalError::PermissionDenied(message) => Error::WorkspacePermissionDenied(message),
|
||||
JournalError::Conflict(message) => Error::RepositoryConflict(message),
|
||||
JournalError::InvalidInput(message) => Error::InvalidInput(message),
|
||||
JournalError::NotFound(id) => {
|
||||
Error::InvalidInput(format!("Journal record not found: {id}"))
|
||||
}
|
||||
JournalError::Subject(error) => subjektiv_store_error(error),
|
||||
other => Error::Store(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_status(
|
||||
api: &WorkspaceApi,
|
||||
store: &crate::subjektiv::SubjektivStore,
|
||||
subject_id: &str,
|
||||
session: Option<&str>,
|
||||
) -> SourceStatus {
|
||||
let unavailable = || SourceStatus {
|
||||
state: SourceState::Unavailable,
|
||||
history_href: None,
|
||||
};
|
||||
let Some(session) = session else {
|
||||
return unavailable();
|
||||
};
|
||||
let Ok(Some(attr)) = store.session_attribution(session) else {
|
||||
return unavailable();
|
||||
};
|
||||
if attr.subject_id != subject_id {
|
||||
return unavailable();
|
||||
}
|
||||
let worker = RuntimeWorkerRef::new(&attr.runtime_id, &attr.worker_id);
|
||||
let Ok(archive) =
|
||||
api.config_store
|
||||
.worker_session_archive(&api.config.workspace_id, &worker, session)
|
||||
else {
|
||||
return unavailable();
|
||||
};
|
||||
let Ok(registry) = api
|
||||
.store
|
||||
.get_worker_registry(&api.config.workspace_id, &worker)
|
||||
else {
|
||||
return unavailable();
|
||||
};
|
||||
if archive.is_none() && registry.is_none() {
|
||||
return SourceStatus {
|
||||
state: SourceState::Missing,
|
||||
history_href: None,
|
||||
};
|
||||
}
|
||||
// The console addresses the current Session only. Never point an old attributed
|
||||
// Session at a different current conversation, or restore a Worker to manufacture a link.
|
||||
let exact = registry
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.session_ref.as_deref() == Some(session));
|
||||
let history_href = if exact {
|
||||
api.store
|
||||
.worker_resource_key(&api.config.workspace_id, &worker)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|key| browser_worker_console_href(&api.config.workspace_id, &key))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
SourceStatus {
|
||||
state: SourceState::Unavailable,
|
||||
history_href,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact route recognition for the normal user read-authority check. Do not include
|
||||
/// any action that writes or reconciles state in this endpoint's tagged request type.
|
||||
pub(super) fn is_read_path(path: &str, workspace: &str) -> bool {
|
||||
path.strip_prefix(&format!("/api/w/{workspace}/subjektiv/subjects/"))
|
||||
.and_then(|tail| tail.strip_suffix("/journal-browser"))
|
||||
.is_some_and(|subject| !subject.is_empty() && !subject.contains('/'))
|
||||
}
|
||||
|
||||
pub(super) async fn route(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||
actor: Option<Extension<RequestActor>>,
|
||||
Json(request): Json<BrowserRequest>,
|
||||
) -> ApiResult<Json<BrowserResponse>> {
|
||||
// Runtime signatures, singleton leases and Job grants are not user browser authority.
|
||||
if actor.is_none() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Journal browser requires user authentication".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let store = open_subjektiv_store(&api)?;
|
||||
require_subjektiv_subject(&store, &path.subject_id)?;
|
||||
let host =
|
||||
JournalHostContext::validated_reader(&store, &path.subject_id).map_err(domain)?;
|
||||
let response =
|
||||
execute(&api, &store, &host, &path.subject_id, request).map_err(domain)?;
|
||||
if serde_json::to_vec(&response)
|
||||
.map_err(|e| Error::Store(e.to_string()))?
|
||||
.len()
|
||||
> MAX_JOURNAL_RESPONSE_BYTES
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"Journal browser response exceeds byte budget; request a smaller page".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(Json(response))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn execute(
|
||||
api: &WorkspaceApi,
|
||||
store: &crate::subjektiv::SubjektivStore,
|
||||
host: &JournalHostContext,
|
||||
subject_id: &str,
|
||||
request: BrowserRequest,
|
||||
) -> JournalResult<BrowserResponse> {
|
||||
Ok(match request {
|
||||
BrowserRequest::Sessions { after, limit } => {
|
||||
if after.as_ref().is_some_and(|s| s.len() > 256) {
|
||||
return Err(JournalError::InvalidInput(
|
||||
"Journal cursor exceeds byte limit".into(),
|
||||
));
|
||||
}
|
||||
let page = store.list_journals(host, after.as_deref(), limit)?;
|
||||
let mut items = Vec::new();
|
||||
for journal in page.items {
|
||||
let unreflected_sources =
|
||||
store.journal_has_unreflected_sources(host, &journal.id)?;
|
||||
let original_source =
|
||||
source_status(api, store, subject_id, Some(&journal.source_session_id));
|
||||
items.push(BrowserSession {
|
||||
journal,
|
||||
unreflected_sources,
|
||||
original_source,
|
||||
});
|
||||
}
|
||||
BrowserResponse::Sessions(JournalPage {
|
||||
items,
|
||||
next: page.next,
|
||||
})
|
||||
}
|
||||
BrowserRequest::Entries {
|
||||
journal_id,
|
||||
query,
|
||||
after,
|
||||
limit,
|
||||
} => BrowserResponse::Entries(store.browse_journal_entries(
|
||||
host,
|
||||
journal_id.as_deref(),
|
||||
query.as_deref(),
|
||||
None,
|
||||
after.as_deref(),
|
||||
limit,
|
||||
)?),
|
||||
BrowserRequest::Entry {
|
||||
journal_id,
|
||||
entry_id,
|
||||
} => {
|
||||
let journal = store.read_journal(host, &journal_id)?;
|
||||
let entry = store.journal_entry_summary(host, &journal_id, &entry_id)?;
|
||||
let original_source =
|
||||
source_status(api, store, subject_id, Some(&journal.source_session_id));
|
||||
let correction_targets =
|
||||
store.journal_correction_targets(host, &journal_id, &entry_id)?;
|
||||
BrowserResponse::Entry(BrowserEntry {
|
||||
journal,
|
||||
entry,
|
||||
correction_targets,
|
||||
original_source,
|
||||
})
|
||||
}
|
||||
BrowserRequest::Text {
|
||||
journal_id,
|
||||
entry_id,
|
||||
field,
|
||||
expected_token,
|
||||
byte_offset,
|
||||
max_bytes,
|
||||
} => BrowserResponse::Text(store.read_journal_text(
|
||||
host,
|
||||
&journal_id,
|
||||
entry_id.as_deref(),
|
||||
field,
|
||||
expected_token.as_deref(),
|
||||
byte_offset,
|
||||
max_bytes,
|
||||
)?),
|
||||
BrowserRequest::Corrections {
|
||||
journal_id,
|
||||
entry_id,
|
||||
after,
|
||||
limit,
|
||||
} => BrowserResponse::Corrections(store.browse_journal_entries(
|
||||
host,
|
||||
Some(&journal_id),
|
||||
None,
|
||||
Some(&entry_id),
|
||||
after.as_deref(),
|
||||
limit,
|
||||
)?),
|
||||
BrowserRequest::MemorySources {
|
||||
selector,
|
||||
source_offset,
|
||||
limit,
|
||||
} => {
|
||||
if !(1..=MAX_JOURNAL_PAGE).contains(&limit) {
|
||||
return Err(JournalError::InvalidInput(
|
||||
"page limit must be 1..32".into(),
|
||||
));
|
||||
}
|
||||
let response = store.execute_memory_journal(
|
||||
host,
|
||||
MemoryJournalRequest::LinksPage {
|
||||
selector: selector.clone(),
|
||||
source_offset,
|
||||
limit: limit.min(crate::subjektiv::MAX_STAGING_ANCHORS),
|
||||
},
|
||||
)?;
|
||||
let MemoryJournalResponse::Links(page) = response else {
|
||||
unreachable!()
|
||||
};
|
||||
let candidate = store.staging_candidate(subject_id, &selector.candidate_id)?;
|
||||
let mut sources = Vec::new();
|
||||
for mut link in page.sources {
|
||||
let source_session = candidate
|
||||
.as_ref()
|
||||
.and_then(|c| c.source_refs.get(link.source_index))
|
||||
.and_then(|s| s.session_id.as_deref());
|
||||
let mut original_source = source_status(api, store, subject_id, source_session);
|
||||
let entry = if let SourceConnection::Linked {
|
||||
journal_id,
|
||||
entry_id,
|
||||
target: JournalTargetAvailability::Available,
|
||||
..
|
||||
} = &link.connection
|
||||
{
|
||||
let journal = store.read_journal(host, journal_id)?;
|
||||
original_source =
|
||||
source_status(api, store, subject_id, Some(&journal.source_session_id));
|
||||
Some(store.journal_entry_summary(host, journal_id, entry_id)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if link.original_source == OriginalSourceAvailability::Missing {
|
||||
// A saved missing-byte attestation does not prove deletion of the whole
|
||||
// original conversation. Keep only independently confirmed metadata absence
|
||||
// as `missing`; otherwise expose unavailable material without a history link.
|
||||
if original_source.state != SourceState::Missing {
|
||||
original_source.state = SourceState::Unavailable;
|
||||
}
|
||||
original_source.history_href = None;
|
||||
}
|
||||
if original_source.state == SourceState::Missing {
|
||||
link.original_source = OriginalSourceAvailability::Missing;
|
||||
}
|
||||
sources.push(BrowserMemorySource {
|
||||
link,
|
||||
entry,
|
||||
original_source,
|
||||
});
|
||||
}
|
||||
BrowserResponse::MemorySources(BrowserMemorySources {
|
||||
selector,
|
||||
sources,
|
||||
total_sources: page.total_sources,
|
||||
next_source_index: page.next_source_index,
|
||||
})
|
||||
}
|
||||
BrowserRequest::RelatedMemory {
|
||||
journal_id,
|
||||
entry_id,
|
||||
after,
|
||||
limit,
|
||||
} => {
|
||||
let response = store.execute_memory_journal(
|
||||
host,
|
||||
MemoryJournalRequest::Related {
|
||||
journal_id,
|
||||
entry_id,
|
||||
after,
|
||||
limit,
|
||||
},
|
||||
)?;
|
||||
let MemoryJournalResponse::Related(page) = response else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
for related in page.items {
|
||||
let memory = store
|
||||
.memory_change(
|
||||
subject_id,
|
||||
&related.selector.memory_id,
|
||||
related.selector.change_id.clone(),
|
||||
)?
|
||||
.ok_or_else(|| JournalError::NotFound("related Memory change".into()))?;
|
||||
items.push(BrowserRelatedMemory {
|
||||
related,
|
||||
claim: bounded_memory_excerpt(&memory.claim, 256),
|
||||
});
|
||||
}
|
||||
BrowserResponse::RelatedMemory(BrowserRelatedMemories {
|
||||
items,
|
||||
next: page.next,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Restored Runtime parent client -> installed public Worker tools -> signed loopback
|
||||
//! HTTP -> real SQLite grants -> live Runtime child -> canonical Session storage.
|
||||
//! Only the external model is fixed. P is restored without submitting a model turn;
|
||||
//! C stays live (this does not test child restore). Run with the normal Server lib target.
|
||||
use super::*;
|
||||
use agen::tool::{Tool, ToolExecutionContext, ToolOutput};
|
||||
use futures::FutureExt;
|
||||
use std::{panic::AssertUnwindSafe, sync::Mutex, time::Duration};
|
||||
use worker_runtime::worker_backend::{
|
||||
ProfileRuntimeWorkerFactory, RuntimeWorkerController, RuntimeWorkerFactory,
|
||||
WorkerRuntimeExecutionBackend,
|
||||
};
|
||||
|
||||
const WATCHDOG: Duration = Duration::from_secs(45);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FixedModel;
|
||||
#[async_trait::async_trait]
|
||||
impl agen::llm_client::LlmClient for FixedModel {
|
||||
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(
|
||||
&self,
|
||||
_: agen::llm_client::Request,
|
||||
) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError>
|
||||
{
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Captures {
|
||||
handles: Mutex<Vec<(worker_runtime::identity::WorkerRef, worker::WorkerHandle)>>,
|
||||
restored_clients: Mutex<
|
||||
Vec<(
|
||||
worker_runtime::identity::WorkerRef,
|
||||
Arc<dyn worker::WorkspaceClient>,
|
||||
)>,
|
||||
>,
|
||||
}
|
||||
|
||||
// Keep the production factory/restore/signers. The archive chooses an unauthenticated
|
||||
// model so restoring an idle parent never depends on developer credentials. The spawn
|
||||
// hook fixes only C's external model boundary; it is deliberately not a restore hook.
|
||||
struct CapturingFactory {
|
||||
inner: ProfileRuntimeWorkerFactory,
|
||||
captures: Arc<Captures>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl RuntimeWorkerFactory for CapturingFactory {
|
||||
async fn spawn_controller(
|
||||
&self,
|
||||
mut request: worker_runtime::execution::WorkerExecutionSpawnRequest,
|
||||
) -> std::result::Result<RuntimeWorkerController, String> {
|
||||
let archive = worker_runtime::profile_archive::ProfileSourceArchive::build(
|
||||
worker_runtime::profile_archive::ProfileSourceArchiveInput {
|
||||
id: "profile-source-archive:restored-parent".into(),
|
||||
entrypoints: BTreeMap::from([("builtin:companion".into(), "profiles/default.dcdl".into())]),
|
||||
imports: BTreeMap::new(),
|
||||
sources: BTreeMap::from([("profiles/default.dcdl".into(), r#"{
|
||||
slug = "default"; description = "Restored parent connection"; scope = "workspace_read";
|
||||
model = { scheme = "anthropic"; model_id = "test-model"; auth = { kind = "none"; }; };
|
||||
engine = { max_tokens = 100; };
|
||||
}"#.into())]),
|
||||
},
|
||||
).map_err(|e| e.to_string())?;
|
||||
request.request.profile_source =
|
||||
worker_runtime::catalog::ProfileSourceArchiveSource::Embedded { archive };
|
||||
let worker_ref = request.worker_ref.clone();
|
||||
let controller = self.inner.spawn_controller(request).await?;
|
||||
self.captures
|
||||
.handles
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((worker_ref, controller.handle.clone()));
|
||||
Ok(controller)
|
||||
}
|
||||
async fn preflight_restore(
|
||||
&self,
|
||||
request: &worker_runtime::execution::WorkerExecutionRestoreRequest,
|
||||
) -> std::result::Result<(), String> {
|
||||
self.inner.preflight_restore(request).await
|
||||
}
|
||||
async fn restore_controller(
|
||||
&self,
|
||||
request: worker_runtime::execution::WorkerExecutionRestoreRequest,
|
||||
) -> std::result::Result<RuntimeWorkerController, String> {
|
||||
let worker_ref = request.worker_ref.clone();
|
||||
let controller = self.inner.restore_controller(request).await?;
|
||||
self.captures
|
||||
.handles
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((worker_ref.clone(), controller.handle.clone()));
|
||||
self.captures
|
||||
.restored_clients
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((worker_ref, controller.workspace_client.clone()));
|
||||
Ok(controller)
|
||||
}
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
execution: Arc<WorkerRuntimeExecutionBackend<CapturingFactory>>,
|
||||
captures: Arc<Captures>,
|
||||
servers: Vec<tokio::task::JoinHandle<()>>,
|
||||
api: Option<WorkspaceApi>,
|
||||
}
|
||||
impl Resources {
|
||||
async fn close(&mut self) {
|
||||
for server in self.servers.drain(..) {
|
||||
server.abort();
|
||||
assert!(server.await.unwrap_err().is_cancelled());
|
||||
}
|
||||
let execution = self.execution.clone();
|
||||
let workers = self
|
||||
.captures
|
||||
.handles
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
// stop_worker waits for shutdown completion and joins all execution-owned tasks.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use worker_runtime::execution::WorkerExecutionBackend;
|
||||
for worker in workers {
|
||||
let result = execution.stop_worker(&worker);
|
||||
assert!(result.is_accepted(), "cleanup {worker:?}: {result:?}");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(api) = self.api.take() {
|
||||
shutdown_workspace_feature_storage(&api).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_pair_member(api: &WorkspaceApi, name: &str) -> RuntimeWorkerRef {
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_RUNTIME_ID.into(),
|
||||
display_name: name.into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:companion".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
RuntimeWorkerRef::new(created.runtime_id, created.worker_id)
|
||||
}
|
||||
|
||||
fn handle(captures: &Captures, worker: &RuntimeWorkerRef) -> worker::WorkerHandle {
|
||||
captures
|
||||
.handles
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(id, _)| id.worker_id.to_string() == worker.worker_id)
|
||||
.unwrap()
|
||||
.1
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn finished_submission(events: &mut tokio::sync::broadcast::Receiver<protocol::Event>) {
|
||||
let mut accepted_id = None;
|
||||
let mut finished = false;
|
||||
loop {
|
||||
match events
|
||||
.recv()
|
||||
.await
|
||||
.expect("controller event stream closed before Submit completion")
|
||||
{
|
||||
protocol::Event::SubmissionAccepted {
|
||||
submission_request_id,
|
||||
..
|
||||
} => {
|
||||
assert!(accepted_id.is_none(), "unexpected additional Submit");
|
||||
accepted_id = Some(submission_request_id);
|
||||
}
|
||||
protocol::Event::SubmissionRejected { message, .. } => {
|
||||
panic!("Submit rejected: {message}")
|
||||
}
|
||||
protocol::Event::RunEnd { result } => {
|
||||
assert!(accepted_id.is_some());
|
||||
assert_eq!(result, protocol::RunResult::Finished);
|
||||
finished = true;
|
||||
}
|
||||
protocol::Event::WorkerState { snapshot }
|
||||
if finished
|
||||
&& snapshot.state == protocol::WorkerState::Idle
|
||||
&& snapshot.last_finished_submission_request_id == accepted_id =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public Feature installation needs a Worker tool host. This non-controller host
|
||||
// uses the *restored controller's* client unchanged, not a fabricated parent signer.
|
||||
async fn tools_for_restored_client(
|
||||
root: &Path,
|
||||
client: Arc<dyn worker::WorkspaceClient>,
|
||||
) -> BTreeMap<String, Arc<dyn Tool>> {
|
||||
let manifest = worker::WorkerManifest::from_toml(
|
||||
"[worker]\nname=\"control-tools\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n",
|
||||
).unwrap();
|
||||
let engine =
|
||||
agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||
FixedModel,
|
||||
);
|
||||
let mut host = worker::Worker::new(
|
||||
manifest,
|
||||
engine,
|
||||
session_store::FsStore::new(root.join("tool-host")).unwrap(),
|
||||
worker::WorkerWorkspaceContext::with_client(
|
||||
Some(worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap()),
|
||||
client.clone(),
|
||||
),
|
||||
worker::WorkerFilesystemAuthority::None,
|
||||
manifest::Scope::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let report = host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module(
|
||||
worker::feature::builtin::manage_worker::manage_worker_feature(client, None, false),
|
||||
));
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
let server = host.engine().tool_server_handle();
|
||||
server.flush_pending();
|
||||
["WorkerList", "WorkerSendInput"]
|
||||
.into_iter()
|
||||
.map(|name| (name.into(), server.get_tool(name).unwrap().1))
|
||||
.collect()
|
||||
}
|
||||
async fn call(tools: &BTreeMap<String, Arc<dyn Tool>>, name: &str, args: Value) -> ToolOutput {
|
||||
tools[name]
|
||||
.execute(
|
||||
&args.to_string(),
|
||||
ToolExecutionContext::new(uuid::Uuid::now_v7().to_string(), "restored-parent", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{name} from restored parent failed: {error}"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn restored_runtime_parent_lists_and_inputs_existing_child() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let mut config = test_server_config(root.path());
|
||||
config.repositories.clear();
|
||||
config.backend_base_url = Some(base.clone());
|
||||
let factory = ProfileRuntimeWorkerFactory::new(root.path())
|
||||
.with_runtime_store_dir(config.embedded_runtime_store_root.clone())
|
||||
.with_remote_worker_mutation_identity(EMBEDDED_RUNTIME_REQUEST_IDENTITY.clone())
|
||||
.with_workspace_request_client(
|
||||
worker_runtime::workspace_request::RuntimeWorkspaceRequestClient::new(
|
||||
TEST_WORKSPACE_ID,
|
||||
&base,
|
||||
EMBEDDED_RUNTIME_ID,
|
||||
)
|
||||
.with_runtime_request_source(&EMBEDDED_RUNTIME_REQUEST_IDENTITY, &base),
|
||||
)
|
||||
.with_spawn_model_client_factory(|| Box::new(FixedModel));
|
||||
let captures = Arc::new(Captures::default());
|
||||
let execution = Arc::new(
|
||||
WorkerRuntimeExecutionBackend::new(CapturingFactory {
|
||||
inner: factory.clone(),
|
||||
captures: captures.clone(),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
let mut resources = Resources {
|
||||
execution,
|
||||
captures: captures.clone(),
|
||||
servers: vec![],
|
||||
api: None,
|
||||
};
|
||||
let exercise = async {
|
||||
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap());
|
||||
let old_store = Arc::downgrade(&store);
|
||||
let mut api = WorkspaceApi::new_with_execution_backend(
|
||||
config.clone(),
|
||||
store.clone(),
|
||||
resources.execution.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
configure_runtime_request_auth(
|
||||
&mut api,
|
||||
&EMBEDDED_RUNTIME_REQUEST_IDENTITY,
|
||||
EMBEDDED_RUNTIME_ID,
|
||||
);
|
||||
api.config.backend_base_url = Some(base.clone());
|
||||
let sources = api.config.remote_runtime_sources.clone();
|
||||
resources.api = Some(api.clone());
|
||||
let server_api = api.clone();
|
||||
resources.servers.push(tokio::spawn(async move {
|
||||
axum::serve(listener, build_router(server_api))
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
let parent = spawn_pair_member(&api, "Parent").await;
|
||||
let child = spawn_pair_member(&api, "Child").await;
|
||||
let child_handle = handle(&captures, &child);
|
||||
assert_eq!(
|
||||
child_handle.shared_state.catalog_status(),
|
||||
protocol::WorkerStatus::Idle
|
||||
);
|
||||
// Materialize P's Session/manifest before stopping, through a real Submit.
|
||||
let mut parent_events = handle(&captures, &parent).subscribe();
|
||||
let accepted = api
|
||||
.runtime
|
||||
.send_input(
|
||||
&parent,
|
||||
WorkerInputRequest {
|
||||
kind: WorkerInputKind::UserIfIdle,
|
||||
content: "parent bootstrap".into(),
|
||||
submission_request_id: None,
|
||||
segments: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(accepted.disposition, WorkerInputDisposition::Accepted);
|
||||
finished_submission(&mut parent_events).await;
|
||||
let grant = WorkerControlGrantRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
grant_id: "restored-parent-child".into(),
|
||||
controller: parent.clone(),
|
||||
subject: child.clone(),
|
||||
relation: "spawned".into(),
|
||||
origin: "test".into(),
|
||||
permissions: vec!["send_input".into(), "observe".into()],
|
||||
operation_id: "restored-parent-child".into(),
|
||||
created_at: now_registry_timestamp(),
|
||||
revoked_at: None,
|
||||
};
|
||||
api.store.create_worker_control_grant(&grant).unwrap();
|
||||
let grants_before = api
|
||||
.store
|
||||
.list_active_worker_control_grants(TEST_WORKSPACE_ID, &parent, 10)
|
||||
.unwrap();
|
||||
let stopped = api
|
||||
.runtime
|
||||
.stop_worker(
|
||||
&parent,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some("restart parent".into()),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stopped.state, InternalWorkerOperationState::Accepted);
|
||||
assert!(
|
||||
handle(&captures, &parent).protocol_is_closed(),
|
||||
"stop must join the old parent controller"
|
||||
);
|
||||
let server = resources.servers.pop().unwrap();
|
||||
server.abort();
|
||||
assert!(server.await.unwrap_err().is_cancelled());
|
||||
shutdown_workspace_feature_storage(&api).await.unwrap();
|
||||
resources.api.take();
|
||||
drop(api);
|
||||
drop(store);
|
||||
assert!(
|
||||
old_store.upgrade().is_none(),
|
||||
"old API/store must be released before reopen"
|
||||
);
|
||||
|
||||
// Same DB and Runtime FS. No post-restart registry or grant seed.
|
||||
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap());
|
||||
let mut api = WorkspaceApi::new_with_execution_backend(
|
||||
config.clone(),
|
||||
store,
|
||||
resources.execution.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
api.config.remote_runtime_sources = sources;
|
||||
resources.api = Some(api.clone());
|
||||
let listener = tokio::net::TcpListener::bind(base.strip_prefix("http://").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let server_api = api.clone();
|
||||
resources.servers.push(tokio::spawn(async move {
|
||||
axum::serve(listener, build_router(server_api))
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
let observation = api.runtime.worker(&parent).unwrap();
|
||||
let restored = api
|
||||
.runtime
|
||||
.restore_worker(
|
||||
&parent,
|
||||
runtime_api::WorkerRestoreRequest {
|
||||
expected_observation_token: observation.restore_observation_token.unwrap(),
|
||||
request_id: "restore-parent".into(),
|
||||
preparation: Some(runtime_api::WorkerRestorePreparation::default()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.state,
|
||||
server_api::WorkerRestoreState::Accepted,
|
||||
"{restored:?}"
|
||||
);
|
||||
let restored_clients = captures.restored_clients.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
restored_clients.len(),
|
||||
1,
|
||||
"only P is restored; C remains the original live controller"
|
||||
);
|
||||
assert_eq!(
|
||||
restored_clients[0].0.worker_id.to_string(),
|
||||
parent.worker_id
|
||||
);
|
||||
let client = restored_clients[0].1.clone();
|
||||
assert_eq!(client.kind(), "runtime-owned-workspace-client");
|
||||
let tools = tools_for_restored_client(root.path(), client).await;
|
||||
let output = call(&tools, "WorkerList", json!({})).await;
|
||||
let list: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
let expected_subject = json!({"kind":"runtime_worker", "runtime_id": child.runtime_id, "worker_id": child.worker_id});
|
||||
assert_eq!(list["items"].as_array().unwrap().len(), 1, "{list}");
|
||||
assert_eq!(
|
||||
list["items"][0]["subject"], expected_subject,
|
||||
"restored parent's list must expose the complete child subject"
|
||||
);
|
||||
assert_eq!(list["items"][0]["permissions"], json!(grant.permissions));
|
||||
assert_eq!(list["items"][0]["summary"]["runtime_id"], child.runtime_id);
|
||||
assert_eq!(list["items"][0]["summary"]["worker_id"], child.worker_id);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_active_worker_control_grants(TEST_WORKSPACE_ID, &parent, 10)
|
||||
.unwrap(),
|
||||
grants_before
|
||||
);
|
||||
let subject = list["items"][0]["subject"].clone();
|
||||
let marker = format!("restored-parent-marker-{}", uuid::Uuid::now_v7());
|
||||
let mut child_events = child_handle.subscribe();
|
||||
let output = call(
|
||||
&tools,
|
||||
"WorkerSendInput",
|
||||
json!({"subject":subject,"content":marker}),
|
||||
)
|
||||
.await;
|
||||
let input: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(input["state"], "accepted", "{input}");
|
||||
finished_submission(&mut child_events).await;
|
||||
let child_ref = worker_runtime::identity::WorkerRef::try_from(&child).unwrap();
|
||||
let before = factory.retained_session_snapshot(&child_ref).unwrap();
|
||||
let bytes = serde_json::to_value(&before.snapshot).unwrap();
|
||||
assert_eq!(
|
||||
before
|
||||
.snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| matches!(
|
||||
&entry.data,
|
||||
protocol::SessionSnapshotEntryData::UserInput { segments }
|
||||
if segments == &vec![protocol::Segment::text(marker.clone())]
|
||||
))
|
||||
.count(),
|
||||
1,
|
||||
"canonical Session must contain the marker exactly once"
|
||||
);
|
||||
api.store
|
||||
.revoke_worker_control_grant(
|
||||
TEST_WORKSPACE_ID,
|
||||
&grant.grant_id,
|
||||
&now_registry_timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
let refused = tools["WorkerSendInput"]
|
||||
.execute(
|
||||
&json!({"subject": subject, "content": marker}).to_string(),
|
||||
ToolExecutionContext::new("revoked-input", "restored-parent", 1),
|
||||
)
|
||||
.await;
|
||||
let error = refused.expect_err("revoked grant must reject the next public input");
|
||||
assert!(error.to_string().contains("unknown worker"), "{error}");
|
||||
let after = factory.retained_session_snapshot(&child_ref).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&after.snapshot).unwrap(),
|
||||
bytes,
|
||||
"rejected input must not change C's canonical Session"
|
||||
);
|
||||
};
|
||||
let outcome = AssertUnwindSafe(tokio::time::timeout(WATCHDOG, exercise))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
tokio::time::timeout(WATCHDOG, resources.close())
|
||||
.await
|
||||
.expect("fixture shutdown/join timed out");
|
||||
match outcome {
|
||||
Ok(result) => result.expect("restored parent connection watchdog expired"),
|
||||
Err(panic) => std::panic::resume_unwind(panic),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
//! Real user-authenticated production router tests, sharing the Memory/Journal provenance fixture.
|
||||
//! There is deliberately no Runtime adapter: reads must use saved state, not a source Worker.
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn subject(f: &Fixture) -> String {
|
||||
f.store
|
||||
.read_journal(&f.host, &f.journal_id)
|
||||
.unwrap()
|
||||
.subject_id
|
||||
}
|
||||
fn browser_path(subject: &str) -> String {
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/journal-browser")
|
||||
}
|
||||
fn browser_request(subject: &str, token: Option<&str>, body: serde_json::Value) -> Request<Body> {
|
||||
let mut request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(browser_path(subject))
|
||||
.header(CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
request = request.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
request
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap()
|
||||
}
|
||||
async fn browser_call(f: &Fixture, token: &str, body: serde_json::Value) -> serde_json::Value {
|
||||
let (status, bytes) = response(
|
||||
build_router(f.api.clone()),
|
||||
browser_request(&subject(f), Some(token), body),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
assert!(bytes.len() <= MAX_JOURNAL_RESPONSE_BYTES);
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
fn detail(f: &Fixture) -> serde_json::Value {
|
||||
json!({"action":"entry","journal_id":f.journal_id,"entry_id":f.entry_id})
|
||||
}
|
||||
fn text(f: &Fixture, field: &str) -> serde_json::Value {
|
||||
json!({"action":"text","journal_id":f.journal_id,
|
||||
"entry_id": if field == "current_position" { None } else { Some(&f.entry_id) },
|
||||
"field":field,"expected_token":null,"byte_offset":0,"max_bytes":8192})
|
||||
}
|
||||
|
||||
/// Adds another real saved Session/Journal, optionally correcting an entry in the first Session.
|
||||
fn later_journal(f: &Fixture, correcting: Vec<String>) -> (String, String) {
|
||||
let session = Uuid::new_v4().to_string();
|
||||
let attr = f
|
||||
.store
|
||||
.record_session_attribution(
|
||||
crate::subjektiv::SubjectSessionAttribution::new(
|
||||
subject(f),
|
||||
RUNTIME,
|
||||
&f.source_worker.worker_id,
|
||||
&session,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let writer = f
|
||||
.store
|
||||
.claim_journal_writer(&subject(f), RUNTIME, &f.source_worker.worker_id)
|
||||
.unwrap();
|
||||
let host = f.store.bind_journal_session(&writer, attr).unwrap();
|
||||
let content = "A later Session revisits the constraint.";
|
||||
let range = JournalSourceRange {
|
||||
source: JournalSource {
|
||||
session_id: session,
|
||||
pointer: JournalSourcePointer {
|
||||
segment_id: Uuid::new_v4(),
|
||||
entry_ref: "E0".into(),
|
||||
},
|
||||
order: 0,
|
||||
content_sha256: Sha256::digest(content.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect(),
|
||||
total_bytes: content.len() as u64,
|
||||
},
|
||||
bytes: JournalByteRange {
|
||||
start: 0,
|
||||
end: content.len() as u64,
|
||||
},
|
||||
};
|
||||
let id = f
|
||||
.store
|
||||
.accept_journal_sources(&host, "Later Session", &[range.clone()])
|
||||
.unwrap();
|
||||
let binding = subjektiv::api::JobAttemptBinding {
|
||||
job_id: "later-fixture".into(),
|
||||
attempt_id: "one".into(),
|
||||
};
|
||||
let generation = f
|
||||
.store
|
||||
.prepare_journal_generation(&host, &id, binding.clone(), vec![range.clone()])
|
||||
.unwrap();
|
||||
let job = JournalJobContext::validated(&f.store, &host, &generation.id, binding).unwrap();
|
||||
subjektiv::journal::service::execute_bounded(
|
||||
&f.store,
|
||||
&job,
|
||||
JournalJobRequest::ReadText {
|
||||
read: JournalTextRead {
|
||||
target: JournalTextTarget::Source {
|
||||
pointer: range.source.pointer.clone(),
|
||||
},
|
||||
offset: 0,
|
||||
limit: 8192,
|
||||
content_token: None,
|
||||
},
|
||||
},
|
||||
|_, _, _| {
|
||||
Ok(subjektiv::journal::recording::JournalMaterial {
|
||||
range: range.clone(),
|
||||
text: content.into(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let entry = f
|
||||
.store
|
||||
.execute_journal(
|
||||
&job,
|
||||
JournalOperation {
|
||||
request_id: "later-entry".into(),
|
||||
expected_state: generation.initial_journal.state_token,
|
||||
action: JournalAction::Create {
|
||||
title: "Later qualification".into(),
|
||||
body: content.into(),
|
||||
ongoing: String::new(),
|
||||
state: JournalEntryState::Fixed,
|
||||
source_end: Some(range.source.pointer.clone()),
|
||||
correction_refs: correcting,
|
||||
},
|
||||
covered_ranges: vec![range],
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
(id, entry)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_user_auth_subject_boundary_and_read_only_actions() {
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "journal-browser-user").await;
|
||||
let app = build_router(f.api.clone());
|
||||
assert_eq!(
|
||||
response(app.clone(), browser_request(&subject(&f), None, detail(&f)))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let signed = runtime_source_request(
|
||||
&f.identity,
|
||||
Some(&f.worker.worker_id),
|
||||
"POST",
|
||||
&browser_path(&subject(&f)),
|
||||
serde_json::to_vec(&detail(&f)).unwrap(),
|
||||
);
|
||||
assert_eq!(response(app.clone(), signed).await.0, StatusCode::FORBIDDEN);
|
||||
let other = f
|
||||
.store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("other-browser").unwrap())
|
||||
.unwrap();
|
||||
for body in [
|
||||
detail(&f),
|
||||
text(&f, "body"),
|
||||
json!({"action":"entries","journal_id":f.journal_id,"query":null,"after":null,"limit":10}),
|
||||
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":10}),
|
||||
json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":10}),
|
||||
] {
|
||||
let (status, bytes) =
|
||||
response(app.clone(), browser_request(&other.id, Some(&token), body)).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains(JOURNAL_BODY));
|
||||
}
|
||||
let empty = response(
|
||||
app.clone(),
|
||||
browser_request(
|
||||
&other.id,
|
||||
Some(&token),
|
||||
json!({"action":"sessions","after":null,"limit":10}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(empty.0, StatusCode::OK);
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&empty.1).unwrap()["value"]["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let unknown_action = browser_request(
|
||||
&subject(&f),
|
||||
Some(&token),
|
||||
json!({"action":"reconcile","selector":f.selector}),
|
||||
);
|
||||
assert_eq!(
|
||||
response(app, unknown_action).await.0,
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
let entry = browser_call(&f, &token, detail(&f)).await;
|
||||
assert_eq!(entry["value"]["entry"]["state"], "open");
|
||||
assert_eq!(entry["value"]["entry"]["title"], "Choosing the approach");
|
||||
assert_eq!(entry["value"]["original_source"]["state"], "unavailable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_multiple_sessions_recent_search_and_filter_bound_paging() {
|
||||
let f = Fixture::new().await;
|
||||
f.fix_initial();
|
||||
let (later, later_entry) = later_journal(&f, vec![]);
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-pages").await;
|
||||
let first = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"sessions","after":null,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first["value"]["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(first["value"]["items"][0]["unreflected_sources"], true);
|
||||
let second = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"sessions","after":first["value"]["next"],"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second["value"]["items"].as_array().unwrap().len(), 1);
|
||||
assert_ne!(
|
||||
first["value"]["items"][0]["journal"]["id"],
|
||||
second["value"]["items"][0]["journal"]["id"]
|
||||
);
|
||||
assert!(second["value"]["next"].is_null());
|
||||
let first = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first["value"]["items"][0]["id"], later_entry);
|
||||
let second = browser_call(&f, &token, json!({"action":"entries","journal_id":null,"query":null,"after":first["value"]["next"],"limit":1})).await;
|
||||
assert_eq!(second["value"]["items"][0]["id"], f.entry_id);
|
||||
assert!(second["value"]["next"].is_null());
|
||||
let filtered = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"entries","journal_id":later,"query":"REVISITS","after":null,"limit":32}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(filtered["value"]["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(filtered["value"]["items"][0]["id"], later_entry);
|
||||
for query in ["no such event", "%"] {
|
||||
let empty = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"entries","journal_id":null,"query":query,"after":null,"limit":32}),
|
||||
)
|
||||
.await;
|
||||
assert!(empty["value"]["items"].as_array().unwrap().is_empty());
|
||||
}
|
||||
let changed_filter = browser_request(
|
||||
&subject(&f),
|
||||
Some(&token),
|
||||
json!({"action":"entries","journal_id":later,
|
||||
"query":null,"after":first["value"]["next"],"limit":1}),
|
||||
);
|
||||
assert_eq!(
|
||||
response(build_router(f.api.clone()), changed_filter)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
let other = f
|
||||
.store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("cursor-other").unwrap())
|
||||
.unwrap();
|
||||
let cross_subject = browser_request(
|
||||
&other.id,
|
||||
Some(&token),
|
||||
json!({"action":"entries","journal_id":null,
|
||||
"query":null,"after":first["value"]["next"],"limit":1}),
|
||||
);
|
||||
assert_eq!(
|
||||
response(build_router(f.api.clone()), cross_subject).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_text_bounds_tokens_position_and_ongoing_are_independent() {
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-text").await;
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, text(&f, "ongoing")).await["value"]["text"],
|
||||
"Validation pending"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, text(&f, "current_position")).await["value"]["text"],
|
||||
""
|
||||
);
|
||||
let mut request = text(&f, "body");
|
||||
request["max_bytes"] = json!(8);
|
||||
let first = browser_call(&f, &token, request.clone()).await;
|
||||
assert_eq!(first["value"]["text"], &JOURNAL_BODY[..8]);
|
||||
request["byte_offset"] = first["value"]["next_byte"].clone();
|
||||
let (status, _) = response(
|
||||
build_router(f.api.clone()),
|
||||
browser_request(&subject(&f), Some(&token), request.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
request["expected_token"] = first["value"]["content_token"].clone();
|
||||
let next = browser_call(&f, &token, request.clone()).await;
|
||||
assert_eq!(next["value"]["text"], &JOURNAL_BODY[8..16]);
|
||||
f.fix_initial(); // changes entry token, never tears the old continuation
|
||||
assert_eq!(
|
||||
response(
|
||||
build_router(f.api.clone()),
|
||||
browser_request(&subject(&f), Some(&token), request)
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
for body in [
|
||||
json!({"action":"sessions","after":null,"limit":0}),
|
||||
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":33}),
|
||||
json!({"action":"entries","journal_id":null,"query":"x".repeat(257),"after":null,"limit":10}),
|
||||
{
|
||||
let mut body = text(&f, "body");
|
||||
body["max_bytes"] = json!(16385);
|
||||
body
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
response(
|
||||
build_router(f.api.clone()),
|
||||
browser_request(&subject(&f), Some(&token), body)
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_saved_bidirectional_links_titles_and_cross_session_corrections_survive_deletion()
|
||||
{
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-links").await;
|
||||
let sources =
|
||||
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":1});
|
||||
let unconnected = browser_call(&f, &token, sources.clone()).await;
|
||||
assert_eq!(
|
||||
unconnected["value"]["sources"][0]["link"]["connection"]["state"],
|
||||
"unconnected"
|
||||
);
|
||||
assert_eq!(
|
||||
unconnected["value"]["sources"][0]["link"]["connection"]["reason"],
|
||||
"not_reconciled"
|
||||
);
|
||||
f.fix_initial();
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: f.selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let (later, correction) = later_journal(&f, vec![f.entry_id.clone()]);
|
||||
let linked = browser_call(&f, &token, sources.clone()).await;
|
||||
assert_eq!(
|
||||
linked["value"]["sources"][0]["entry"]["title"],
|
||||
"Choosing the approach"
|
||||
);
|
||||
assert!(linked["value"]["sources"][0]["entry"]["created_at"].is_string());
|
||||
assert_eq!(
|
||||
linked["value"]["sources"][0]["link"]["connection"]["entry_id"],
|
||||
f.entry_id
|
||||
);
|
||||
let corrections = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"corrections","journal_id":f.journal_id,
|
||||
"entry_id":f.entry_id,"after":null,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(corrections["value"]["items"][0]["id"], correction);
|
||||
assert_eq!(corrections["value"]["items"][0]["journal_id"], later);
|
||||
let corrected = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"entry","journal_id":later,"entry_id":correction}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
corrected["value"]["correction_targets"][0]["id"],
|
||||
f.entry_id
|
||||
);
|
||||
assert_eq!(
|
||||
corrected["value"]["correction_targets"][0]["journal_id"],
|
||||
f.journal_id
|
||||
);
|
||||
let related_request = json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":1});
|
||||
let related = browser_call(&f, &token, related_request.clone()).await;
|
||||
assert_eq!(
|
||||
related["value"]["items"][0]["claim"],
|
||||
"Check the constraint before choosing"
|
||||
);
|
||||
assert_eq!(
|
||||
related["value"]["items"][0]["related"]["selector"]["change_id"],
|
||||
f.selector.change_id
|
||||
);
|
||||
assert_eq!(
|
||||
related["value"]["items"][0]["related"]["reconsideration"],
|
||||
true
|
||||
);
|
||||
fs::remove_dir_all(&f.source_root).unwrap();
|
||||
f.api
|
||||
.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap();
|
||||
let deleted = browser_call(&f, &token, detail(&f)).await;
|
||||
assert_eq!(deleted["value"]["original_source"]["state"], "missing");
|
||||
assert!(deleted["value"]["original_source"]["history_href"].is_null());
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, text(&f, "body")).await["value"]["text"],
|
||||
JOURNAL_BODY
|
||||
);
|
||||
let linked = browser_call(&f, &token, sources).await;
|
||||
assert_eq!(
|
||||
linked["value"]["sources"][0]["original_source"]["state"],
|
||||
"missing"
|
||||
);
|
||||
assert_eq!(linked["value"]["sources"][0]["entry"]["id"], f.entry_id);
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, related_request).await["value"]["items"][0]["claim"],
|
||||
"Check the constraint before choosing"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_history_navigation_requires_exact_session_mapping_not_registry_presence() {
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-original").await;
|
||||
let mut registry = f
|
||||
.api
|
||||
.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let initial = browser_call(&f, &token, detail(&f)).await;
|
||||
assert!(initial["value"]["original_source"]["history_href"].is_null());
|
||||
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
|
||||
f.api.store.upsert_worker_registry(®istry).unwrap();
|
||||
let exact = browser_call(&f, &token, detail(&f)).await;
|
||||
let resource_key = f
|
||||
.api
|
||||
.store
|
||||
.worker_resource_key(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
exact["value"]["original_source"]["history_href"],
|
||||
browser_worker_console_href(TEST_WORKSPACE_ID, &resource_key)
|
||||
);
|
||||
assert!(resource_key.starts_with("W-"));
|
||||
// Metadata does not attest original bytes. A later Session must not be linked as the old one.
|
||||
assert_eq!(exact["value"]["original_source"]["state"], "unavailable");
|
||||
registry.session_ref = Some("different-session".into());
|
||||
f.api.store.upsert_worker_registry(®istry).unwrap();
|
||||
assert!(
|
||||
browser_call(&f, &token, detail(&f)).await["value"]["original_source"]["history_href"]
|
||||
.is_null()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_many_saved_links_page_without_reconciliation_and_use_immutable_claims() {
|
||||
let f = Fixture::new().await;
|
||||
f.fix_initial();
|
||||
let correction = f.add_correction();
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: f.selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-many-links").await;
|
||||
let mut candidate = f
|
||||
.store
|
||||
.staging_candidate(&subject(&f), &f.selector.candidate_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
candidate.id = "second-browser-candidate".into();
|
||||
candidate.claim = "Another immutable lesson from both events".into();
|
||||
let mut source = candidate.source_refs[0].clone();
|
||||
source.evidence_id = Some(f.ranges[1].source.pointer.entry_ref.clone());
|
||||
source.entry_range = Some([1, 1]);
|
||||
let mut evidence = candidate.evidence[0].clone();
|
||||
evidence.id = source.evidence_id.clone().unwrap();
|
||||
evidence.entry_range = Some([1, 1]);
|
||||
candidate.source_refs.push(source);
|
||||
candidate.evidence.push(evidence);
|
||||
let (candidate, _) = f
|
||||
.store
|
||||
.stage_candidate_with_attribution(
|
||||
candidate,
|
||||
f.store
|
||||
.session_attribution(&f.ranges[0].source.session_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let (memory, _) = f
|
||||
.store
|
||||
.apply_candidate(
|
||||
&subject(&f),
|
||||
&candidate.id,
|
||||
crate::subjektiv::MemoryChangeTarget::Create,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
candidate.kind.clone(),
|
||||
&candidate.claim,
|
||||
"Saved Memory body",
|
||||
&candidate.why_useful,
|
||||
"Second adoption",
|
||||
),
|
||||
"Reviewed",
|
||||
)
|
||||
.unwrap();
|
||||
let selector = MemoryJournalSelector {
|
||||
memory_id: memory.id.clone(),
|
||||
change_id: memory.change_id.clone(),
|
||||
candidate_id: candidate.id.clone(),
|
||||
};
|
||||
let first = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"memory_sources","selector":selector,"source_offset":0,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first["value"]["total_sources"], 2);
|
||||
assert_eq!(first["value"]["next_source_index"], 1);
|
||||
assert_eq!(
|
||||
first["value"]["sources"][0]["link"]["connection"]["reason"],
|
||||
"not_reconciled"
|
||||
);
|
||||
// Reading a browser page never reconciles. Only the existing domain fixture explicitly does so.
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let first = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"memory_sources","selector":selector,"source_offset":0,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
let next = browser_call(&f,&token,json!({"action":"memory_sources","selector":selector,"source_offset":first["value"]["next_source_index"],"limit":1})).await;
|
||||
assert_eq!(first["value"]["sources"][0]["entry"]["id"], f.entry_id);
|
||||
assert_eq!(next["value"]["sources"][0]["entry"]["id"], correction);
|
||||
assert!(next["value"]["next_source_index"].is_null());
|
||||
f.store
|
||||
.revise_memory(
|
||||
&subject(&f),
|
||||
&memory.id,
|
||||
memory.change_id,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
candidate.kind,
|
||||
"Changed current claim",
|
||||
"New body",
|
||||
"Updated lesson",
|
||||
"Later qualification",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let first = browser_call(&f,&token,json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":1})).await;
|
||||
let next = browser_call(&f,&token,json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":first["value"]["next"],"limit":1})).await;
|
||||
assert!(first["value"]["next"].is_object());
|
||||
assert!(next["value"]["next"].is_null());
|
||||
let claims = [
|
||||
first["value"]["items"][0]["claim"].as_str().unwrap(),
|
||||
next["value"]["items"][0]["claim"].as_str().unwrap(),
|
||||
];
|
||||
assert!(claims.contains(&"Another immutable lesson from both events"));
|
||||
assert!(claims.contains(&"Check the constraint before choosing"));
|
||||
assert!(!claims.contains(&"Changed current claim"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_read_post_has_memory_get_user_authority_but_no_mutation_authority() {
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_api_token(f.api.store.as_ref(), "browser-reader-not-owner");
|
||||
// Ordinary authenticated read authority is the same as Memory GET, even though browser
|
||||
// selectors use POST. This exception is exact-route only; no mutations gain authority.
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, detail(&f)).await["value"]["entry"]["id"],
|
||||
f.entry_id
|
||||
);
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(r#"{"role":"not-allowed"}"#))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response(build_router(f.api.clone()), request).await.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_saved_missing_bytes_do_not_claim_original_conversation_deleted() {
|
||||
let f = Fixture::new().await;
|
||||
f.fix_initial();
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: f.selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-missing-byte").await;
|
||||
let mut registry = f
|
||||
.api
|
||||
.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
|
||||
f.api.store.upsert_worker_registry(®istry).unwrap();
|
||||
let sources =
|
||||
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":1});
|
||||
let present = browser_call(&f, &token, sources.clone()).await;
|
||||
assert!(present["value"]["sources"][0]["original_source"]["history_href"].is_string());
|
||||
|
||||
// The Host attests only a missing byte of the referenced source. Original Session
|
||||
// metadata and material still exist; neither this attestation nor its saved link
|
||||
// projection certifies deletion of the conversation as a whole.
|
||||
let mut missing_byte = f.ranges[0].clone();
|
||||
missing_byte.bytes.end = missing_byte.bytes.start + 1;
|
||||
f.store
|
||||
.mark_journal_sources_unavailable(&f.host, &f.journal_id, &[missing_byte])
|
||||
.unwrap();
|
||||
assert!(f.source_root.exists());
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
let partial = browser_call(&f, &token, sources.clone()).await;
|
||||
let source = &partial["value"]["sources"][0];
|
||||
assert_eq!(source["link"]["original_source"], "missing");
|
||||
assert_eq!(source["original_source"]["state"], "unavailable");
|
||||
assert!(source["original_source"]["history_href"].is_null());
|
||||
assert_eq!(source["entry"]["id"], f.entry_id);
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, text(&f, "body")).await["value"]["text"],
|
||||
JOURNAL_BODY
|
||||
);
|
||||
|
||||
// Only independently confirmed registry/archive absence changes the conversation
|
||||
// status to missing. The same saved Journal and immutable link remain readable.
|
||||
fs::remove_dir_all(&f.source_root).unwrap();
|
||||
f.api
|
||||
.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap();
|
||||
let deleted = browser_call(&f, &token, sources).await;
|
||||
assert_eq!(
|
||||
deleted["value"]["sources"][0]["original_source"]["state"],
|
||||
"missing"
|
||||
);
|
||||
assert!(deleted["value"]["sources"][0]["original_source"]["history_href"].is_null());
|
||||
assert_eq!(deleted["value"]["sources"][0]["entry"]["id"], f.entry_id);
|
||||
}
|
||||
|
||||
/// Emits actual authenticated router/store responses for the production Web contract runner.
|
||||
/// The paired `test:journal:backend-contract` task validates all action payloads with the real
|
||||
/// Web parser; this Rust test remains usable without a JavaScript runtime or product process.
|
||||
#[tokio::test]
|
||||
async fn journal_browser_web_contract_serializes_saved_router_responses() {
|
||||
let f = Fixture::new().await;
|
||||
f.fix_initial();
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: f.selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let (later, correction) = later_journal(&f, vec![f.entry_id.clone()]);
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-web-contract").await;
|
||||
let mut registry = f
|
||||
.api
|
||||
.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
|
||||
f.api.store.upsert_worker_registry(®istry).unwrap();
|
||||
let sources =
|
||||
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":3});
|
||||
let requests = [
|
||||
json!({"action":"sessions","after":null,"limit":20}),
|
||||
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":20}),
|
||||
detail(&f),
|
||||
text(&f, "body"),
|
||||
json!({"action":"corrections","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":10}),
|
||||
sources.clone(),
|
||||
json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":3}),
|
||||
json!({"action":"entry","journal_id":later,"entry_id":correction}),
|
||||
];
|
||||
for request in requests {
|
||||
let response = browser_call(&f, &token, request.clone()).await;
|
||||
println!(
|
||||
"JOURNAL_BROWSER_CONTRACT {}",
|
||||
json!({"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject(&f),"request":request,"response":response})
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(&f.source_root).unwrap();
|
||||
f.api
|
||||
.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap();
|
||||
for request in [detail(&f), sources] {
|
||||
let response = browser_call(&f, &token, request.clone()).await;
|
||||
println!(
|
||||
"JOURNAL_BROWSER_CONTRACT {}",
|
||||
json!({"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject(&f),"request":request,"response":response})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_cookie_read_post_retains_origin_check_for_non_owner_reader() {
|
||||
let f = Fixture::new().await;
|
||||
seed_test_api_token(f.api.store.as_ref(), "journal-cookie-reader");
|
||||
f.api
|
||||
.store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
token_hash: crate::auth::token_hash("journal-cookie-session"),
|
||||
session_id: "journal-cookie-session-id".into(),
|
||||
user_id: "user-journal-cookie-reader".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
expires_at: "2099-01-01T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let AuthConfig::Passkey {
|
||||
origin,
|
||||
cookie_name,
|
||||
..
|
||||
} = &f.api.config.auth;
|
||||
for (presented, expected) in [
|
||||
(None, StatusCode::FORBIDDEN),
|
||||
(Some("https://foreign.example"), StatusCode::FORBIDDEN),
|
||||
(Some(origin.as_str()), StatusCode::OK),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(browser_path(&subject(&f)))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
format!("{cookie_name}=journal-cookie-session"),
|
||||
);
|
||||
if let Some(origin) = presented {
|
||||
request = request.header(axum::http::header::ORIGIN, origin);
|
||||
}
|
||||
let (status, bytes) = response(
|
||||
build_router(f.api.clone()),
|
||||
request
|
||||
.body(Body::from(serde_json::to_vec(&detail(&f)).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, expected);
|
||||
if status == StatusCode::OK {
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(value["value"]["entry"]["id"], f.entry_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,5 +619,220 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
|
||||
)
|
||||
);
|
||||
drop(requests);
|
||||
let connection = connected_host_reader(&api, &next, &next_attr.session_id);
|
||||
let before = store
|
||||
.journal_resume_state(
|
||||
&JournalHostContext::validated_session(&store, next_attr.clone()).unwrap(),
|
||||
&store
|
||||
.journal_for_session(
|
||||
&JournalHostContext::validated_session(&store, next_attr.clone()).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let (startup, compacted) = tokio::task::spawn_blocking(move || {
|
||||
(
|
||||
connection.journal_startup_context().unwrap(),
|
||||
connection.journal_compaction_context().unwrap(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(startup.journals[0].source_session_id, old_attr.session_id);
|
||||
assert_eq!(
|
||||
compacted.journals[0].source_session_id,
|
||||
next_attr.session_id
|
||||
);
|
||||
assert_eq!(compacted.journals[0].state_token, before.0.state_token);
|
||||
assert_eq!(compacted.journals[0].process_order, before.0.process_order);
|
||||
assert_eq!(compacted.journals[0].process_byte, before.0.process_byte);
|
||||
assert_eq!(
|
||||
store
|
||||
.journal_resume_state(
|
||||
&JournalHostContext::validated_session(&store, next_attr.clone()).unwrap(),
|
||||
&before.0.id
|
||||
)
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
for (worker, session) in [
|
||||
(&old, old_attr.session_id.as_str()),
|
||||
(&next, old_attr.session_id.as_str()),
|
||||
(&next, "unattributed-session"),
|
||||
] {
|
||||
let connection = connected_host_reader(&api, worker, session);
|
||||
assert!(
|
||||
tokio::task::spawn_blocking(move || connection.journal_compaction_context())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signed_compaction_read_does_not_contact_runtime_or_wait_for_active_generation_and_rejects_jobs()
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(dir.path()).await;
|
||||
let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
|
||||
let store = open_subjektiv_store(&api).unwrap();
|
||||
let subject = store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("snapshot").unwrap())
|
||||
.unwrap();
|
||||
let settings = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||
.unwrap();
|
||||
let reserve = |key: String, operation: &str| {
|
||||
api.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
"runtime-test",
|
||||
operation,
|
||||
&"f".repeat(64),
|
||||
Some(&key),
|
||||
&settings,
|
||||
)
|
||||
.unwrap()
|
||||
.worker_id
|
||||
.to_string()
|
||||
};
|
||||
let body = reserve(
|
||||
crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap(),
|
||||
"body",
|
||||
);
|
||||
let job = reserve(
|
||||
format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{}", subject.id),
|
||||
"job",
|
||||
);
|
||||
let attr = store
|
||||
.record_session_attribution(
|
||||
crate::subjektiv::SubjectSessionAttribution::new(
|
||||
&subject.id,
|
||||
"runtime-test",
|
||||
&body,
|
||||
"committed-session",
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = bind_fixture_session(&store, attr);
|
||||
let range = JournalSourceRange {
|
||||
source: JournalSource {
|
||||
session_id: "committed-session".into(),
|
||||
pointer: JournalSourcePointer {
|
||||
segment_id: uuid::Uuid::now_v7(),
|
||||
entry_ref: "E0".into(),
|
||||
},
|
||||
order: 0,
|
||||
content_sha256: "f".repeat(64),
|
||||
total_bytes: 10,
|
||||
},
|
||||
bytes: JournalByteRange { start: 0, end: 10 },
|
||||
};
|
||||
let id = store
|
||||
.accept_journal_sources(&host, "pending", &[range.clone()])
|
||||
.unwrap();
|
||||
let generation = store
|
||||
.prepare_journal_generation(
|
||||
&host,
|
||||
&id,
|
||||
subjektiv::api::JobAttemptBinding {
|
||||
job_id: "active-job".into(),
|
||||
attempt_id: "attempt".into(),
|
||||
},
|
||||
vec![range],
|
||||
)
|
||||
.unwrap();
|
||||
let expected = store.journal_compaction_snapshot(&host).unwrap();
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/journal/compaction");
|
||||
let app = build_router(api.clone());
|
||||
for (worker, session, status) in [
|
||||
(&body, "committed-session", StatusCode::OK),
|
||||
(&job, "committed-session", StatusCode::FORBIDDEN),
|
||||
(&body, "foreign-session", StatusCode::FORBIDDEN),
|
||||
] {
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
app.clone().oneshot(runtime_source_request(
|
||||
&identity,
|
||||
Some(worker),
|
||||
"POST",
|
||||
&path,
|
||||
serde_json::to_vec(&JournalCompactionRequest {
|
||||
session_id: session.into(),
|
||||
})
|
||||
.unwrap(),
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("snapshot must not wait for an unavailable Runtime or active generation")
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), status);
|
||||
if status == StatusCode::OK {
|
||||
let snapshot: subjektiv::journal::startup::JournalStartupSnapshot =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(snapshot, expected);
|
||||
}
|
||||
}
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&JournalCompactionRequest {
|
||||
session_id: "committed-session".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(store.journal_compaction_snapshot(&host).unwrap(), expected);
|
||||
assert_eq!(
|
||||
store.active_journal_generation(&host, &id).unwrap(),
|
||||
Some(generation)
|
||||
);
|
||||
}
|
||||
|
||||
fn connected_host_reader(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
session: &str,
|
||||
) -> worker::subjektiv::SubjektivHostConnection {
|
||||
let audience = embedded_runtime_request_audience(&api.config).unwrap();
|
||||
let client = worker_runtime::worker_source::RuntimeOwnedWorkspaceClient::new(
|
||||
TEST_WORKSPACE_ID,
|
||||
api.config.backend_base_url.as_ref().unwrap(),
|
||||
&worker.runtime_id,
|
||||
&worker.worker_id,
|
||||
)
|
||||
.with_runtime_request_source(&EMBEDDED_RUNTIME_REQUEST_IDENTITY, audience);
|
||||
let mut config = manifest::ResolvedSubjektivFeatureConfig::default();
|
||||
config.profile.enabled = true;
|
||||
config
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
language: "English".into(),
|
||||
})
|
||||
.unwrap();
|
||||
worker::subjektiv::SubjektivHostConnection::new(
|
||||
worker::subjektiv::BackendSubjektivHost::from_resolved_config(&config, Arc::new(client))
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
worker::subjektiv::SubjektivHostContext {
|
||||
worker_id: worker.worker_id.clone(),
|
||||
session_id: session.into(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -729,3 +729,6 @@ async fn memory_journal_router_correction_reverse_lookup_preserves_memory_and_or
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[path = "subjektiv_journal_browser_tests.rs"]
|
||||
mod browser_tests;
|
||||
|
||||
@@ -319,6 +319,39 @@ async fn assignment_waiting_self_report_does_not_loop_but_external_answer_is_del
|
||||
delivery_stop(&api, &worker);
|
||||
}
|
||||
|
||||
async fn delivery_public_assignment_snapshot(
|
||||
api: &WorkspaceApi,
|
||||
ticket: &str,
|
||||
assignment: &str,
|
||||
) -> (Value, Value, Value) {
|
||||
let root = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}");
|
||||
let detail = delivery_http(
|
||||
api,
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let retained = delivery_http(
|
||||
api,
|
||||
"GET",
|
||||
&format!("{root}/assignment-tracking?assignment_id={assignment}"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let history = delivery_http(
|
||||
api,
|
||||
"GET",
|
||||
&format!("{root}/assignment-tracking/history?assignment_id={assignment}&limit=100"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
(detail, retained, history)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -331,6 +364,25 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne
|
||||
let initial = delivery_tracking(&api, &ticket.id, assignment);
|
||||
delivery_self_report(&api, &identity, &worker, &initial, "done", "return-options").await;
|
||||
let done = delivery_tracking(&api, &ticket.id, assignment);
|
||||
let (done_detail, done_public, done_history) =
|
||||
delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await;
|
||||
let expected_done = serde_json::to_value(&done).unwrap();
|
||||
assert_eq!(
|
||||
expected_done["tracking"]["result"],
|
||||
"Returned options without closing"
|
||||
);
|
||||
assert_eq!(done_detail["state"], "open");
|
||||
assert_eq!(done_detail["assignment_tracking"], json!([expected_done]));
|
||||
assert_eq!(done_public, done_detail["assignment_tracking"]);
|
||||
let history_items = done_history["items"].as_array().unwrap();
|
||||
assert_eq!(history_items.len(), 2, "initial waiting and reported done");
|
||||
assert_eq!(history_items[0]["tracking"]["status"], "waiting");
|
||||
assert_eq!(
|
||||
history_items[1]["event_id"],
|
||||
done.last_event_id.as_deref().unwrap()
|
||||
);
|
||||
assert_eq!(history_items[1]["tracking"], expected_done["tracking"]);
|
||||
assert!(done_history["next_event_id"].is_null());
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket.id).unwrap().state.as_str(),
|
||||
"open"
|
||||
@@ -373,6 +425,29 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne
|
||||
"case history must not revive done delegation"
|
||||
);
|
||||
assert_eq!(delivery_tracking(&api, &ticket.id, assignment), done);
|
||||
let (detail, public, history) =
|
||||
delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await;
|
||||
assert_eq!(
|
||||
detail["state"],
|
||||
if key == "close-return" {
|
||||
"closed"
|
||||
} else {
|
||||
"open"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
detail["closure_history"].as_array().unwrap().len(),
|
||||
if key == "close-return" { 1 } else { 2 }
|
||||
);
|
||||
assert_eq!(
|
||||
detail["assignment_tracking"],
|
||||
done_detail["assignment_tracking"]
|
||||
);
|
||||
assert_eq!(public, done_public, "case decision rewrote public handling");
|
||||
assert_eq!(
|
||||
history, done_history,
|
||||
"case decision rewrote handling history"
|
||||
);
|
||||
}
|
||||
let reassigned = delivery_assign(&api, &ticket.id, "reissue-return").await;
|
||||
let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap();
|
||||
@@ -395,6 +470,69 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne
|
||||
.status,
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
let (detail, old_public, old_history) =
|
||||
delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await;
|
||||
let (_, new_public, new_history) =
|
||||
delivery_public_assignment_snapshot(&api, &ticket.id, new_assignment).await;
|
||||
assert_eq!(detail["state"], "open");
|
||||
assert_eq!(detail["closure_history"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
old_public,
|
||||
json!([serde_json::to_value(&retained).unwrap()])
|
||||
);
|
||||
assert_eq!(old_public[0]["tracking"], expected_done["tracking"]);
|
||||
assert_eq!(
|
||||
old_public[0]["last_event_id"],
|
||||
expected_done["last_event_id"]
|
||||
);
|
||||
assert_eq!(old_public[0]["release"]["action"], "replaced");
|
||||
assert_eq!(
|
||||
old_public[0]["release"]["replacement_assignment_id"],
|
||||
new_assignment
|
||||
);
|
||||
assert_eq!(old_public[0]["release"]["operation_key"], "reissue-return");
|
||||
assert_eq!(old_history, done_history, "reissue changed old history");
|
||||
assert_eq!(detail["assignment_tracking"], new_public);
|
||||
assert_eq!(new_public[0]["assignment_id"], new_assignment);
|
||||
assert_eq!(new_public[0]["current"], true);
|
||||
assert_eq!(new_public[0]["tracking"]["status"], "waiting");
|
||||
assert!(new_public[0]["tracking"]["result"].is_null());
|
||||
assert!(new_public[0]["release"].is_null());
|
||||
assert_eq!(new_history["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
new_history["items"][0]["event_id"],
|
||||
new_public[0]["last_event_id"]
|
||||
);
|
||||
// Follow the public cursor rather than assuming current-first ordering.
|
||||
let page_root = format!(
|
||||
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/assignment-tracking/assignments",
|
||||
ticket.id
|
||||
);
|
||||
let first = delivery_http(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("{page_root}?limit=1"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let cursor = first["next_assignment_id"]
|
||||
.as_str()
|
||||
.expect("two assignments need a cursor");
|
||||
let second = delivery_http(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("{page_root}?limit=1&after_assignment_id={cursor}"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert!(second["next_assignment_id"].is_null());
|
||||
assert_eq!(first["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(second["items"].as_array().unwrap().len(), 1);
|
||||
let rows = [&first["items"][0], &second["items"][0]];
|
||||
assert!(rows.contains(&&old_public[0]));
|
||||
assert!(rows.contains(&&new_public[0]));
|
||||
// An exact old Assign retry returns its receipt, never revives old work.
|
||||
assert_eq!(
|
||||
delivery_assign(&api, &ticket.id, "assign-return").await,
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
// Tools/WIP -> per-request Runtime signature -> production router/SQLite.
|
||||
// Reuses the workdirless tracking fixture; its Runtime records execution rather
|
||||
// than creating sessions. No LLM, repository, live service or delivery E2E here.
|
||||
// Run: cargo test -p yoi-workspace-server --lib tools_backend_tests
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolOutput};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use worker::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
|
||||
use worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
WorkspaceResponse,
|
||||
};
|
||||
|
||||
struct TicketRouterClient {
|
||||
api: WorkspaceApi,
|
||||
identity: Arc<RuntimeIdentityMaterial>,
|
||||
source: RuntimeWorkerRef,
|
||||
runtime: tokio::runtime::Handle,
|
||||
lose_next_response: AtomicBool,
|
||||
requests: Mutex<Vec<(String, u16, String)>>,
|
||||
}
|
||||
impl std::fmt::Debug for TicketRouterClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("SignedTicketRouterClient")
|
||||
}
|
||||
}
|
||||
impl WorkspaceClient for TicketRouterClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some(TEST_WORKSPACE_ID)
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"signed-ticket-router"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> std::result::Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let method = match request.method {
|
||||
WorkspaceRequestMethod::Get => "GET",
|
||||
WorkspaceRequestMethod::Post => "POST",
|
||||
WorkspaceRequestMethod::Put => "PUT",
|
||||
WorkspaceRequestMethod::Patch => "PATCH",
|
||||
WorkspaceRequestMethod::Delete => "DELETE",
|
||||
};
|
||||
let signed = runtime_source_request(
|
||||
&self.identity,
|
||||
Some(&self.source.worker_id),
|
||||
method,
|
||||
&request.path,
|
||||
request.body.unwrap_or_default().into_bytes(),
|
||||
);
|
||||
// Test tool dispatch runs on spawn_blocking, so this sync transport
|
||||
// can drive the router without nesting runtimes on an async thread.
|
||||
self.runtime.block_on(async {
|
||||
let response = build_router(self.api.clone())
|
||||
.oneshot(signed)
|
||||
.await
|
||||
.map_err(|e| WorkspaceClientError::Request(e.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.map_err(|e| WorkspaceClientError::Request(e.to_string()))?;
|
||||
let body = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
let mutation = request.path.ends_with("/assignment-answer")
|
||||
|| request.path.ends_with("/implementation-results");
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((request.path, status, body.clone()));
|
||||
if method == "POST"
|
||||
&& mutation
|
||||
&& (200..300).contains(&status)
|
||||
&& self.lose_next_response.swap(false, Ordering::SeqCst)
|
||||
{
|
||||
return Err(WorkspaceClientError::Request(
|
||||
"response lost after commit; outcome unknown".into(),
|
||||
));
|
||||
}
|
||||
Ok(WorkspaceResponse { status, body })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NoTicketLlm;
|
||||
#[async_trait::async_trait]
|
||||
impl agen::llm_client::LlmClient for NoTicketLlm {
|
||||
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(
|
||||
&self,
|
||||
_: agen::llm_client::Request,
|
||||
) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError>
|
||||
{
|
||||
panic!("Ticket tools fixture must not call an LLM")
|
||||
}
|
||||
}
|
||||
async fn execute_tool(
|
||||
tool: Arc<dyn Tool>,
|
||||
input: serde_json::Value,
|
||||
context: ToolExecutionContext,
|
||||
) -> std::result::Result<serde_json::Value, ToolError> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
futures::executor::block_on(tool.execute(&input.to_string(), context)).map(output_json)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
fn output_json(output: ToolOutput) -> serde_json::Value {
|
||||
serde_json::from_str(output.content.as_deref().unwrap_or(&output.summary)).unwrap()
|
||||
}
|
||||
struct TicketSurface {
|
||||
tools: BTreeMap<String, Arc<dyn Tool>>,
|
||||
wip: bool,
|
||||
}
|
||||
impl TicketSurface {
|
||||
async fn new(root: &Path, client: Arc<TicketRouterClient>, wip: bool) -> Self {
|
||||
let manifest = worker::WorkerManifest::from_toml("[worker]\nname=\"ticket-tools\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n").unwrap();
|
||||
let engine =
|
||||
agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||
NoTicketLlm,
|
||||
);
|
||||
let mut host = worker::Worker::new(
|
||||
manifest,
|
||||
engine,
|
||||
session_store::FsStore::new(root.join("sessions")).unwrap(),
|
||||
worker::WorkerWorkspaceContext::with_client(
|
||||
Some(worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap()),
|
||||
client.clone(),
|
||||
),
|
||||
worker::WorkerFilesystemAuthority::None,
|
||||
manifest::Scope::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let report =
|
||||
host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module(
|
||||
TicketFeature::new(client.clone(), TicketFeatureAccess::workflow()),
|
||||
));
|
||||
assert!(!report.has_errors());
|
||||
let handle = host.engine().tool_server_handle();
|
||||
handle.flush_pending();
|
||||
let tools = [
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentAnswer",
|
||||
"TicketImplementationResult",
|
||||
"TicketImplementationResultQuery",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (name.into(), handle.get_tool(name).unwrap().1))
|
||||
.collect();
|
||||
if !wip {
|
||||
return Self { tools, wip };
|
||||
}
|
||||
let mut mounts = worker::wip::WipMountRegistry::new();
|
||||
let route = mounts.allocate_namespace("ticket", "tickets").unwrap();
|
||||
worker::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||
&mut mounts,
|
||||
client,
|
||||
TicketFeatureAccess::workflow(),
|
||||
None,
|
||||
&route,
|
||||
)
|
||||
.unwrap();
|
||||
let mut engine = agen::Engine::<_, agen::state::Mutable, ()>::new_annotated(NoTicketLlm);
|
||||
worker::wip::install_wip_mode_with_mounts(&mut engine, None, "ticket-tools".into(), mounts)
|
||||
.unwrap();
|
||||
let handle = engine.tool_server_handle();
|
||||
handle.flush_pending();
|
||||
Self {
|
||||
tools: ["Inspect", "Invoke"]
|
||||
.into_iter()
|
||||
.map(|name| (name.into(), handle.get_tool(name).unwrap().1))
|
||||
.collect(),
|
||||
wip,
|
||||
}
|
||||
}
|
||||
async fn call(
|
||||
&self,
|
||||
name: &str,
|
||||
mut input: serde_json::Value,
|
||||
context: ToolExecutionContext,
|
||||
) -> std::result::Result<serde_json::Value, ToolError> {
|
||||
if !self.wip {
|
||||
return execute_tool(self.tools[name].clone(), input, context).await;
|
||||
}
|
||||
let path = format!("/tickets/{}", input["ticket"].as_str().unwrap());
|
||||
input.as_object_mut().unwrap().remove("ticket");
|
||||
// Observe the real item representation before invoking a mutation. Tracking
|
||||
// reads alone do not establish the Ticket WIP object validator. Explicit
|
||||
// refresh also reconciles stale observations after an unknown result;
|
||||
// it does not resend the operation or manufacture an observed CAS value.
|
||||
let inspected = execute_tool(
|
||||
self.tools["Inspect"].clone(),
|
||||
serde_json::json!({"path":path,"refresh":true}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await?;
|
||||
execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":{}}), ToolExecutionContext::direct()).await?;
|
||||
let inspected = execute_tool(
|
||||
self.tools["Inspect"].clone(),
|
||||
serde_json::json!({"path":path,"refresh":true}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await?;
|
||||
let operation = match name {
|
||||
"TicketAssignmentTrackingQuery" => "assignment_tracking_query",
|
||||
"TicketAssignmentAnswer" => "assignment_answer",
|
||||
"TicketImplementationResult" => "implementation_result",
|
||||
"TicketImplementationResultQuery" => "implementation_result_query",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":operation,"arguments":input}), context).await
|
||||
}
|
||||
}
|
||||
fn ticket_client(
|
||||
api: &WorkspaceApi,
|
||||
identity: RuntimeIdentityMaterial,
|
||||
source: &RuntimeWorkerRef,
|
||||
) -> Arc<TicketRouterClient> {
|
||||
Arc::new(TicketRouterClient {
|
||||
api: api.clone(),
|
||||
identity: Arc::new(identity),
|
||||
source: source.clone(),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
lose_next_response: AtomicBool::new(false),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
fn context(call: &str) -> ToolExecutionContext {
|
||||
ToolExecutionContext::new(call, "ticket-tools-batch", 0)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waiting_delegation() {
|
||||
for wip in [false, true] {
|
||||
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 input_count = runtime.input_requests.lock().unwrap().len();
|
||||
let client = ticket_client(&api, identity, &source);
|
||||
let surface = TicketSurface::new(dir.path(), client.clone(), wip).await;
|
||||
let observed = surface
|
||||
.call(
|
||||
"TicketAssignmentTrackingQuery",
|
||||
serde_json::json!({"ticket":ticket}),
|
||||
context("read"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed[0]["assignment_id"], current.assignment_id);
|
||||
assert_eq!(
|
||||
observed[0]["last_event_id"],
|
||||
serde_json::to_value(¤t.last_event_id).unwrap()
|
||||
);
|
||||
assert_eq!(observed[0]["tracking"]["status"], "waiting");
|
||||
let input = serde_json::json!({"ticket":ticket,"assignment_id":observed[0]["assignment_id"],"expected_last_event_id":observed[0]["last_event_id"],"operation_key":"answer-observed","body":"Use the bounded option"});
|
||||
client.lose_next_response.store(true, Ordering::SeqCst);
|
||||
let error = surface
|
||||
.call("TicketAssignmentAnswer", input.clone(), context("answer"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("outcome unknown"), "{error}");
|
||||
let writes = || {
|
||||
client
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(p, _, _)| p.ends_with("/assignment-answer"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(writes().len(), 1, "unknown outcome must not silently retry");
|
||||
let committed: serde_json::Value = serde_json::from_str(&writes()[0].2).unwrap();
|
||||
let receipt = surface
|
||||
.call("TicketAssignmentAnswer", input.clone(), context("answer"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(receipt, committed);
|
||||
assert_eq!(
|
||||
surface
|
||||
.call("TicketAssignmentAnswer", input.clone(), context("answer"))
|
||||
.await
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert_eq!(receipt["assignment_id"], observed[0]["assignment_id"]);
|
||||
let mut stale = input.clone();
|
||||
stale["operation_key"] = "stale-answer".into();
|
||||
stale["expected_last_event_id"] = "unobserved-event".into();
|
||||
let error = surface
|
||||
.call("TicketAssignmentAnswer", stale, context("stale"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("409"), "{error}");
|
||||
let mut refused = input.clone();
|
||||
refused["body"] = "Different answer under same operation".into();
|
||||
let error = surface
|
||||
.call("TicketAssignmentAnswer", refused, context("answer"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("409"), "{error}");
|
||||
// A valid Runtime signature is not a Worker membership grant. The
|
||||
// Backend must reject this unregistered source through the same tools.
|
||||
let denied = Arc::new(TicketRouterClient {
|
||||
api: api.clone(),
|
||||
identity: client.identity.clone(),
|
||||
source: RuntimeWorkerRef::new(
|
||||
source.runtime_id.clone(),
|
||||
"00000000-0000-7000-8000-000000000099",
|
||||
),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
lose_next_response: AtomicBool::new(false),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
});
|
||||
let denied_surface = TicketSurface::new(&dir.path().join("denied"), denied, wip).await;
|
||||
let error = denied_surface
|
||||
.call("TicketAssignmentAnswer", input.clone(), context("denied"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("401"), "{error}");
|
||||
assert_eq!(
|
||||
runtime.input_requests.lock().unwrap().len(),
|
||||
input_count,
|
||||
"self Answer must not enqueue a new input to its own Orchestrator"
|
||||
);
|
||||
let saved = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.show(ticket.clone().into())
|
||||
.unwrap();
|
||||
let events: Vec<_> = saved
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.kind.as_str() == "assignment_answer")
|
||||
.collect();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].body.as_str(), input["body"].as_str().unwrap());
|
||||
assert_eq!(
|
||||
events[0].author.as_deref(),
|
||||
Some(format!("worker:{}/{}", source.runtime_id, source.worker_id).as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
current
|
||||
);
|
||||
assert_eq!(
|
||||
surface
|
||||
.call(
|
||||
"TicketAssignmentTrackingQuery",
|
||||
serde_json::json!({"ticket":ticket}),
|
||||
context("read-after")
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
observed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn report_counts(api: &WorkspaceApi) -> (i64, i64) {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM ticket_implementation_results",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap(),
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM notification_sources WHERE kind='implementation_result'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn implementation_result_tool_and_wip_keep_host_owned_retry_identity_without_assignments() {
|
||||
for wip in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
// Deliberately do not call assign_tracking: reporting needs no delegation/MR.
|
||||
let before = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.show(ticket.clone().into())
|
||||
.unwrap();
|
||||
let client = ticket_client(&api, identity, &source);
|
||||
let surface = TicketSurface::new(dir.path(), client.clone(), wip).await;
|
||||
let input =
|
||||
serde_json::json!({"ticket":ticket,"kind":"result","body":"Repository-free findings"});
|
||||
client.lose_next_response.store(true, Ordering::SeqCst);
|
||||
let error = surface
|
||||
.call(
|
||||
"TicketImplementationResult",
|
||||
input.clone(),
|
||||
context("report-a"),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("outcome unknown"), "{error}");
|
||||
assert_eq!(report_counts(&api), (1, 1));
|
||||
let writes = || {
|
||||
client
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(p, _, _)| p.ends_with("/implementation-results"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(writes().len(), 1);
|
||||
let committed: serde_json::Value = serde_json::from_str(&writes()[0].2).unwrap();
|
||||
let receipt = surface
|
||||
.call(
|
||||
"TicketImplementationResult",
|
||||
input.clone(),
|
||||
context("report-a"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(receipt, committed);
|
||||
assert_eq!(
|
||||
surface
|
||||
.call(
|
||||
"TicketImplementationResult",
|
||||
input.clone(),
|
||||
context("report-a")
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert_eq!(receipt["request"]["body"], input["body"]);
|
||||
assert_eq!(report_counts(&api), (1, 1));
|
||||
let queried = surface
|
||||
.call(
|
||||
"TicketImplementationResultQuery",
|
||||
serde_json::json!({"ticket":ticket,"result_id":receipt["result_id"]}),
|
||||
context("query"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(queried, receipt);
|
||||
let mut changed = input.clone();
|
||||
changed["body"] = "Different body".into();
|
||||
let error = surface
|
||||
.call("TicketImplementationResult", changed, context("report-a"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("409"), "{error}");
|
||||
assert_eq!(report_counts(&api), (1, 1));
|
||||
let other = surface
|
||||
.call("TicketImplementationResult", input, context("report-b"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(other["result_id"], receipt["result_id"]);
|
||||
assert_eq!(report_counts(&api), (2, 2));
|
||||
assert_eq!(
|
||||
browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.show(ticket.clone().into())
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.get_current_ticket_role_assignment(
|
||||
TEST_WORKSPACE_ID,
|
||||
&ticket,
|
||||
TicketAssignmentRole::Orchestrator
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.get_active_ticket_role_assignment(
|
||||
TEST_WORKSPACE_ID,
|
||||
&ticket,
|
||||
TicketAssignmentRole::Worker
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let suppressed: i64 = conn.query_row("SELECT count(*) FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.kind='implementation_result' AND d.disposition='suppress' AND d.reason='no_recipient'", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(suppressed, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Real router response bytes -> production Web parser/operation/summary. The
|
||||
// sibling lifecycle suite owns delivery; this bridge replaces only the Runtime
|
||||
// execution boundary and launches no model, browser, or live product process.
|
||||
use super::*;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
async fn web_body(api: &WorkspaceApi, method: &str, path: &str, body: Option<Value>) -> String {
|
||||
let response = build_router(api.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header(
|
||||
"authorization",
|
||||
format!("Bearer api-token-{TEST_WORKSPACE_ID}"),
|
||||
)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.map(|v| v.to_string()).unwrap_or_default()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"{method} {path}: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
async fn web_pages(
|
||||
api: &WorkspaceApi,
|
||||
path: &str,
|
||||
cursor_field: &str,
|
||||
query_field: &str,
|
||||
) -> Vec<String> {
|
||||
let mut pages = Vec::new();
|
||||
let mut next = None::<String>;
|
||||
loop {
|
||||
assert!(pages.len() < 10, "pagination failed to terminate");
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
query.append_pair("limit", "1");
|
||||
if let Some(cursor) = next.as_deref() {
|
||||
query.append_pair(query_field, cursor);
|
||||
}
|
||||
let separator = if path.ends_with('?') { "" } else { "&" };
|
||||
let raw = web_body(
|
||||
api,
|
||||
"GET",
|
||||
&format!("{path}{separator}{}", query.finish()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let value: Value = serde_json::from_str(&raw).unwrap();
|
||||
next = value[cursor_field].as_str().map(str::to_owned);
|
||||
pages.push(raw);
|
||||
if next.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
pages
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Deno on PATH; deno task test:ticket:real-api selects this bridge explicitly"]
|
||||
async fn ticket_web_roundtrip_preserves_retained_result_case_history_and_current_answer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, execution, worker, identity) = delivery_recording_fixture(dir.path()).await;
|
||||
let ticket = delivery_ticket(&api, "Retain completed research across reissue");
|
||||
let assigned = delivery_assign(&api, &ticket.id, "web-initial").await;
|
||||
let old_id = assigned["assignment"]["assignment_id"].as_str().unwrap();
|
||||
let initial = delivery_tracking(&api, &ticket.id, old_id);
|
||||
delivery_self_report(&api, &identity, &worker, &initial, "done", "web-done").await;
|
||||
let done = delivery_tracking(&api, &ticket.id, old_id);
|
||||
let base = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{}", ticket.id);
|
||||
let detail_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", ticket.id);
|
||||
let done_detail = web_body(&api, "GET", &detail_path, None).await;
|
||||
let mut case_details = Vec::new();
|
||||
for (key, action) in [
|
||||
(
|
||||
"web-close",
|
||||
json!({"action":"close", "reason":"not_planned"}),
|
||||
),
|
||||
("web-reopen", json!({"action":"reopen"})),
|
||||
] {
|
||||
let observed = api.authority.ticket(&ticket.id).unwrap();
|
||||
web_body(
|
||||
&api,
|
||||
"POST",
|
||||
&format!("{base}/case"),
|
||||
Some(json!({
|
||||
"expected_content_digest":observed.content_digest,
|
||||
"expected_state":observed.state,
|
||||
"expected_closure_operation_key":observed.closure_operation_key,
|
||||
"operation_key":key,"action":action,"explanation":"Independent human case decision"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
case_details.push(web_body(&api, "GET", &detail_path, None).await);
|
||||
assert_eq!(delivery_tracking(&api, &ticket.id, old_id), done);
|
||||
}
|
||||
let reassigned = delivery_assign(&api, &ticket.id, "web-reissue").await;
|
||||
let current_id = reassigned["assignment"]["assignment_id"].as_str().unwrap();
|
||||
assert_ne!(old_id, current_id);
|
||||
let current = delivery_tracking(&api, &ticket.id, current_id);
|
||||
let reissued_detail = web_body(&api, "GET", &detail_path, None).await;
|
||||
let retained = web_body(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("{base}/assignment-tracking?assignment_id={old_id}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let assignments = web_pages(
|
||||
&api,
|
||||
&format!("{base}/assignment-tracking/assignments?"),
|
||||
"next_assignment_id",
|
||||
"after_assignment_id",
|
||||
)
|
||||
.await;
|
||||
let history = web_pages(
|
||||
&api,
|
||||
&format!("{base}/assignment-tracking/history?assignment_id={old_id}"),
|
||||
"next_event_id",
|
||||
"after_event_id",
|
||||
)
|
||||
.await;
|
||||
// Use the actual public read as the Answer input, not the store expectation.
|
||||
let read = web_body(&api, "GET", &format!("{base}/assignment-tracking"), None).await;
|
||||
let rows: Value = serde_json::from_str(&read).unwrap();
|
||||
let answer_body = json!({"assignment_id":rows[0]["assignment_id"], "expected_last_event_id":rows[0]["last_event_id"], "operation_key":"web-current-answer", "body":"Continue the new request"});
|
||||
let receipt = web_body(
|
||||
&api,
|
||||
"POST",
|
||||
&format!("{base}/assignment-answer"),
|
||||
Some(answer_body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
web_body(
|
||||
&api,
|
||||
"POST",
|
||||
&format!("{base}/assignment-answer"),
|
||||
Some(answer_body)
|
||||
)
|
||||
.await,
|
||||
receipt
|
||||
);
|
||||
let after_answer = web_body(&api, "GET", &detail_path, None).await;
|
||||
assert_eq!(delivery_tracking(&api, &ticket.id, current_id), current);
|
||||
let wire = json!({
|
||||
"expected":{"workspace_id":TEST_WORKSPACE_ID,"ticket_id":ticket.id,
|
||||
"old_assignment_id":old_id,"new_assignment_id":current_id,
|
||||
"done_event_id":done.last_event_id,"new_event_id":current.last_event_id,
|
||||
"old_result":"Returned options without closing"},
|
||||
"done_detail":done_detail,"closed_detail":case_details[0],"reopened_detail":case_details[1],
|
||||
"reissued_detail":reissued_detail,"retained_old":retained,"assignment_pages":assignments,
|
||||
"old_history_pages":history,"answer_receipt":receipt,"answered_detail":after_answer
|
||||
});
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let mut child = tokio::process::Command::new("deno")
|
||||
.current_dir(root.join("web/workspace"))
|
||||
.args([
|
||||
"run",
|
||||
"--cached-only",
|
||||
"--config",
|
||||
"test/ticket-real-api/deno.json",
|
||||
"test/ticket-real-api/roundtrip.ts",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.expect("explicit Ticket bridge requires Deno on PATH");
|
||||
// Include stdin backpressure inside the watchdog. Dropping the future kills
|
||||
// the child on timeout; wait_with_output reaps it on normal completion.
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(30), async move {
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
stdin
|
||||
.write_all(&serde_json::to_vec(&wire).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
drop(stdin);
|
||||
child.wait_with_output().await.unwrap()
|
||||
})
|
||||
.await
|
||||
.expect("Ticket Web bridge exceeded 30 seconds");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Ticket Web bridge failed:\n{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.contains("real Ticket bytes → production parser/operation/summary lifecycle passed")
|
||||
);
|
||||
execution.take_inputs();
|
||||
delivery_stop(&api, &worker);
|
||||
}
|
||||
Reference in New Issue
Block a user