chore: merge develop into hare/develop

This commit is contained in:
2026-10-19 01:31:23 +09:00
66 changed files with 8656 additions and 143 deletions
@@ -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]
+18 -2
View File
@@ -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(&registry).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(&registry).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(&registry).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(&registry).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(&current.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, &current.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);
}