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
+2
View File
@@ -69,6 +69,8 @@ pub enum LoggedSessionHistoryOrigin {
},
/// Host-produced opening context summary; not auto-read or TaskStore material.
CompactionSummary,
/// Frozen Host Journal reference, never independent observation evidence.
JournalContext,
DerivedSummary,
LegacyUnknown,
}
+3 -1
View File
@@ -829,7 +829,9 @@ fn boundary_reference(
fn canonical_body(entry: &LoggedHistoryEntry) -> Option<CanonicalBody<'_>> {
if matches!(
entry.metadata.origin,
LoggedSessionHistoryOrigin::DerivedSummary | LoggedSessionHistoryOrigin::CompactionSummary
LoggedSessionHistoryOrigin::DerivedSummary
| LoggedSessionHistoryOrigin::CompactionSummary
| LoggedSessionHistoryOrigin::JournalContext
) {
return None;
}
@@ -543,6 +543,14 @@ fn reasoning_proves_rollback_safety_but_private_and_derived_items_are_not_materi
let segment = crate::new_segment_id();
let mut derived = message("derived", LoggedRole::User, "summary");
derived.metadata.origin = LoggedSessionHistoryOrigin::DerivedSummary;
let mut journal = message("journal", LoggedRole::User, "frozen Journal reference");
journal.metadata.origin = LoggedSessionHistoryOrigin::JournalContext;
let mut summary = message(
"compaction-summary",
LoggedRole::User,
"complementary summary",
);
summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary;
let reasoning = entry(
"reasoning",
LoggedItem::Reasoning {
@@ -559,7 +567,12 @@ fn reasoning_proves_rollback_safety_but_private_and_derived_items_are_not_materi
&[
seed(
session,
vec![derived, message("system", LoggedRole::System, "secret")],
vec![
derived,
journal,
summary,
message("system", LoggedRole::System, "secret"),
],
),
LogEntry::AnnotatedSystemItem {
ts: 1,
@@ -1889,6 +1889,7 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
LoggedSessionHistoryOrigin::CompactionSummary
| LoggedSessionHistoryOrigin::JournalContext
| LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
+38
View File
@@ -840,6 +840,44 @@ impl SubjektivHost for LocalSubjectHost {
.journal_startup_snapshot(&reader, predecessor.as_deref())
.map_err(journal::journal_error)
}
fn journal_compaction_granted(&self) -> bool {
self.live.load(Ordering::Acquire)
}
fn journal_compaction_context(
&self,
context: &SubjektivHostContext,
) -> Result<subjektiv::journal::startup::JournalStartupSnapshot, SubjektivHostError> {
self.body(context)?;
let attribution = self
.catalog
.store
.session_attribution(&context.session_id)
.map_err(domain_error)?
.filter(|attr| {
attr.subject_id == self.binding.subject_id
&& attr.runtime_id == self.binding.scope_id
&& attr.worker_id == self.worker_id.to_string()
})
.ok_or_else(|| {
SubjektivHostError::InvalidArgument(
"foreign or unattributed compaction Session".into(),
)
})?;
let host = subjektiv::journal::JournalHostContext::validated_session(
&self.catalog.store,
attribution,
)
.map_err(journal::journal_error)?;
if host.writer_binding_id() != Some(self.writer.binding_id.as_str()) {
return Err(SubjektivHostError::InvalidArgument(
"compaction writer lifetime mismatch".into(),
));
}
self.catalog
.store
.journal_compaction_snapshot(&host)
.map_err(journal::journal_error)
}
fn journal_recording_granted(&self) -> bool {
self.live.load(Ordering::Acquire)
}
@@ -8,6 +8,7 @@ use session_store::{Store, WorkerMetadataStore};
use std::sync::atomic::AtomicBool;
mod compaction;
mod connected_compaction;
mod handoff;
mod memory_links;
@@ -290,6 +291,65 @@ fn save(
receipt
}
#[test]
fn compaction_host_reads_pending_recording_without_advancing_or_accepting_foreign_sessions() {
let fixture = Fixture::new("partially performed; not confirmed");
let connection = worker::subjektiv::SubjektivHostConnection::new(
fixture.host().clone(),
fixture.context.clone(),
)
.unwrap();
assert!(connection.journal_compaction_granted());
assert!(
connection
.journal_compaction_context()
.unwrap()
.journals
.is_empty()
);
let (adapter, _, _) = fixture.adapter();
let before = adapter.state().unwrap();
let snapshot = connection.journal_compaction_context().unwrap();
assert_eq!(
snapshot.journals[0].source_session_id,
fixture.context.session_id
);
assert_eq!(
snapshot.journals[0].process_order,
before.current_journal.process_order
);
assert_eq!(
snapshot.journals[0].process_byte,
before.current_journal.process_byte
);
assert_eq!(adapter.state().unwrap(), before);
assert!(
fixture
.host()
.catalog
.store
.active_journal_generation(&adapter.context, &adapter.grant.journal_id)
.unwrap()
.is_some()
);
for context in [
SubjektivHostContext {
worker_id: "foreign-worker".into(),
..fixture.context.clone()
},
SubjektivHostContext {
session_id: session_store::SessionId::now_v7().to_string(),
..fixture.context.clone()
},
] {
assert!(fixture.host().journal_compaction_context(&context).is_err());
}
fixture.host().live.store(false, Ordering::Release);
assert!(!connection.journal_compaction_granted());
assert!(connection.journal_compaction_context().is_err());
fixture.host().live.store(true, Ordering::Release);
}
#[test]
fn duplicate_admission_reserves_one_job_and_excludes_private_prompt() {
let fixture = Fixture::new("short request");
@@ -0,0 +1,171 @@
//! Real LocalSubjectHost/Journal storage -> PreparedWorker -> compactor Request -> durable seed.
//! Only external LLM responses and the fixture's Job execution fence are substituted. The
//! in-flight generation is retained while its real adapter updates storage during generation.
//! Temporary storage and the parent fixture own cleanup; ordinary standalone lib tests select
//! this suite. Predetermined summaries attest transport and persistence, not model understanding.
use super::*;
use agen::llm_client::{
ClientError, LlmClient, Request,
event::{Event, ResponseStatus, StatusEvent},
types::Item,
};
use futures::Stream;
use session_store::{CombinedStore, FsWorkerStore, LogEntry, LoggedSessionHistoryOrigin};
use std::pin::Pin;
const BEFORE: &str = "FROZEN_POSITION: B partially implemented; tests not run; result unconfirmed";
const AFTER: &str = "NEXT_POSITION: background Journal changed during compactor generation";
const COMPLEMENT: &str =
"Unrecorded request: keep the user's constraint and finish the remaining checks.";
#[derive(Clone)]
struct MockClient {
adapter: Arc<LocalJournalHost>,
requests: Arc<Mutex<Vec<Request>>>,
updated: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl LlmClient for MockClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
assert!(
request
.tools
.iter()
.any(|tool| tool.name == "write_summary")
);
let first = self.requests.lock().unwrap().is_empty();
self.requests.lock().unwrap().push(request);
let events = if first {
assert!(!self.updated.swap(true, Ordering::SeqCst));
let state = self.adapter.state().unwrap();
save(
&self.adapter,
"position-during-compaction",
&state.current_journal.state_token,
JournalAction::UpdatePosition {
current_position: AFTER.into(),
},
vec![],
);
vec![
Event::tool_use_start(0, "summary", "write_summary"),
Event::tool_input_delta(0, serde_json::json!({ "text": COMPLEMENT }).to_string()),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
} else {
vec![
Event::text_block_start(0),
Event::text_delta(0, "summary saved"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
};
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
}
#[tokio::test]
async fn local_host_compaction_freezes_pending_journal_through_request_and_durable_seed_during_update()
{
tokio::time::timeout(std::time::Duration::from_secs(30), async {
let fixture = Fixture::new("Keep the user's constraint; work remains unfinished and unconfirmed.");
let (adapter, _, job) = fixture.adapter();
let created = save(
&adapter,
"record-partial-work",
&adapter.generation.initial_journal.state_token,
JournalAction::Create {
title: "A corrected to B".into(),
body: "B partially implemented; tests not run; result unconfirmed".into(),
ongoing: "Finish remaining checks".into(),
state: JournalEntryState::Open,
source_end: None,
correction_refs: vec![],
},
adapter.generation.selected_ranges.clone(),
);
save(&adapter, "position-before-compaction", &created.state_token,
JournalAction::UpdatePosition { current_position: BEFORE.into() }, vec![]);
let before = adapter.state().unwrap();
let expected = fixture.host().journal_compaction_context(&fixture.context).unwrap();
let journal = &expected.journals[0];
assert_eq!(journal.source_session_id, fixture.context.session_id);
assert_eq!(journal.current_position, BEFORE);
assert_eq!(journal.entries[0].state, JournalEntryState::Open);
assert!(!before.selected_ranges.is_empty());
assert_eq!(journal.process_order, before.current_journal.process_order);
assert_eq!(journal.process_byte, before.current_journal.process_byte);
assert_eq!(journal.process_order, 0);
assert_eq!(journal.process_byte, 0, "saved open records do not advance processing checkpoint");
let expected_json = serde_json::to_string(&expected).unwrap();
let adapter = Arc::new(adapter);
let model = MockClient { adapter: adapter.clone(), requests: Arc::default(), updated: Arc::new(AtomicBool::new(false)) };
let workers = StandaloneWorkerStore::open_existing(&fixture.host().catalog.state_root).unwrap();
let worker_id = fixture._allocation.as_ref().unwrap().worker_id();
let store = CombinedStore::new(fixture.sessions.clone(), FsWorkerStore::new(workers.worker_metadata_dir(worker_id)).unwrap());
let mut manifest = manifest::WorkerManifest::from_toml(&format!("[worker]\nname='{}'\n[model]\nscheme='anthropic'\nmodel_id='scripted'\n[engine]\n[scope]\nallow=[]", fixture.context.worker_id)).unwrap();
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = false;
let mut prepared = worker::WorkerBootstrap::new(
manifest,
store,
worker::PromptCatalogSource::builtins_only(),
worker::WorkerWorkspaceContext::no_workspace(),
worker::WorkerFilesystemAuthority::None,
worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime"), bash_output_dir: fixture._temp.path().join("bash") },
worker::WorkerControllerTransport::InProcess,
).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap();
assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id);
let source_segment = prepared.worker().segment_id();
let original = fixture.sessions.read_all(prepared.worker().session_id(), source_segment).unwrap();
let generation = fixture.host().catalog.store.active_journal_generation(&adapter.context, &adapter.grant.journal_id).unwrap().unwrap();
assert!(!fixture.host().session_fully_reflected(&fixture.context.session_id).unwrap());
prepared.worker_mut().compact(0).await.unwrap();
assert!(model.updated.load(Ordering::SeqCst));
let worker = prepared.worker();
assert_ne!(worker.segment_id(), source_segment);
let seed = worker.store().read_all(worker.session_id(), worker.segment_id()).unwrap().into_iter().find_map(|entry| match entry { LogEntry::AnnotatedSegmentStart { history, .. } => Some(history), _ => None }).expect("durable compacted seed");
let journal_items: Vec<_> = seed.iter().filter(|entry| entry.metadata.origin == LoggedSessionHistoryOrigin::JournalContext).collect();
assert_eq!(journal_items.len(), 1);
let journal_item = Item::from(journal_items[0].item.clone());
let body = journal_item.as_text().unwrap();
assert!(body.contains(&expected_json), "durable seed contains the full selected JSON and processing checkpoint");
assert!(body.contains(BEFORE));
assert!(!body.contains(AFTER));
assert!(seed.iter().any(|entry| entry.metadata.origin == LoggedSessionHistoryOrigin::CompactionSummary && Item::from(entry.item.clone()).as_text().is_some_and(|text| text.contains(COMPLEMENT))));
let requests = model.requests.lock().unwrap();
let input = requests[0].items.iter().find_map(|item| match item {
Item::Message { role: agen::llm_client::types::Role::User, .. } => item.as_text().filter(|text| text.contains("## Conversation overview/index")),
_ => None,
}).expect("actual compactor user request");
assert!(input.ends_with(body), "exact same frozen reference goes to actual compactor and seed");
assert_eq!(input.matches(&expected_json).count(), 1);
assert!(!input.contains(AFTER));
drop(requests);
let latest = fixture.host().journal_compaction_context(&fixture.context).unwrap();
assert_eq!(latest.journals[0].current_position, AFTER);
assert_ne!(latest.journals[0].state_token, expected.journals[0].state_token);
assert_eq!(latest.journals[0].process_order, expected.journals[0].process_order);
assert_eq!(latest.journals[0].process_byte, expected.journals[0].process_byte);
assert_eq!(latest.journals[0].entries, expected.journals[0].entries);
assert_eq!(fixture.host().catalog.store.active_journal_generation(&adapter.context, &adapter.grant.journal_id).unwrap(), Some(generation));
assert_eq!(fixture.host().jobs().unwrap().get(&job.request.job_id).unwrap().state, job.state);
let unchanged: Vec<_> = fixture.sessions.read_all(worker.session_id(), source_segment).unwrap().into_iter().filter(|entry| !matches!(entry, LogEntry::Extension { domain, .. } if domain == "metrics")).collect();
assert_eq!(serde_json::to_value(unchanged).unwrap(), serde_json::to_value(original).unwrap());
assert!(!fixture.host().session_fully_reflected(&fixture.context.session_id).unwrap());
}).await.expect("compaction must finish without recording Job completion");
}
+181
View File
@@ -0,0 +1,181 @@
//! Bounded saved-Journal browser projections. No history fetch, reconciliation or execution.
use super::store::{check_host, entry, fit_page, journal, summary};
use super::*;
use rusqlite::params;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EntryCursor {
scope: String,
subject: String,
journal: Option<String>,
query: Option<String>,
correcting: Option<String>,
created_at: String,
id: String,
}
fn invalid(message: &str) -> JournalError {
JournalError::InvalidInput(message.into())
}
impl SubjektivStore {
pub fn journal_entry_summary(
&self,
host: &JournalHostContext,
journal_id: &str,
entry_id: &str,
) -> JournalResult<JournalEntrySummary> {
self.database.try_with_connection(|tx| {
check_host(self, tx, host, false)?;
journal(tx, host, journal_id, false)?;
summary(tx, entry(tx, journal_id, entry_id)?)
})
}
/// Correction targets are capped at sixteen by the writer contract, and may be in
/// another Journal of this Subject. Include actual Journal IDs rather than guessing.
pub fn journal_correction_targets(
&self,
host: &JournalHostContext,
journal_id: &str,
entry_id: &str,
) -> JournalResult<Vec<JournalEntrySummary>> {
self.database.try_with_connection(|tx| {
check_host(self, tx, host, false)?;
journal(tx, host, journal_id, false)?;
entry(tx, journal_id, entry_id)?;
let mut stmt = tx.prepare(
"SELECT e.journal_id,e.id FROM journal_corrections c
JOIN journal_entries e ON e.subject_id=c.subject_id AND e.id=c.target_id
WHERE c.subject_id=?1 AND c.entry_id=?2 ORDER BY e.created_at,e.id LIMIT 16",
)?;
let rows = stmt
.query_map(params![host.subject_id, entry_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
rows.into_iter()
.map(|(journal_id, id)| summary(tx, entry(tx, &journal_id, &id)?))
.collect()
})
}
/// Recent entries use immutable creation time and ID as a stable descending keyset.
/// Search is literal substring matching (not SQL LIKE wildcards), bounded to 256 bytes.
/// A correcting selector filters saved correction edges, not inferred text or intervals.
pub fn browse_journal_entries(
&self,
host: &JournalHostContext,
journal_id: Option<&str>,
query: Option<&str>,
correcting: Option<&str>,
after: Option<&str>,
limit: usize,
) -> JournalResult<JournalPage<JournalEntrySummary>> {
if !(1..=MAX_JOURNAL_PAGE).contains(&limit) {
return Err(invalid("page limit must be 1..32"));
}
if query.is_some_and(|q| q.len() > 256 || q.chars().any(char::is_control)) {
return Err(invalid(
"search query must be at most 256 bytes without controls",
));
}
if correcting.is_some() && journal_id.is_none() {
return Err(invalid("correction lookup requires a Journal"));
}
let cursor: Option<EntryCursor> = after
.map(|raw| {
if raw.len() > 4096 {
return Err(invalid("entry cursor exceeds byte limit"));
}
let cursor: EntryCursor =
serde_json::from_str(raw).map_err(|_| invalid("invalid entry cursor"))?;
if cursor.scope != host.scope_id
|| cursor.subject != host.subject_id
|| cursor.journal.as_deref() != journal_id
|| cursor.query.as_deref() != query
|| cursor.correcting.as_deref() != correcting
{
return Err(invalid(
"entry cursor belongs to another scope, Subject or filter",
));
}
Ok(cursor)
})
.transpose()?;
self.database.try_with_connection(|tx| {
check_host(self, tx, host, false)?;
if let Some(id) = journal_id {
journal(tx, host, id, false)?;
}
if let Some(id) = correcting {
entry(tx, journal_id.unwrap(), id)?;
}
let mut stmt = tx.prepare(
"SELECT e.journal_id,e.id,e.created_at FROM journal_entries e
WHERE e.subject_id=?1 AND (?4 IS NOT NULL OR ?2 IS NULL OR e.journal_id=?2)
AND (?3 IS NULL OR instr(lower(e.title),lower(?3))>0
OR instr(lower(e.body),lower(?3))>0 OR instr(lower(e.ongoing),lower(?3))>0)
AND (?4 IS NULL OR EXISTS(SELECT 1 FROM journal_corrections c
WHERE c.subject_id=?1 AND c.entry_id=e.id AND c.target_id=?4))
AND (?5 IS NULL OR (e.created_at,e.id)<(?5,?6))
ORDER BY e.created_at DESC,e.id DESC LIMIT ?7",
)?;
let rows = stmt
.query_map(
params![
host.subject_id,
journal_id,
query,
correcting,
cursor.as_ref().map(|c| &c.created_at),
cursor.as_ref().map(|c| &c.id),
limit + 1
],
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
},
)?
.collect::<Result<Vec<_>, _>>()?;
let more = rows.len() > limit;
let mut items = Vec::new();
for (journal_id_value, id, created_at) in rows.into_iter().take(limit) {
let next = serde_json::to_string(&EntryCursor {
scope: host.scope_id.clone(),
subject: host.subject_id.clone(),
journal: journal_id.map(str::to_owned),
query: query.map(str::to_owned),
correcting: correcting.map(str::to_owned),
created_at,
id: id.clone(),
})?;
items.push((next, summary(tx, entry(tx, &journal_id_value, &id)?)?));
}
fit_page(items, more)
})
}
/// True only for admitted source bytes which the durable checkpoint has not reflected.
/// False does not claim that all original Session activity was admitted or recorded.
pub fn journal_has_unreflected_sources(
&self,
host: &JournalHostContext,
journal_id: &str,
) -> JournalResult<bool> {
self.database.try_with_connection(|tx| {
check_host(self, tx, host, false)?;
journal(tx, host, journal_id, false)?;
Ok(tx.query_row(
"SELECT EXISTS(SELECT 1 FROM journal_sources s, json_each(s.accepted_json) b
WHERE s.journal_id=?1 AND json_extract(b.value,'$.end')>s.reflected_bytes)",
[journal_id],
|r| r.get(0),
)?)
})
}
}
+1
View File
@@ -40,6 +40,7 @@
pub mod binding;
pub use binding::JournalWriterBinding;
pub(crate) use binding::migrate as migrate_binding;
pub mod browser;
mod lifecycle;
pub mod memory_links;
pub(crate) use lifecycle::migrate as migrate_lifecycle;
+29 -4
View File
@@ -1,5 +1,9 @@
//! Read-only, bounded Journal context for a new body Session. Selection and text projection
//! share one storage transaction; no source history, Job, admission or checkpoint is consulted.
//! Read-only, bounded Journal context at body startup and compaction boundaries.
//! Selection, text projection, references and processing checkpoints share one storage
//! transaction. Reads neither consult source history/Job liveness nor mutate recording.
//! Startup prefers the Host-retained predecessor; compaction requires a live body writer
//! and prefers its current Session, without waiting for Idle or recording completion.
//! Both supplement only from the same Subject under the same independent budgets.
//! This is derived reference material, never a new observation or an execution capability.
use super::*;
@@ -71,11 +75,32 @@ impl SubjektivStore {
&self,
host: &JournalHostContext,
previous_session: Option<&str>,
) -> JournalResult<JournalStartupSnapshot> {
self.context_snapshot(host, previous_session, false)
}
/// Read the saved current-Session Journal without reconciling or advancing it.
/// The active writer check fences released body lifetimes, not non-Idle execution.
pub fn journal_compaction_snapshot(
&self,
host: &JournalHostContext,
) -> JournalResult<JournalStartupSnapshot> {
let session = host.attribution.as_ref().ok_or_else(|| {
JournalError::PermissionDenied("compaction requires a Host-bound body Session".into())
})?;
self.context_snapshot(host, Some(&session.session_id), true)
}
fn context_snapshot(
&self,
host: &JournalHostContext,
preferred_session: Option<&str>,
require_current_writer: bool,
) -> JournalResult<JournalStartupSnapshot> {
self.database.try_transaction(|tx| {
super::store::check_host(self, tx, host, false)?;
super::store::check_host(self, tx, host, require_current_writer)?;
let mut ids = Vec::new();
if let Some(session) = previous_session {
if let Some(session) = preferred_session {
let previous: Option<(String, String)> = tx.query_row(
"SELECT id,subject_id FROM journals WHERE source_session_id=?1",
[session], |r| Ok((r.get(0)?, r.get(1)?)),
+5 -2
View File
@@ -70,7 +70,10 @@ fn page_limit(limit: usize) -> JournalResult<usize> {
}
Ok(limit)
}
fn fit_page<T: Serialize>(rows: Vec<(String, T)>, mut more: bool) -> JournalResult<JournalPage<T>> {
pub(super) fn fit_page<T: Serialize>(
rows: Vec<(String, T)>,
mut more: bool,
) -> JournalResult<JournalPage<T>> {
let mut page = JournalPage {
items: Vec::new(),
next: None,
@@ -162,7 +165,7 @@ pub(super) fn journal(
}
Ok(j)
}
fn entry(tx: &Connection, journal_id: &str, id: &str) -> JournalResult<JournalEntry> {
pub(super) fn entry(tx: &Connection, journal_id: &str, id: &str) -> JournalResult<JournalEntry> {
let row = tx.query_row("SELECT id, journal_id, position, state, title, body, ongoing, source_end_json, created_at, updated_at, fixed_at FROM journal_entries WHERE journal_id=?1 AND id=?2", params![journal_id, id], |r| Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,u64>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?,r.get::<_,String>(6)?,r.get::<_,Option<String>>(7)?,r.get::<_,String>(8)?,r.get::<_,String>(9)?,r.get::<_,Option<String>>(10)?))).optional()?.ok_or_else(|| JournalError::NotFound(id.into()))?;
let mut stmt = tx.prepare(
"SELECT target_id FROM journal_corrections WHERE entry_id=?1 ORDER BY target_id",
+100
View File
@@ -100,6 +100,10 @@ fn released_binding_preserves_open_and_receipts_but_never_revives_old_session_wr
f.store().release_journal_writer(&binding).unwrap();
f.store().release_journal_writer(&binding).unwrap();
f.reopen();
assert!(matches!(
f.store().journal_compaction_snapshot(&f.host),
Err(JournalError::PermissionDenied(_))
));
let old = f
.store()
.query_journal_operation(&f.host, &f.journal_id, "create")
@@ -409,6 +413,102 @@ fn release_and_late_admission_are_serialized_by_the_persisted_binding_check() {
manager.shutdown().unwrap();
}
#[test]
fn compaction_snapshot_prioritizes_current_session_without_waiting_or_mutating_recording() {
let f = Fixture::new();
let (generation, context) = f.generation("in-flight", f.ranges.clone());
f.execute(
&context,
"open",
open_action(&"日本語\\\"\\n".repeat(6000)),
f.ranges.clone(),
);
f.execute(
&context,
"position",
JournalAction::UpdatePosition {
current_position: "partial activity; checkpoint still behind".into(),
},
vec![],
);
let mut latest = String::new();
for n in 0..5 {
let session = format!("supplement-{n}");
let host = session_host(f.store(), &f.host.subject_id, &session);
f.store()
.accept_journal_sources(&host, "recent", &[source_range(&session, 0, 10)])
.unwrap();
latest = session;
}
let reader = JournalHostContext::validated_reader(f.store(), &f.host.subject_id).unwrap();
let startup = f
.store()
.journal_startup_snapshot(&reader, Some(&latest))
.unwrap();
assert_eq!(startup.journals[0].source_session_id, latest);
let before = f.state();
let snapshot = f.store().journal_compaction_snapshot(&f.host).unwrap();
assert_eq!(snapshot.journals[0].source_session_id, "source-session");
assert_eq!(snapshot.journals[0].process_order, before.0.process_order);
assert_eq!(snapshot.journals[0].process_byte, before.0.process_byte);
assert_eq!(
snapshot.journals[0].entries[0].state,
JournalEntryState::Open
);
assert!(snapshot.journals[0].entries[0].omitted);
assert!(snapshot.omitted);
assert_eq!(snapshot.journals.len(), 3);
assert!(serde_json::to_vec(&snapshot).unwrap().len() <= startup::STARTUP_MAX_BYTES);
assert_eq!(
f.store().journal_compaction_snapshot(&f.host).unwrap(),
snapshot
);
assert_eq!(f.state(), before);
assert_eq!(
f.store()
.active_journal_generation(&f.host, &f.journal_id)
.unwrap(),
Some(generation)
);
assert!(matches!(
f.store().journal_compaction_snapshot(&reader),
Err(JournalError::PermissionDenied(_))
));
f.execute(
&context,
"new-position",
JournalAction::UpdatePosition {
current_position: "background recording progressed after snapshot".into(),
},
vec![],
);
let refreshed = f.store().journal_compaction_snapshot(&f.host).unwrap();
assert_ne!(
refreshed.journals[0].state_token,
snapshot.journals[0].state_token
);
assert_ne!(
refreshed.journals[0].current_position,
snapshot.journals[0].current_position
);
let empty_host = session_host(f.store(), &f.host.subject_id, "not-yet-admitted");
assert!(
!f.store()
.journal_compaction_snapshot(&empty_host)
.unwrap()
.journals
.is_empty()
);
let other = f
.store()
.create_subject_with_behavior(SubjectRole::new("other").unwrap(), "unchanged".into())
.unwrap();
let other_host = session_host(f.store(), &other.id, "other-session");
let empty = f.store().journal_compaction_snapshot(&other_host).unwrap();
assert!(empty.journals.is_empty());
assert!(!empty.omitted);
}
#[test]
fn startup_snapshot_distinguishes_none_is_subject_local_and_bounds_utf8_at_storage() {
let f = Fixture::new();
@@ -21,6 +21,12 @@ pub enum JournalRecordingReason {
WorkerRestored,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JournalCompactionRequest {
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JournalRecordingRequest {
@@ -3,6 +3,12 @@
//! Hooks request Host reconciliation, never infer source ranges from in-memory history.
//! Hosts retain exact operation inputs/receipts and validate live Job authority on every
//! operation. Successful operations are never rolled back by Job finalization.
//!
//! Compaction freezes a read-only Host snapshot independently of admission/Job progress.
//! Its rendered reference is shared by mandatory compactor input and the replacement seed;
//! no restore/turn hook refreshes it. Previous references are only historical compactor
//! material so fallback can recover information intentionally omitted from an earlier
//! complementary summary, without accumulating stale references in the next segment.
use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
@@ -79,6 +85,60 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource {
}
}
/// Read once without waiting for recording. The returned body is immutable mandatory
/// compactor input and the exact replacement-segment reference, including fallback status.
/// Selection and authorization belong to the Host, not the core compaction engine.
pub(crate) fn freeze_compaction_journal(
connection: std::io::Result<Option<SubjektivHostConnection>>,
prompts: &crate::prompt::catalog::PromptCatalog,
) -> Result<String, crate::prompt::catalog::CatalogError> {
let workspace_id = connection.as_ref().ok().and_then(|connection| {
connection
.as_ref()
.and_then(|connection| connection.host.workspace_id())
});
let read = match connection {
Ok(Some(connection)) if connection.host.journal_compaction_granted() => {
Some(connection.journal_compaction_context().map_err(|_| ()))
}
Ok(_) => None,
Err(_) => Some(Err(())),
};
let (status, json) = match read {
None => ("disabled", String::new()),
Some(Ok(snapshot)) => {
let status = if snapshot.journals.is_empty() {
"absent"
} else {
"available"
};
match serde_json::to_string(&snapshot) {
Ok(json) if json.len() <= subjektiv::journal::startup::STARTUP_MAX_BYTES => {
(status, json)
}
_ => ("unavailable", String::new()),
}
}
Some(Err(())) => ("unavailable", String::new()),
};
if status == "unavailable" {
tracing::warn!("Journal compaction snapshot unavailable; continuing without Journal");
}
use sha2::{Digest, Sha256};
let snapshot_sha256: Option<String> = (!json.is_empty()).then(|| {
Sha256::digest(json.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
});
prompts.subjektiv_journal_compaction(
status,
&json,
snapshot_sha256.as_deref(),
workspace_id.as_deref(),
)
}
#[derive(Clone)]
pub struct JournalRecordingFeature {
host: SubjektivHostConnection,
@@ -175,6 +175,26 @@ impl WorkerControlService for WorkspaceWorkerControlService {
"invalid Workspace control response: missing items".to_string(),
)
})?;
for item in items.iter_mut() {
let subject = item.get_mut("subject").ok_or_else(|| {
WorkspaceClientError::Request(
"invalid Workspace control response: missing subject".to_string(),
)
})?;
let runtime: server_api::WorkerControlSubject = serde_json::from_value(subject.take())
.map_err(|error| {
WorkspaceClientError::Request(format!(
"invalid Workspace control subject: {error}"
))
})?;
// Backend lists only Runtime subjects; Tools use a tagged union so
// callers can pass the observed subject unchanged to control tools.
*subject = serde_json::json!({
"kind": "runtime_worker",
"runtime_id": runtime.runtime_id,
"worker_id": runtime.worker_id,
});
}
let mut seen = std::collections::HashSet::new();
items.retain(|item| {
item.get("subject")
@@ -1318,7 +1338,6 @@ mod tests {
"items": [
{
"subject": {
"kind": "runtime_worker",
"runtime_id": "runtime-a",
"worker_id": "worker-a"
},
@@ -1326,7 +1345,6 @@ mod tests {
},
{
"subject": {
"kind": "runtime_worker",
"runtime_id": "runtime-a",
"worker_id": "worker-a"
},
@@ -1664,7 +1682,7 @@ mod tests {
}
#[tokio::test]
async fn control_service_deduplicates_worker_list_by_stable_subject() {
async fn control_service_projects_runtime_subject_and_deduplicates_worker_list() {
let client = Arc::new(RecordingWorkspaceClient::default());
let control = WorkspaceWorkerControlService {
client: client.clone(),
+22
View File
@@ -103,6 +103,7 @@ pub enum WorkerPrompt {
ResidentMemoryRestoreSection,
SubjektivResidentContext,
SubjektivJournalStartup,
SubjektivJournalCompaction,
WorkerOrchestrationGuidanceSection,
SubWorkerSpawnToolDescription,
}
@@ -128,6 +129,7 @@ impl WorkerPrompt {
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
Self::SubjektivJournalStartup => "internal.subjektiv_journal_startup",
Self::SubjektivJournalCompaction => "internal.subjektiv_journal_compaction",
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section"
@@ -154,6 +156,7 @@ impl WorkerPrompt {
WorkerPrompt::ResidentMemoryRestoreSection,
WorkerPrompt::SubjektivResidentContext,
WorkerPrompt::SubjektivJournalStartup,
WorkerPrompt::SubjektivJournalCompaction,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::SubWorkerSpawnToolDescription,
];
@@ -543,6 +546,25 @@ impl PromptCatalog {
},
)
}
pub(crate) fn subjektiv_journal_compaction(
&self,
status: &str,
snapshot_json: &str,
snapshot_sha256: Option<&str>,
workspace_id: Option<&str>,
) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivJournalCompaction,
minijinja::context! {
status => status,
snapshot_json => snapshot_json,
snapshot_sha256 => snapshot_sha256,
workspace_id => workspace_id,
source_digest => self.projection.source_digest,
projection_digest => self.projection.catalog_digest,
},
)
}
pub fn subjektiv_journal_startup(&self, snapshot_json: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivJournalStartup,
+3 -3
View File
@@ -736,9 +736,9 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance
}
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
WorkerHistoryProvenance::CompactionSummary | WorkerHistoryProvenance::DerivedSummary => {
SessionEntryProvenance::DerivedSummary
}
WorkerHistoryProvenance::CompactionSummary
| WorkerHistoryProvenance::DerivedSummary
| WorkerHistoryProvenance::JournalContext => SessionEntryProvenance::DerivedSummary,
WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
+11 -4
View File
@@ -156,10 +156,17 @@ pub(crate) fn restore_history_entries(
}
*target = from_logged(entry);
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
)),
LogEntry::AnnotatedSystemItem { entry, .. } => {
history.push(HistoryEntry::new(entry.item.to_history_item(), {
let mut metadata = entry.metadata.clone();
// Typed startup records predate the dedicated Journal origin.
// Classify only this trusted event, never a plaintext marker.
if matches!(entry.item, session_store::SystemItem::JournalStartup { .. }) {
metadata.origin = WorkerHistoryProvenance::JournalContext;
}
metadata
}))
}
_ => {}
}
}
+47
View File
@@ -80,6 +80,17 @@ pub trait SubjektivHost: Send + Sync + 'static {
"Journal startup not granted".into(),
))
}
fn journal_compaction_granted(&self) -> bool {
false
}
fn journal_compaction_context(
&self,
_context: &SubjektivHostContext,
) -> Result<JournalStartupSnapshot, SubjektivHostError> {
Err(SubjektivHostError::Unavailable(
"Journal compaction not granted".into(),
))
}
/// Recording admission is separate from both Memory and Journal Job operation grants.
fn journal_recording_granted(&self) -> bool {
false
@@ -267,6 +278,29 @@ impl SubjektivHost for BackendSubjektivHost {
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
)
}
fn journal_compaction_granted(&self) -> bool {
true
}
fn journal_compaction_context(
&self,
context: &SubjektivHostContext,
) -> Result<JournalStartupSnapshot, SubjektivHostError> {
decode(
self.client
.execute_with_timeout(
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/journal/compaction", self.workspace_id),
serde_json::to_string(&transport::JournalCompactionRequest {
session_id: context.session_id.clone(),
})
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
),
Duration::from_secs(5),
)
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
)
}
fn journal_recording_granted(&self) -> bool {
// This adapter exists only for explicitly enabled, authenticated Host connections.
// The Backend still verifies the main Worker/Subject binding on every request.
@@ -488,6 +522,19 @@ impl SubjektivHostConnection {
}
self.host.journal_startup_context(&self.context)
}
pub fn journal_compaction_granted(&self) -> bool {
self.host.journal_compaction_granted()
}
pub fn journal_compaction_context(&self) -> Result<JournalStartupSnapshot, SubjektivHostError> {
if !self.host.journal_compaction_granted() {
return Err(SubjektivHostError::Unavailable(
"Journal compaction not granted".into(),
));
}
self.host.journal_compaction_context(&self.context)
}
pub fn new(
host: Arc<dyn SubjektivHost>,
context: SubjektivHostContext,
+58 -4
View File
@@ -6918,6 +6918,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// within `retained_tokens`. Item-granular, turn boundaries ignored.
let cut = self.split_for_retained(retained_tokens);
// Freeze one Host-owned read before starting the compactor. Background recording
// remains independent; never re-read when building the replacement segment.
let journal_context = crate::feature::builtin::journal::freeze_compaction_journal(
self.subjektiv_host_connection(),
&self.prompts.load_full(),
)
.map_err(WorkerError::PromptCatalog)?;
let history_entries = self.session.history().entries();
let retain_from = cut.index.min(history_entries.len());
let initial_summary = initial_compaction_summary(history_entries)?;
@@ -6926,7 +6933,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// it as well would duplicate inherited context on every compact.
let retained_history_entries = history_entries[retain_from..]
.iter()
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
.filter(|entry| {
!matches!(
entry.annotation.origin,
WorkerHistoryProvenance::CompactionSummary
| WorkerHistoryProvenance::JournalContext
)
})
.cloned()
.collect::<Vec<_>>();
let mut retained_items = retained_history_entries
@@ -6943,7 +6956,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// never needs durable image bodies; relevant images are nominated with add_reference.
let mut entries_to_summarise = history_entries[..retain_from]
.iter()
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
.filter(|entry| {
!matches!(
entry.annotation.origin,
WorkerHistoryProvenance::CompactionSummary
| WorkerHistoryProvenance::JournalContext
)
})
.cloned()
.collect::<Vec<_>>();
project_compactor_capture_attachments(&mut entries_to_summarise);
@@ -7013,7 +7032,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// The full Host summary is mandatory input, separate from the bounded
// prefix overview and current TaskStore snapshot.
let task_snapshot_text = self.task_feature.snapshot_text();
let summary_input = build_summary_input(
let mut summary_input = build_summary_input(
&items_to_summarise,
initial_summary.as_deref(),
&default_refs,
@@ -7025,6 +7044,28 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
summary_target_tokens,
},
);
// Previous references are historical source material, not additional latest
// Journals. In particular, a failed/empty read must not silently lose context
// that the previous summary intentionally delegated to its injected Journal.
// Carry them only into the compactor, never alongside the new reference seed.
for entry in history_entries
.iter()
.filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::JournalContext)
{
if let Some(body) = entry.item.as_text() {
summary_input.text.push_str(
"\n\n## Previous Journal reference (historical, not automatically retained)\n",
);
summary_input.text.push_str(body);
summary_input
.text
.push_str("\n## End previous Journal reference\n");
}
}
// Mandatory context, outside overview truncation. The internal Worker's normal
// request budget checks include this exact body and the full previous summary.
summary_input.text.push_str("\n\n");
summary_input.text.push_str(&journal_context);
if summary_input.warning_exceeded {
self.alert(
AlertLevel::Warn,
@@ -7340,6 +7381,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
crate::session_history::COMPACTION_SUMMARY_HEADER
));
compact_introduced_system_messages.push(summary_message.clone());
let journal_message = Item::system_message(journal_context);
compact_introduced_system_messages.push(journal_message.clone());
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
if let Some(msg) = reference_message.as_ref() {
compact_introduced_system_messages.push(msg.clone());
@@ -7352,6 +7395,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
compact_introduced_system_messages.push(task_snapshot_message.clone());
new_history.push(summary_message);
new_history.push(journal_message);
new_history.extend(auto_read_messages);
if let Some(msg) = reference_message {
new_history.push(msg);
@@ -7407,6 +7451,16 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
}),
),
)
} else if index == 1 {
HistoryEntry::new(
item,
new_history_metadata(
WorkerHistoryProvenance::JournalContext,
Some(SessionHistoryDerivation {
sources: derived_sources.clone(),
}),
),
)
} else if let Some((offset, original)) = original_entries[original_cursor..]
.iter()
.enumerate()
@@ -16732,7 +16786,7 @@ permission = "write"
assert_eq!(replay[0].item.as_text(), Some("PRIOR_JOURNAL_REFERENCE"));
assert_eq!(
replay[0].annotation.origin,
WorkerHistoryProvenance::DerivedSummary
WorkerHistoryProvenance::JournalContext
);
let view =
crate::session_capture::SessionCapture::from_history_entries("startup", replay);
+776 -1
View File
@@ -2086,6 +2086,27 @@ struct SummaryInputFixture {
impl SummaryInputFixture {
async fn new(manifest_toml: &str, client: MockClient) -> Self {
Self::with_manifest(
worker::WorkerManifest::from_toml(manifest_toml).unwrap(),
client,
)
.await
}
async fn with_journal(
manifest_toml: &str,
client: MockClient,
host: Arc<CompactionJournalHost>,
) -> Self {
let mut manifest = worker::WorkerManifest::from_toml(manifest_toml).unwrap();
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = false;
let mut fixture = Self::with_manifest(manifest, client).await;
fixture.worker.bind_subjektiv_host(Some(host));
fixture
}
async fn with_manifest(manifest: worker::WorkerManifest, client: MockClient) -> Self {
let store_dir = tempfile::tempdir().unwrap();
let store = CombinedStore::new(
FsStore::new(store_dir.path()).unwrap(),
@@ -2096,7 +2117,7 @@ impl SummaryInputFixture {
client,
);
let mut worker = Worker::new(
worker::WorkerManifest::from_toml(manifest_toml).unwrap(),
manifest,
engine,
store,
worker::WorkerWorkspaceContext::no_workspace(),
@@ -2120,6 +2141,16 @@ impl SummaryInputFixture {
&mut self,
history: Vec<session_store::LoggedHistoryEntry>,
compacted: bool,
) {
self.restore_seed_with_system(history, compacted, vec![])
.await;
}
async fn restore_seed_with_system(
&mut self,
history: Vec<session_store::LoggedHistoryEntry>,
compacted: bool,
systems: Vec<session_store::LoggedSystemHistoryEntry>,
) {
self.worker
.run_text("fixture rewind boundary")
@@ -2150,6 +2181,16 @@ impl SummaryInputFixture {
}
_ => unreachable!(),
}
records.splice(
1..1,
systems
.into_iter()
.map(|entry| LogEntry::AnnotatedSystemItem {
ts: 1,
entry,
extensions: vec![],
}),
);
self.worker
.store()
.create_segment(session_id, segment_id, &records)
@@ -2801,3 +2842,737 @@ async fn budget_warning_is_durable_and_keeps_full_summary_in_first_model_request
if alert.message.contains("compact worker context budget is low")))
);
}
const COMPACTION_HOST_WORKSPACE_ID: &str = "journal-transport-host-workspace";
// Public compact/rewind -> real internal Worker, prompt catalog, and FsStore.
// Only the authenticated Host and LLM provider are replaced. This does not test
// Host authorization/selection or live-model semantic continuation quality.
struct CompactionJournalHost {
granted: bool,
unavailable: bool,
snapshot: Mutex<subjektiv::journal::startup::JournalStartupSnapshot>,
reads: Mutex<Vec<worker::subjektiv::SubjektivHostContext>>,
startup_reads: AtomicUsize,
}
impl CompactionJournalHost {
fn new(snapshot: subjektiv::journal::startup::JournalStartupSnapshot) -> Self {
Self {
granted: true,
unavailable: false,
snapshot: Mutex::new(snapshot),
reads: Mutex::new(vec![]),
startup_reads: AtomicUsize::new(0),
}
}
}
impl worker::subjektiv::SubjektivHost for CompactionJournalHost {
fn settings(&self) -> worker::subjektiv::SubjektivHostSettings {
worker::subjektiv::SubjektivHostSettings {
language: "English".into(),
}
}
fn workspace_id(&self) -> Option<String> {
Some(COMPACTION_HOST_WORKSPACE_ID.into())
}
fn journal_compaction_granted(&self) -> bool {
self.granted
}
fn journal_compaction_context(
&self,
context: &worker::subjektiv::SubjektivHostContext,
) -> Result<
subjektiv::journal::startup::JournalStartupSnapshot,
worker::subjektiv::SubjektivHostError,
> {
self.reads.lock().unwrap().push(context.clone());
if self.unavailable {
return Err(worker::subjektiv::SubjektivHostError::Unavailable(
"injected Journal read failure".into(),
));
}
Ok(self.snapshot.lock().unwrap().clone())
}
fn journal_startup_granted(&self) -> bool {
true
}
fn journal_startup_context(
&self,
_: &worker::subjektiv::SubjektivHostContext,
) -> Result<
subjektiv::journal::startup::JournalStartupSnapshot,
worker::subjektiv::SubjektivHostError,
> {
self.startup_reads.fetch_add(1, Ordering::SeqCst);
Err(worker::subjektiv::SubjektivHostError::Unavailable(
"startup must not be read during compaction/rewind/normal turns".into(),
))
}
fn memory(
&self,
_: &worker::subjektiv::SubjektivHostContext,
_: server_api::SubjektivMemoryBackendOperation,
) -> Result<server_api::SubjektivMemoryBackendResponse, worker::subjektiv::SubjektivHostError>
{
Err(worker::subjektiv::SubjektivHostError::Unavailable(
"not used".into(),
))
}
fn session(
&self,
_: &worker::subjektiv::SubjektivHostContext,
_: server_api::SubjektivSessionBackendOperation,
) -> Result<server_api::SubjektivSessionBackendResponse, worker::subjektiv::SubjektivHostError>
{
Err(worker::subjektiv::SubjektivHostError::Unavailable(
"not used".into(),
))
}
fn stage_candidate(
&self,
_: &worker::subjektiv::SubjektivHostContext,
_: memory::backend::MemoryStageCandidateOperation,
) -> Result<server_api::SubjektivStageCandidateResponse, worker::subjektiv::SubjektivHostError>
{
Err(worker::subjektiv::SubjektivHostError::Unavailable(
"not used".into(),
))
}
fn record_session(
&self,
_: &worker::subjektiv::SubjektivHostContext,
_: bool,
) -> Result<server_api::SubjektivRecordSessionResponse, worker::subjektiv::SubjektivHostError>
{
Err(worker::subjektiv::SubjektivHostError::Unavailable(
"not used".into(),
))
}
fn request_consolidation(
&self,
_: &worker::subjektiv::SubjektivHostContext,
_: memory::backend::MemoryConsolidateStagingOperation,
) -> Result<memory::backend::MemoryConsolidationOutput, worker::subjektiv::SubjektivHostError>
{
Err(worker::subjektiv::SubjektivHostError::Unavailable(
"not used".into(),
))
}
}
fn compaction_snapshot(generation: usize) -> subjektiv::journal::startup::JournalStartupSnapshot {
use subjektiv::journal::startup::{JournalStartupSnapshot, StartupEntry, StartupJournal};
JournalStartupSnapshot {
subject_id: "compaction-subject".into(),
omitted: true,
journals: vec![StartupJournal {
journal_id: format!("journal-reference-{generation}"),
source_session_id: "current-source-session".into(),
state_token: format!("state-token-{generation}"),
process_order: 7 + generation as u64,
process_byte: 23 + generation as u64,
current_position: format!(
"JOURNAL_POSITION_{generation}: work ongoing, result not verified\nExact JSON bytes: 日本語, \"quoted\", \\path"
),
entries: vec![StartupEntry {
entry_id: format!("fixed-event-reference-{generation}"),
position: 2,
state: subjektiv::journal::JournalEntryState::Fixed,
title: "An event ended, not the entire conversation".into(),
body: format!("JOURNAL_BODY_{generation}"),
ongoing: "User instructions after the checkpoint are not recorded".into(),
source_end: Some(subjektiv::journal::JournalSourcePointer {
segment_id: session_store::new_segment_id(),
entry_ref: "E3".into(),
}),
correction_refs: vec![format!("correction-reference-{generation}")],
omitted: false,
}],
omitted: true,
}],
}
}
fn journal_body(worker: &Worker<MockClient, TestStore>) -> String {
let seed = replacement_seed(worker);
assert_eq!(
seed[1].metadata.origin,
session_store::LoggedSessionHistoryOrigin::JournalContext
);
let item = Item::from(seed[1].item.clone());
assert!(matches!(
item,
Item::Message {
role: agen::Role::System,
..
}
));
assert_eq!(
seed.iter()
.filter(|entry| entry.metadata.origin
== session_store::LoggedSessionHistoryOrigin::JournalContext)
.count(),
1
);
item.as_text().unwrap().to_string()
}
fn journal_source_records(
store: &TestStore,
session: session_store::SessionId,
segment: session_store::SegmentId,
) -> serde_json::Value {
// Compaction appends telemetry; the source material and typed startup
// records must remain equivalent, not rewritten or removed.
let records = store
.read_all(session, segment)
.unwrap()
.into_iter()
.filter(
|record| !matches!(record, LogEntry::Extension { domain, .. } if domain == "metrics"),
)
.collect::<Vec<_>>();
serde_json::to_value(records).unwrap()
}
fn assert_frozen_journal_transport(input: &str, body: &str) {
assert!(
input.ends_with(body),
"the actual compactor user input ends with the frozen reference"
);
assert_eq!(input.matches(body).count(), 1);
}
fn assert_journal_provenance(
body: &str,
expected_json: Option<&str>,
catalog: &worker::PromptCatalog,
workspace_id: Option<&str>,
) {
use sha2::{Digest, Sha256};
let projection = catalog.projection();
assert!(body.contains(&format!(
"Prompt provenance: `internal.subjektiv_journal_compaction`, source `{}`, projection `{}`.",
projection.source_digest, projection.catalog_digest,
)));
if let Some(workspace_id) = workspace_id {
assert!(body.contains(&format!("Host Workspace: `{workspace_id}`.")));
} else {
assert!(!body.contains("Host Workspace:"));
}
if let Some(expected_json) = expected_json {
// Hash the actual rendered JSON, including escapes and UTF-8 bytes,
// not a parsed/re-serialized or pretty-printed approximation.
let rendered_json = body.lines().rfind(|line| !line.is_empty()).unwrap();
assert_eq!(rendered_json.as_bytes(), expected_json.as_bytes());
let expected_hash = Sha256::digest(rendered_json.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let declared_hash = body
.lines()
.find_map(|line| {
line.strip_prefix("Snapshot SHA-256 (the exact JSON bytes below): `")
.and_then(|value| value.strip_suffix("`."))
})
.expect("snapshot SHA-256 must be persisted in the same rendered body");
assert_eq!(declared_hash, expected_hash);
} else {
assert!(
!body.contains("Snapshot SHA-256"),
"failed/disabled reads must not invent a snapshot hash"
);
}
}
#[tokio::test]
async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tail_tasks_and_source()
{
use session_store::LoggedSessionHistoryOrigin::{
CompactionSummary, DerivedSummary, JournalContext,
};
let initial = long_summary("journal-initial");
let summaries = (0..3)
.map(|index| long_summary(&format!("journal-next-{index}")))
.collect::<Vec<_>>();
let mut responses = vec![single_text_events("fixture")];
for (index, summary) in summaries.iter().enumerate() {
responses.push(single_text_events(&format!("TAIL_ASSISTANT_{index}")));
responses.push(write_summary_tool_use_events(
&format!("journal-summary-{index}"),
summary,
));
responses.push(single_text_events("done"));
}
responses.extend([
single_text_events("normal after compact"),
single_text_events("after rewind"),
]);
let client = MockClient::new(responses);
let observed = client.clone();
let host = Arc::new(CompactionJournalHost::new(compaction_snapshot(0)));
let mut fixture =
SummaryInputFixture::with_journal(MANUAL_ONLY_MANIFEST_TOML, client, host.clone()).await;
// A resolved nonempty source digest distinguishes provenance forwarding
// from silently substituting the built-in catalog's empty source digest.
let mut projection = fixture.worker.prompts().load_full().projection().clone();
projection.source_digest = "a".repeat(64);
let initial_catalog = Arc::new(worker::PromptCatalog::from_projection(projection).unwrap());
fixture.worker.prompts().store(initial_catalog.clone());
let legacy_startup = session_store::LoggedSystemHistoryEntry {
item: session_store::SystemItem::JournalStartup {
body: "OLD_TYPED_STARTUP_JOURNAL".into(),
snapshot: serde_json::json!({"journals": []}),
snapshot_sha256: "legacy-startup-digest".into(),
prompt_provenance: session_store::PromptRenderProvenance {
workspace_id: None,
source_digest: "legacy-source".into(),
projection_digest: "legacy-projection".into(),
logical_name: "internal.subjektiv_journal_startup".into(),
},
},
metadata: summary_entry(Item::system_message(""), DerivedSummary).metadata,
};
let legacy_id = legacy_startup.metadata.entry_id.clone();
fixture
.restore_seed_with_system(
vec![
summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{initial}")),
CompactionSummary,
),
summary_entry(
Item::system_message("OLD_COMPACTION_JOURNAL"),
JournalContext,
),
annotated(Item::tool_call(
"seed-task",
"TaskCreate",
r#"{"subject":"JOURNAL_TASK","description":"Keep the unfinished task"}"#,
)),
annotated(Item::tool_result("seed-task", "created")),
],
true,
vec![legacy_startup],
)
.await;
assert_eq!(
fixture
.worker
.session_history()
.entries()
.iter()
.find(|entry| entry.annotation.entry_id == legacy_id)
.unwrap()
.annotation
.origin,
JournalContext
);
let session = fixture.worker.session_id();
let original_segment = fixture.worker.segment_id();
let mut historical_sources = Vec::new();
let mut old_bodies = vec![
"OLD_TYPED_STARTUP_JOURNAL".to_string(),
"OLD_COMPACTION_JOURNAL".to_string(),
];
for (index, retained) in [0, 1_000_000, 100].into_iter().enumerate() {
let directive = format!(
"UNRECORDED_USER_{index}: do not merge; original completion conditions still apply"
);
fixture.worker.run_text(&directive).await.unwrap();
if index == 0 {
assert_eq!(
fixture
.worker
.session_history()
.entries()
.iter()
.find(|entry| entry.annotation.entry_id == legacy_id)
.unwrap()
.annotation
.origin,
JournalContext,
"legacy startup classification survives a normal turn"
);
}
assert_eq!(
host.reads.lock().unwrap().len(),
index,
"normal turns do not fetch compaction Journal"
);
let snapshot = compaction_snapshot(index);
*host.snapshot.lock().unwrap() = snapshot.clone();
let first_request = observed.requests().len();
let updating_host = host.clone();
observed.set_observer(move |call| {
if call == first_request {
// A background update at the provider boundary, after the
// request has been captured but before generation completes.
*updating_host.snapshot.lock().unwrap() = compaction_snapshot(100 + index);
}
});
let source = fixture.worker.segment_id();
let frozen_catalog = fixture.worker.prompts().load_full();
historical_sources.push((
source,
journal_source_records(fixture.worker.store(), session, source),
));
fixture.worker.compact(retained).await.unwrap();
let input = first_compactor_input(&observed.requests()[first_request]);
assert_full_initial_summary(
&input,
if index == 0 {
&initial
} else {
&summaries[index - 1]
},
);
let body = journal_body(&fixture.worker);
assert_frozen_journal_transport(&input, &body);
assert_journal_provenance(
&body,
Some(&serde_json::to_string(&snapshot).unwrap()),
&frozen_catalog,
Some(COMPACTION_HOST_WORKSPACE_ID),
);
assert!(
body.contains(&serde_json::to_string(&snapshot).unwrap()),
"contents, references, fixed source_end and partial processing checkpoint must match"
);
assert!(!body.contains(&format!("JOURNAL_POSITION_{}:", 100 + index)));
assert_eq!(host.reads.lock().unwrap().len(), index + 1);
let history = fixture.worker.history();
let overview = input
.split("## Previous Journal reference (historical, not automatically retained)")
.next()
.unwrap();
for old in &old_bodies {
assert!(
!overview.contains(old),
"superseded Journal is excluded from ordinary overview"
);
assert!(
!body.contains(old),
"frozen latest reference does not incorporate old injections"
);
assert!(
history
.iter()
.all(|item| !item.as_text().is_some_and(|text| text.contains(old))),
"superseded Journal is not retained"
);
}
assert!(
input.contains(&directive)
|| history
.iter()
.any(|item| item.as_text() == Some(directive.as_str())),
"unrecorded user instruction must reach summary input or retained tail"
);
if retained == 1_000_000 {
assert!(
history
.iter()
.any(|item| item.as_text() == Some("TAIL_ASSISTANT_1"))
);
}
let task = history
.iter()
.filter_map(Item::as_text)
.find(|text| text.starts_with("[Session TaskStore snapshot]"))
.unwrap();
let json = task
.split("```json\n")
.nth(1)
.unwrap()
.split("\n```")
.next()
.unwrap();
let tasks: serde_json::Value = serde_json::from_str(json).unwrap();
assert_eq!(
tasks["tasks"].as_array().unwrap().len(),
1,
"replayed TaskCreate must not duplicate across compactions"
);
assert_eq!(tasks["tasks"][0]["subject"], "JOURNAL_TASK");
assert_eq!(tasks["tasks"][0]["status"], "pending");
assert!(input.contains("JOURNAL_TASK"));
old_bodies.push(body);
for (source, before) in &historical_sources {
assert_eq!(
journal_source_records(fixture.worker.store(), session, *source),
*before,
"original segments must remain unchanged"
);
}
}
assert_ne!(original_segment, fixture.worker.segment_id());
let reads = host.reads.lock().unwrap().clone();
assert!(
reads
.iter()
.all(|context| context.worker_id == "test-worker"
&& context.session_id == session.to_string())
);
// Reloaded prompt resources must not regenerate the persisted reference
// or relabel its original source/projection on normal turns or restore.
let mut templates = initial_catalog.projection().templates.clone();
templates
.get_mut("internal.subjektiv_journal_compaction")
.unwrap()
.push_str("\nRELOADED_PROMPT_MUST_NOT_REPLACE_FROZEN_BODY");
let mut reloaded_projection =
worker::EffectivePromptCatalog::new(templates, "test-schema", "test-toolchain").unwrap();
reloaded_projection.source_digest = "b".repeat(64);
let reloaded_catalog =
Arc::new(worker::PromptCatalog::from_projection(reloaded_projection).unwrap());
assert_ne!(
initial_catalog.projection().catalog_digest,
reloaded_catalog.projection().catalog_digest
);
fixture.worker.prompts().store(reloaded_catalog);
fixture
.worker
.run_text("normal post-compaction turn")
.await
.unwrap();
let records = fixture
.worker
.store()
.read_all(session, fixture.worker.segment_id())
.unwrap();
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&records);
let target = snapshot
.entries
.iter()
.rev()
.find(|entry| {
matches!(
entry.data,
protocol::SessionSnapshotEntryData::UserInput { .. }
)
})
.unwrap();
fixture
.worker
.rewind_to(target.entry_id.clone())
.await
.unwrap();
let restored_body = journal_body(&fixture.worker);
assert_eq!(restored_body, *old_bodies.last().unwrap());
assert_journal_provenance(
&restored_body,
old_bodies
.last()
.unwrap()
.lines()
.rfind(|line| !line.is_empty()),
&initial_catalog,
Some(COMPACTION_HOST_WORKSPACE_ID),
);
fixture
.worker
.run_text("normal turn after typed history restore")
.await
.unwrap();
assert_eq!(
host.reads.lock().unwrap().len(),
3,
"rewind uses the shared typed restore path without fresh Journal acquisition"
);
assert_eq!(host.startup_reads.load(Ordering::SeqCst), 0);
let normal = observed.requests().last().unwrap().clone();
assert_eq!(
normal
.items
.iter()
.filter(|item| item.as_text() == Some(old_bodies.last().unwrap().as_str()))
.count(),
1
);
}
#[tokio::test]
async fn journal_absent_disabled_unavailable_and_over_byte_budget_keep_conversation_compaction() {
for case in [
"absent",
"disabled",
"unavailable",
"over-byte-budget",
"no-host",
] {
let mut snapshot = compaction_snapshot(0);
if case == "absent" {
snapshot.journals.clear();
}
if case == "over-byte-budget" {
snapshot.journals[0].current_position =
"x".repeat(subjektiv::journal::startup::STARTUP_MAX_BYTES);
}
let expected_json = (case == "absent").then(|| serde_json::to_string(&snapshot).unwrap());
let mut host = CompactionJournalHost::new(snapshot);
host.granted = case != "disabled";
host.unavailable = case == "unavailable";
let host = Arc::new(host);
let client = MockClient::new(vec![
single_text_events("seed"),
write_summary_tool_use_events("fallback", "fallback summary"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = if case == "no-host" {
SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await
} else {
SummaryInputFixture::with_journal(MANUAL_ONLY_MANIFEST_TOML, client, host.clone()).await
};
let inherited = long_summary("fallback-predecessor");
fixture
.restore_seed(
vec![
summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{inherited}")),
session_store::LoggedSessionHistoryOrigin::CompactionSummary,
),
summary_entry(
Item::system_message("PREVIOUS_JOURNAL_DELEGATED_CONTEXT"),
session_store::LoggedSessionHistoryOrigin::JournalContext,
),
annotated(Item::user_message(
"UNRECORDED_FALLBACK_GOAL: implementation still unfinished",
)),
],
true,
)
.await;
fixture.worker.compact(0).await.unwrap();
let body = journal_body(&fixture.worker);
let expected = match case {
"no-host" | "disabled" => "disabled",
"absent" => "absent",
_ => "unavailable",
};
assert!(
body.contains(&format!("Journal read status: {expected}.")),
"{case}"
);
let input = first_compactor_input(&observed.requests()[1]);
assert_frozen_journal_transport(&input, &body);
assert_journal_provenance(
&body,
expected_json.as_deref(),
&fixture.worker.prompts().load_full(),
(case != "no-host").then_some(COMPACTION_HOST_WORKSPACE_ID),
);
assert_full_initial_summary(&input, &inherited);
assert!(input.contains("UNRECORDED_FALLBACK_GOAL"), "{case}");
assert!(input.contains("## Previous Journal reference (historical, not automatically retained)\nPREVIOUS_JOURNAL_DELEGATED_CONTEXT"));
assert!(
fixture.worker.history().iter().all(|item| !item
.as_text()
.is_some_and(|text| text.contains("PREVIOUS_JOURNAL_DELEGATED_CONTEXT"))),
"fallback retains only the newly frozen reference, not an old snapshot as latest"
);
assert!(!body.contains("JOURNAL_POSITION_0"));
assert!(!body.contains("journal-reference-0"));
assert_eq!(
host.reads.lock().unwrap().len(),
usize::from(!matches!(case, "disabled" | "no-host")),
"{case}"
);
assert!(
fixture.worker.history()[0]
.as_text()
.unwrap()
.contains("fallback summary")
);
}
}
#[tokio::test]
async fn journal_snapshot_counts_against_compactor_hard_input_cap_without_mutating_source() {
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace("[compaction]", "[compaction]\nworker_context_max_tokens = 7000\nfinish_warning_remaining_tokens = 0\nfinal_reserve_tokens = 0\noverview_target_tokens = 1");
for large in [false, true] {
let mut snapshot = compaction_snapshot(0);
if large {
snapshot.journals[0].current_position = " x".repeat(15_000);
}
assert!(
serde_json::to_vec(&snapshot).unwrap().len()
<= subjektiv::journal::startup::STARTUP_MAX_BYTES
);
if large {
assert!(
agen::token_counter::total_tokens(
&[Item::user_message(&snapshot.journals[0].current_position)],
&[]
)
.tokens
> 7_000
);
}
let host = Arc::new(CompactionJournalHost::new(snapshot));
let client = MockClient::new(vec![
single_text_events("seed"),
write_summary_tool_use_events("summary", "new summary"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::with_journal(&manifest, client, host.clone()).await;
fixture.worker.run_text("unfinished goal").await.unwrap();
let session = fixture.worker.session_id();
let source = fixture.worker.segment_id();
let before = journal_source_records(fixture.worker.store(), session, source);
let history = fixture.worker.history();
let result = fixture.worker.compact(0).await;
if large {
let error = result.expect_err(
"mandatory frozen Journal must not be truncated to evade hard input cap",
);
assert!(
matches!(error, WorkerError::CompactCancelled)
|| error.to_string().contains("input occupancy"),
"{error:?}"
);
assert_eq!(
observed.requests().len(),
1,
"over-budget input must not reach the provider"
);
assert_eq!(fixture.worker.segment_id(), source);
assert_eq!(fixture.worker.history(), history);
assert_eq!(
fixture.worker.store().list_segments(session).unwrap(),
vec![source]
);
assert_eq!(
fixture
.worker
.store()
.read_by_name("test-worker")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(source)
);
} else {
result.unwrap();
assert_eq!(observed.requests().len(), 3);
assert_ne!(fixture.worker.segment_id(), source);
}
assert_eq!(host.reads.lock().unwrap().len(), 1);
assert_eq!(
journal_source_records(fixture.worker.store(), session, source),
before
);
}
}
@@ -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);
}