diff --git a/crates/subjektiv/src/journal/browser.rs b/crates/subjektiv/src/journal/browser.rs new file mode 100644 index 000000000..f4498f168 --- /dev/null +++ b/crates/subjektiv/src/journal/browser.rs @@ -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, + query: Option, + correcting: Option, + 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 { + 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> { + 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::, _>>()?; + 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> { + 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 = 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::, _>>()?; + 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 { + 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), + )?) + }) + } +} diff --git a/crates/subjektiv/src/journal/mod.rs b/crates/subjektiv/src/journal/mod.rs index fe20b279d..bdca4307f 100644 --- a/crates/subjektiv/src/journal/mod.rs +++ b/crates/subjektiv/src/journal/mod.rs @@ -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; diff --git a/crates/subjektiv/src/journal/store.rs b/crates/subjektiv/src/journal/store.rs index 60263e499..373c06996 100644 --- a/crates/subjektiv/src/journal/store.rs +++ b/crates/subjektiv/src/journal/store.rs @@ -70,7 +70,10 @@ fn page_limit(limit: usize) -> JournalResult { } Ok(limit) } -fn fit_page(rows: Vec<(String, T)>, mut more: bool) -> JournalResult> { +pub(super) fn fit_page( + rows: Vec<(String, T)>, + mut more: bool, +) -> JournalResult> { 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 { +pub(super) fn entry(tx: &Connection, journal_id: &str, id: &str) -> JournalResult { 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>(7)?,r.get::<_,String>(8)?,r.get::<_,String>(9)?,r.get::<_,Option>(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", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cb544a19a..0ca9f08d0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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, @@ -13151,6 +13157,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router { .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)) diff --git a/crates/workspace-server/src/server/subjektiv_journal_browser.rs b/crates/workspace-server/src/server/subjektiv_journal_browser.rs new file mode 100644 index 000000000..b97fc732c --- /dev/null +++ b/crates/workspace-server/src/server/subjektiv_journal_browser.rs @@ -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, + limit: usize, + }, + Entries { + journal_id: Option, + query: Option, + after: Option, + limit: usize, + }, + Entry { + journal_id: String, + entry_id: String, + }, + Text { + journal_id: String, + entry_id: Option, + field: JournalTextField, + expected_token: Option, + byte_offset: usize, + max_bytes: usize, + }, + Corrections { + journal_id: String, + entry_id: String, + after: Option, + limit: usize, + }, + MemorySources { + selector: MemoryJournalSelector, + source_offset: usize, + limit: usize, + }, + RelatedMemory { + journal_id: String, + entry_id: String, + after: Option, + limit: usize, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub(super) enum BrowserResponse { + Sessions(JournalPage), + Entries(JournalPage), + Entry(BrowserEntry), + Text(JournalTextPage), + Corrections(JournalPage), + 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, +} +#[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, + pub original_source: SourceStatus, +} +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct BrowserMemorySource { + pub link: MemoryJournalSourceLink, + pub entry: Option, + pub original_source: SourceStatus, +} +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct BrowserMemorySources { + pub selector: MemoryJournalSelector, + pub sources: Vec, + pub total_sources: usize, + pub next_source_index: Option, +} +#[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, + pub next: Option, +} + +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, + AxumPath(path): AxumPath, + actor: Option>, + Json(request): Json, +) -> ApiResult> { + // 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 { + 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, + }) + } + }) +} diff --git a/crates/workspace-server/src/server/tests/subjektiv_journal_browser_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_journal_browser_tests.rs new file mode 100644 index 000000000..7046bc2a7 --- /dev/null +++ b/crates/workspace-server/src/server/tests/subjektiv_journal_browser_tests.rs @@ -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 { + 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) { + 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::(&empty.1).unwrap()["value"]["items"] + .as_array() + .unwrap() + .is_empty() + ); + let unknown_action = browser_request( + &subject(&f), + Some(&token), + json!({"action":"reconcile","selector":f.selector}), + ); + assert_eq!( + response(app, unknown_action).await.0, + StatusCode::UNPROCESSABLE_ENTITY + ); + let entry = browser_call(&f, &token, detail(&f)).await; + assert_eq!(entry["value"]["entry"]["state"], "open"); + assert_eq!(entry["value"]["entry"]["title"], "Choosing the approach"); + assert_eq!(entry["value"]["original_source"]["state"], "unavailable"); +} + +#[tokio::test] +async fn journal_browser_multiple_sessions_recent_search_and_filter_bound_paging() { + let f = Fixture::new().await; + f.fix_initial(); + let (later, later_entry) = later_journal(&f, vec![]); + let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-pages").await; + let first = browser_call( + &f, + &token, + json!({"action":"sessions","after":null,"limit":1}), + ) + .await; + assert_eq!(first["value"]["items"].as_array().unwrap().len(), 1); + assert_eq!(first["value"]["items"][0]["unreflected_sources"], true); + let second = browser_call( + &f, + &token, + json!({"action":"sessions","after":first["value"]["next"],"limit":1}), + ) + .await; + assert_eq!(second["value"]["items"].as_array().unwrap().len(), 1); + assert_ne!( + first["value"]["items"][0]["journal"]["id"], + second["value"]["items"][0]["journal"]["id"] + ); + assert!(second["value"]["next"].is_null()); + let first = browser_call( + &f, + &token, + json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":1}), + ) + .await; + assert_eq!(first["value"]["items"][0]["id"], later_entry); + let second = browser_call(&f, &token, json!({"action":"entries","journal_id":null,"query":null,"after":first["value"]["next"],"limit":1})).await; + assert_eq!(second["value"]["items"][0]["id"], f.entry_id); + assert!(second["value"]["next"].is_null()); + let filtered = browser_call( + &f, + &token, + json!({"action":"entries","journal_id":later,"query":"REVISITS","after":null,"limit":32}), + ) + .await; + assert_eq!(filtered["value"]["items"].as_array().unwrap().len(), 1); + assert_eq!(filtered["value"]["items"][0]["id"], later_entry); + for query in ["no such event", "%"] { + let empty = browser_call( + &f, + &token, + json!({"action":"entries","journal_id":null,"query":query,"after":null,"limit":32}), + ) + .await; + assert!(empty["value"]["items"].as_array().unwrap().is_empty()); + } + let changed_filter = browser_request( + &subject(&f), + Some(&token), + json!({"action":"entries","journal_id":later, + "query":null,"after":first["value"]["next"],"limit":1}), + ); + assert_eq!( + response(build_router(f.api.clone()), changed_filter) + .await + .0, + StatusCode::BAD_REQUEST + ); + let other = f + .store + .create_subject(crate::subjektiv::SubjectRole::new("cursor-other").unwrap()) + .unwrap(); + let cross_subject = browser_request( + &other.id, + Some(&token), + json!({"action":"entries","journal_id":null, + "query":null,"after":first["value"]["next"],"limit":1}), + ); + assert_eq!( + response(build_router(f.api.clone()), cross_subject).await.0, + StatusCode::BAD_REQUEST + ); +} + +#[tokio::test] +async fn journal_browser_text_bounds_tokens_position_and_ongoing_are_independent() { + let f = Fixture::new().await; + let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-text").await; + assert_eq!( + browser_call(&f, &token, text(&f, "ongoing")).await["value"]["text"], + "Validation pending" + ); + assert_eq!( + browser_call(&f, &token, text(&f, "current_position")).await["value"]["text"], + "" + ); + let mut request = text(&f, "body"); + request["max_bytes"] = json!(8); + let first = browser_call(&f, &token, request.clone()).await; + assert_eq!(first["value"]["text"], &JOURNAL_BODY[..8]); + request["byte_offset"] = first["value"]["next_byte"].clone(); + let (status, _) = response( + build_router(f.api.clone()), + browser_request(&subject(&f), Some(&token), request.clone()), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + request["expected_token"] = first["value"]["content_token"].clone(); + let next = browser_call(&f, &token, request.clone()).await; + assert_eq!(next["value"]["text"], &JOURNAL_BODY[8..16]); + f.fix_initial(); // changes entry token, never tears the old continuation + assert_eq!( + response( + build_router(f.api.clone()), + browser_request(&subject(&f), Some(&token), request) + ) + .await + .0, + StatusCode::CONFLICT + ); + for body in [ + json!({"action":"sessions","after":null,"limit":0}), + json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":33}), + json!({"action":"entries","journal_id":null,"query":"x".repeat(257),"after":null,"limit":10}), + { + let mut body = text(&f, "body"); + body["max_bytes"] = json!(16385); + body + }, + ] { + assert_eq!( + response( + build_router(f.api.clone()), + browser_request(&subject(&f), Some(&token), body) + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + } +} + +#[tokio::test] +async fn journal_browser_saved_bidirectional_links_titles_and_cross_session_corrections_survive_deletion() + { + let f = Fixture::new().await; + let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-links").await; + let sources = + json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":1}); + let unconnected = browser_call(&f, &token, sources.clone()).await; + assert_eq!( + unconnected["value"]["sources"][0]["link"]["connection"]["state"], + "unconnected" + ); + assert_eq!( + unconnected["value"]["sources"][0]["link"]["connection"]["reason"], + "not_reconciled" + ); + f.fix_initial(); + f.call(MemoryJournalRequest::Reconcile { + selector: f.selector.clone(), + }) + .await; + let (later, correction) = later_journal(&f, vec![f.entry_id.clone()]); + let linked = browser_call(&f, &token, sources.clone()).await; + assert_eq!( + linked["value"]["sources"][0]["entry"]["title"], + "Choosing the approach" + ); + assert!(linked["value"]["sources"][0]["entry"]["created_at"].is_string()); + assert_eq!( + linked["value"]["sources"][0]["link"]["connection"]["entry_id"], + f.entry_id + ); + let corrections = browser_call( + &f, + &token, + json!({"action":"corrections","journal_id":f.journal_id, + "entry_id":f.entry_id,"after":null,"limit":1}), + ) + .await; + assert_eq!(corrections["value"]["items"][0]["id"], correction); + assert_eq!(corrections["value"]["items"][0]["journal_id"], later); + let corrected = browser_call( + &f, + &token, + json!({"action":"entry","journal_id":later,"entry_id":correction}), + ) + .await; + assert_eq!( + corrected["value"]["correction_targets"][0]["id"], + f.entry_id + ); + assert_eq!( + corrected["value"]["correction_targets"][0]["journal_id"], + f.journal_id + ); + let related_request = json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":1}); + let related = browser_call(&f, &token, related_request.clone()).await; + assert_eq!( + related["value"]["items"][0]["claim"], + "Check the constraint before choosing" + ); + assert_eq!( + related["value"]["items"][0]["related"]["selector"]["change_id"], + f.selector.change_id + ); + assert_eq!( + related["value"]["items"][0]["related"]["reconsideration"], + true + ); + fs::remove_dir_all(&f.source_root).unwrap(); + f.api + .store + .delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker) + .unwrap(); + let deleted = browser_call(&f, &token, detail(&f)).await; + assert_eq!(deleted["value"]["original_source"]["state"], "missing"); + assert!(deleted["value"]["original_source"]["history_href"].is_null()); + assert_eq!( + browser_call(&f, &token, text(&f, "body")).await["value"]["text"], + JOURNAL_BODY + ); + let linked = browser_call(&f, &token, sources).await; + assert_eq!( + linked["value"]["sources"][0]["original_source"]["state"], + "missing" + ); + assert_eq!(linked["value"]["sources"][0]["entry"]["id"], f.entry_id); + assert_eq!( + browser_call(&f, &token, related_request).await["value"]["items"][0]["claim"], + "Check the constraint before choosing" + ); +} + +#[tokio::test] +async fn journal_browser_history_navigation_requires_exact_session_mapping_not_registry_presence() { + let f = Fixture::new().await; + let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-original").await; + let mut registry = f + .api + .store + .get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker) + .unwrap() + .unwrap(); + let initial = browser_call(&f, &token, detail(&f)).await; + assert!(initial["value"]["original_source"]["history_href"].is_null()); + registry.session_ref = Some(f.ranges[0].source.session_id.clone()); + f.api.store.upsert_worker_registry(®istry).unwrap(); + let exact = browser_call(&f, &token, detail(&f)).await; + let resource_key = f + .api + .store + .worker_resource_key(TEST_WORKSPACE_ID, &f.source_worker) + .unwrap() + .unwrap(); + assert_eq!( + exact["value"]["original_source"]["history_href"], + browser_worker_console_href(TEST_WORKSPACE_ID, &resource_key) + ); + assert!(resource_key.starts_with("W-")); + // Metadata does not attest original bytes. A later Session must not be linked as the old one. + assert_eq!(exact["value"]["original_source"]["state"], "unavailable"); + registry.session_ref = Some("different-session".into()); + f.api.store.upsert_worker_registry(®istry).unwrap(); + assert!( + browser_call(&f, &token, detail(&f)).await["value"]["original_source"]["history_href"] + .is_null() + ); +} + +#[tokio::test] +async fn journal_browser_many_saved_links_page_without_reconciliation_and_use_immutable_claims() { + let f = Fixture::new().await; + f.fix_initial(); + let correction = f.add_correction(); + f.call(MemoryJournalRequest::Reconcile { + selector: f.selector.clone(), + }) + .await; + let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-many-links").await; + let mut candidate = f + .store + .staging_candidate(&subject(&f), &f.selector.candidate_id) + .unwrap() + .unwrap(); + candidate.id = "second-browser-candidate".into(); + candidate.claim = "Another immutable lesson from both events".into(); + let mut source = candidate.source_refs[0].clone(); + source.evidence_id = Some(f.ranges[1].source.pointer.entry_ref.clone()); + source.entry_range = Some([1, 1]); + let mut evidence = candidate.evidence[0].clone(); + evidence.id = source.evidence_id.clone().unwrap(); + evidence.entry_range = Some([1, 1]); + candidate.source_refs.push(source); + candidate.evidence.push(evidence); + let (candidate, _) = f + .store + .stage_candidate_with_attribution( + candidate, + f.store + .session_attribution(&f.ranges[0].source.session_id) + .unwrap() + .unwrap(), + ) + .unwrap(); + let (memory, _) = f + .store + .apply_candidate( + &subject(&f), + &candidate.id, + crate::subjektiv::MemoryChangeTarget::Create, + crate::subjektiv::MemoryDraft::active( + candidate.kind.clone(), + &candidate.claim, + "Saved Memory body", + &candidate.why_useful, + "Second adoption", + ), + "Reviewed", + ) + .unwrap(); + let selector = MemoryJournalSelector { + memory_id: memory.id.clone(), + change_id: memory.change_id.clone(), + candidate_id: candidate.id.clone(), + }; + let first = browser_call( + &f, + &token, + json!({"action":"memory_sources","selector":selector,"source_offset":0,"limit":1}), + ) + .await; + assert_eq!(first["value"]["total_sources"], 2); + assert_eq!(first["value"]["next_source_index"], 1); + assert_eq!( + first["value"]["sources"][0]["link"]["connection"]["reason"], + "not_reconciled" + ); + // Reading a browser page never reconciles. Only the existing domain fixture explicitly does so. + f.call(MemoryJournalRequest::Reconcile { + selector: selector.clone(), + }) + .await; + let first = browser_call( + &f, + &token, + json!({"action":"memory_sources","selector":selector,"source_offset":0,"limit":1}), + ) + .await; + let next = browser_call(&f,&token,json!({"action":"memory_sources","selector":selector,"source_offset":first["value"]["next_source_index"],"limit":1})).await; + assert_eq!(first["value"]["sources"][0]["entry"]["id"], f.entry_id); + assert_eq!(next["value"]["sources"][0]["entry"]["id"], correction); + assert!(next["value"]["next_source_index"].is_null()); + f.store + .revise_memory( + &subject(&f), + &memory.id, + memory.change_id, + crate::subjektiv::MemoryDraft::active( + candidate.kind, + "Changed current claim", + "New body", + "Updated lesson", + "Later qualification", + ), + ) + .unwrap(); + let first = browser_call(&f,&token,json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":1})).await; + let next = browser_call(&f,&token,json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":first["value"]["next"],"limit":1})).await; + assert!(first["value"]["next"].is_object()); + assert!(next["value"]["next"].is_null()); + let claims = [ + first["value"]["items"][0]["claim"].as_str().unwrap(), + next["value"]["items"][0]["claim"].as_str().unwrap(), + ]; + assert!(claims.contains(&"Another immutable lesson from both events")); + assert!(claims.contains(&"Check the constraint before choosing")); + assert!(!claims.contains(&"Changed current claim")); +} + +#[tokio::test] +async fn journal_browser_read_post_has_memory_get_user_authority_but_no_mutation_authority() { + let f = Fixture::new().await; + let token = seed_test_api_token(f.api.store.as_ref(), "browser-reader-not-owner"); + // Ordinary authenticated read authority is the same as Memory GET, even though browser + // selectors use POST. This exception is exact-route only; no mutations gain authority. + assert_eq!( + browser_call(&f, &token, detail(&f)).await["value"]["entry"]["id"], + f.entry_id + ); + let request = Request::builder() + .method("POST") + .uri(format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects")) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(r#"{"role":"not-allowed"}"#)) + .unwrap(); + assert_eq!( + response(build_router(f.api.clone()), request).await.0, + StatusCode::FORBIDDEN + ); +} + +#[tokio::test] +async fn journal_browser_saved_missing_bytes_do_not_claim_original_conversation_deleted() { + let f = Fixture::new().await; + f.fix_initial(); + f.call(MemoryJournalRequest::Reconcile { + selector: f.selector.clone(), + }) + .await; + let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-missing-byte").await; + let mut registry = f + .api + .store + .get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker) + .unwrap() + .unwrap(); + registry.session_ref = Some(f.ranges[0].source.session_id.clone()); + f.api.store.upsert_worker_registry(®istry).unwrap(); + let sources = + json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":1}); + let present = browser_call(&f, &token, sources.clone()).await; + assert!(present["value"]["sources"][0]["original_source"]["history_href"].is_string()); + + // The Host attests only a missing byte of the referenced source. Original Session + // metadata and material still exist; neither this attestation nor its saved link + // projection certifies deletion of the conversation as a whole. + let mut missing_byte = f.ranges[0].clone(); + missing_byte.bytes.end = missing_byte.bytes.start + 1; + f.store + .mark_journal_sources_unavailable(&f.host, &f.journal_id, &[missing_byte]) + .unwrap(); + assert!(f.source_root.exists()); + assert!( + f.api + .store + .get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker) + .unwrap() + .is_some() + ); + let partial = browser_call(&f, &token, sources.clone()).await; + let source = &partial["value"]["sources"][0]; + assert_eq!(source["link"]["original_source"], "missing"); + assert_eq!(source["original_source"]["state"], "unavailable"); + assert!(source["original_source"]["history_href"].is_null()); + assert_eq!(source["entry"]["id"], f.entry_id); + assert_eq!( + browser_call(&f, &token, text(&f, "body")).await["value"]["text"], + JOURNAL_BODY + ); + + // Only independently confirmed registry/archive absence changes the conversation + // status to missing. The same saved Journal and immutable link remain readable. + fs::remove_dir_all(&f.source_root).unwrap(); + f.api + .store + .delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker) + .unwrap(); + let deleted = browser_call(&f, &token, sources).await; + assert_eq!( + deleted["value"]["sources"][0]["original_source"]["state"], + "missing" + ); + assert!(deleted["value"]["sources"][0]["original_source"]["history_href"].is_null()); + assert_eq!(deleted["value"]["sources"][0]["entry"]["id"], f.entry_id); +} + +/// Emits actual authenticated router/store responses for the production Web contract runner. +/// The paired `test:journal:backend-contract` task validates all action payloads with the real +/// Web parser; this Rust test remains usable without a JavaScript runtime or product process. +#[tokio::test] +async fn journal_browser_web_contract_serializes_saved_router_responses() { + let f = Fixture::new().await; + f.fix_initial(); + f.call(MemoryJournalRequest::Reconcile { + selector: f.selector.clone(), + }) + .await; + let (later, correction) = later_journal(&f, vec![f.entry_id.clone()]); + let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-web-contract").await; + let mut registry = f + .api + .store + .get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker) + .unwrap() + .unwrap(); + registry.session_ref = Some(f.ranges[0].source.session_id.clone()); + f.api.store.upsert_worker_registry(®istry).unwrap(); + let sources = + json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":3}); + let requests = [ + json!({"action":"sessions","after":null,"limit":20}), + json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":20}), + detail(&f), + text(&f, "body"), + json!({"action":"corrections","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":10}), + sources.clone(), + json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":3}), + json!({"action":"entry","journal_id":later,"entry_id":correction}), + ]; + for request in requests { + let response = browser_call(&f, &token, request.clone()).await; + println!( + "JOURNAL_BROWSER_CONTRACT {}", + json!({"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject(&f),"request":request,"response":response}) + ); + } + fs::remove_dir_all(&f.source_root).unwrap(); + f.api + .store + .delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker) + .unwrap(); + for request in [detail(&f), sources] { + let response = browser_call(&f, &token, request.clone()).await; + println!( + "JOURNAL_BROWSER_CONTRACT {}", + json!({"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject(&f),"request":request,"response":response}) + ); + } +} + +#[tokio::test] +async fn journal_browser_cookie_read_post_retains_origin_check_for_non_owner_reader() { + let f = Fixture::new().await; + seed_test_api_token(f.api.store.as_ref(), "journal-cookie-reader"); + f.api + .store + .create_browser_session(&BrowserSessionRecord { + token_hash: crate::auth::token_hash("journal-cookie-session"), + session_id: "journal-cookie-session-id".into(), + user_id: "user-journal-cookie-reader".into(), + created_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2099-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + let AuthConfig::Passkey { + origin, + cookie_name, + .. + } = &f.api.config.auth; + for (presented, expected) in [ + (None, StatusCode::FORBIDDEN), + (Some("https://foreign.example"), StatusCode::FORBIDDEN), + (Some(origin.as_str()), StatusCode::OK), + ] { + let mut request = Request::builder() + .method("POST") + .uri(browser_path(&subject(&f))) + .header(CONTENT_TYPE, "application/json") + .header( + axum::http::header::COOKIE, + format!("{cookie_name}=journal-cookie-session"), + ); + if let Some(origin) = presented { + request = request.header(axum::http::header::ORIGIN, origin); + } + let (status, bytes) = response( + build_router(f.api.clone()), + request + .body(Body::from(serde_json::to_vec(&detail(&f)).unwrap())) + .unwrap(), + ) + .await; + assert_eq!(status, expected); + if status == StatusCode::OK { + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["value"]["entry"]["id"], f.entry_id); + } + } +} diff --git a/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs index b4f9c2e4b..abcc72632 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs @@ -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; diff --git a/tools/web-ux/browser-tests/journal_browser_fixture.ts b/tools/web-ux/browser-tests/journal_browser_fixture.ts new file mode 100644 index 000000000..5e5349467 --- /dev/null +++ b/tools/web-ux/browser-tests/journal_browser_fixture.ts @@ -0,0 +1,291 @@ +/** UI向けのHTTP代替データ。Journalの正本・認可・永続化の実装ではない。 */ +export const journalFixture = { + workspaceId: "memory-review", + subjectId: + "release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review", + memoryId: + "memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", + candidateId: "candidate-release-decision-0001", + literalQuery: "release [a+b] %_", +}; + +function journal(id: string, subjectId: string) { + return { + id, + subject_id: subjectId, + source_session_id: `session-${id}`, + title: id === "journal-release" + ? "Release coordination Session" + : id === "journal-review" + ? "Independent review Session" + : "Archived planning Session", + current_position_bytes: 120, + process_order: 24, + process_byte: 120, + state_token: `state-${id}`, + created_at: "2026-01-02T03:04:05Z", + updated_at: "2026-01-04T03:04:05Z", + }; +} +function source(journalId: string) { + return journalId === "journal-release" + ? { state: "missing", history_href: null } + : journalId === "journal-review" + ? { state: "unavailable", history_href: null } + : { + state: "unavailable", + history_href: "/w/memory-review/workers/W-fixture/console", + }; +} +function entry(index: number) { + const journalId = index === 4 + ? "journal-planning" + : index % 2 + ? "journal-review" + : "journal-release"; + return { + id: `entry-${String(index).padStart(2, "0")}`, + journal_id: journalId, + position: index + 1, + state: index === 0 ? "open" : "fixed", + title: index === 2 + ? "Corrected release evidence" + : `Release milestone ${ + String(index).padStart(2, "0") + } · ${journalFixture.literalQuery}`, + correction_refs: index === 2 ? ["entry-00"] : [], + content_token: `content-entry-${index}`, + created_at: "2026-01-02T03:04:05Z", + updated_at: "2026-01-03T03:04:05Z", + fixed_at: index === 0 ? null : "2026-01-03T03:04:05Z", + }; +} +const entries = Array.from({ length: 28 }, (_, index) => entry(index)); +const related = Array.from({ length: 8 }, (_, index) => ({ + related: { + selector: { + memory_id: index === 0 + ? journalFixture.memoryId + : `memory-related-${index}`, + change_id: "change-current", + candidate_id: journalFixture.candidateId, + }, + source_index: index, + reconsideration: index === 0, + }, + claim: index === 0 + ? "Keep subject Memory provenance typed and visible" + : `Related release lesson ${index}`, +})); +const body = + `# Preserved Journal narrative\n\nThis saved Journal remains readable without the original Worker or Session. It is not the original transcript or proof of each Memory claim.\n\n${ + Array.from({ length: 64 }, (_, i) => + `## Release event ${ + i + 1 + }\n\nThe team compared the available evidence and recorded the release decision. Literal search token: ${journalFixture.literalQuery}.`) + .join("\n\n") + }\n\n## Journal narrative complete`; + +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +const failure = (status: number, message: string) => + json( + { error: status === 403 ? "Forbidden" : "Unavailable", message }, + status, + ); + +export async function journalBrowserFixture( + request: Request, + subjectId: string, +): Promise { + if (request.method !== "POST") { + return failure(405, "read-only Journal queries require POST"); + } + const r = await request.json().catch(() => null) as + | Record + | null; + if (!r || typeof r.action !== "string") { + return failure(400, "missing query action"); + } + const reply = (value: unknown) => json({ kind: r.action, value }); + if (subjectId === "failed-subject") { + return failure(503, "Journal read temporarily unavailable"); + } + if (r.journal_id === "journal-denied") { + return failure(403, "Journal read permission denied"); + } + const empty = subjectId === "empty-subject" || + subjectId === "ungenerated-subject"; + switch (r.action) { + case "sessions": { + if (empty) return reply({ items: [], next: null }); + const journals = ["journal-release", "journal-review", "journal-archive"]; + const offset = r.after === "fixture-sessions-2" ? 2 : 0; + return reply({ + items: journals.slice(offset, offset + 2).map((id) => ({ + journal: journal(id, subjectId), + original_source: source(id), + unreflected_sources: id === "journal-review", + })), + next: offset === 0 ? "fixture-sessions-2" : null, + }); + } + case "entries": { + if (r.query === "fixture-denied") { + return failure(403, "Journal read permission denied"); + } + if (r.query === "fixture-failure") { + return failure(503, "Journal list unavailable"); + } + const filtered = empty + ? [] + : entries.filter((e) => + (!r.journal_id || r.journal_id === e.journal_id) && + (!r.query || + `${e.title} ${body}`.toLowerCase().includes( + String(r.query).toLowerCase(), + )) + ); + const offset = r.after === "fixture-entries-2" ? 16 : 0; + const limit = Math.min(Number(r.limit) || 20, 16); + return reply({ + items: filtered.slice(offset, offset + limit), + next: offset + limit < filtered.length ? "fixture-entries-2" : null, + }); + } + case "entry": { + const e = entries.find((e) => + e.id === r.entry_id && e.journal_id === r.journal_id + ); + return e + ? reply({ + journal: journal(e.journal_id, subjectId), + entry: e, + correction_targets: entries.filter((target) => + e.correction_refs.includes(target.id) + ), + original_source: source(e.journal_id), + }) + : failure(404, "Journal entry not found"); + } + case "text": { + const e = r.entry_id + ? entries.find((e) => + e.id === r.entry_id && e.journal_id === r.journal_id + ) + : null; + const token = r.field === "current_position" + ? `state-${r.journal_id}` + : e?.content_token; + if (!token) return failure(404, "Journal text not found"); + if (r.expected_token && r.expected_token !== token) { + return failure(409, "Journal text changed; reread from the beginning"); + } + const text = r.field === "body" + ? body + : r.field === "ongoing" + ? "## Ongoing release context\n\nThe next review is still open." + : "## Current Session position\n\nRelease review is awaiting the next evidence check. This current position does not replace the event history."; + const bytes = new TextEncoder().encode(text); + const start = Number(r.byte_offset) || 0; + let end = Math.min( + bytes.length, + start + Math.min(Number(r.max_bytes) || 16384, 16384), + ); + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1; + return reply({ + text: new TextDecoder().decode(bytes.slice(start, end)), + next_byte: end < bytes.length ? end : null, + content_token: token, + }); + } + case "corrections": + return reply({ + items: r.entry_id === "entry-00" ? [entry(2)] : [], + next: null, + }); + case "related_memory": { + if (r.entry_id === "entry-03") return reply({ items: [], next: null }); + const offset = r.after ? Number(r.after.source_index) + 1 : 0; + const limit = Math.min(Number(r.limit) || 3, 20); + const items = related.slice(offset, offset + limit); + const last = items.at(-1)?.related; + return reply({ + items, + next: offset + limit < related.length && last + ? { ...last.selector, source_index: last.source_index } + : null, + }); + } + case "memory_sources": { + const s = r.selector; + // 不変selectorは本番Memory loaderから受け取り、routeだけから推測しない。 + if ( + !s || s.candidate_id !== journalFixture.candidateId || + ![ + "change-current", + "change-earlier", + "change-original", + "change-parent", + ].includes( + s.change_id, + ) + ) return failure(404, "Immutable candidate not found"); + if (s.memory_id === "memory-sources-failure") { + return failure(503, "Related Journal unavailable"); + } + if (s.memory_id === "memory-resolved-0002") { + return reply({ + selector: s, + sources: [{ + link: { + source_index: 0, + original_source: "unknown", + connection: { + state: "unconnected", + reason: "insufficient_source", + }, + }, + entry: null, + original_source: { state: "unavailable", history_href: null }, + }], + total_sources: 1, + next_source_index: null, + }); + } + const offset = Number(r.source_offset) || 0; + const limit = Math.min(Number(r.limit) || 3, 20); + const sources = entries.slice(offset, Math.min(8, offset + limit)).map(( + e, + index, + ) => ({ + link: { + source_index: offset + index, + original_source: e.journal_id === "journal-release" + ? "missing" + : "unknown", + connection: { + state: "linked", + journal_id: e.journal_id, + entry_id: e.id, + target: "available", + reconsideration: e.id === "entry-00", + }, + }, + entry: e, + original_source: source(e.journal_id), + })); + return reply({ + selector: s, + sources, + total_sources: 8, + next_source_index: offset + limit < 8 ? offset + limit : null, + }); + } + default: + return failure(400, "unsupported read-only Journal query"); + } +} diff --git a/tools/web-ux/browser-tests/journal_browser_shell_test.ts b/tools/web-ux/browser-tests/journal_browser_shell_test.ts new file mode 100644 index 000000000..fc683d28f --- /dev/null +++ b/tools/web-ux/browser-tests/journal_browser_shell_test.ts @@ -0,0 +1,649 @@ +/** 本番static web buildとloopback HTTP fixtureでUI境界だけを検証する。 + * 最新のweb/workspace/buildが前提で、このsuiteはbuildしない。 + * 製品・Worker・modelは起動せず、Journal保存・認可の保証はBackend試験が担う。 + * tools/web-uxのtest:browserまたはこのfileを明示選択して実行する。 + */ +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Locator, type Page } from "playwright"; +import { journalFixture as fixture } from "./journal_browser_fixture.ts"; +import { + assertMainOwnsVerticalScroll, + assertNoPageWideOverflow, + freePort, + mainScrollState, + waitForServer, +} from "./memory_document_harness.ts"; + +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const artifacts = join(root, "target/web-ux/t-737-browser-interactions"); +const subjectPath = `/w/${fixture.workspaceId}/memory/${fixture.subjectId}`; +const journalPath = `${subjectPath}/journal`; +const memoryPath = + `${subjectPath}/${fixture.memoryId}?change_id=change-current`; +const claim = "Keep subject Memory provenance typed and visible"; +const entryTitle = "Release milestone 00 · release [a+b] %_"; +const detailPath = `${journalPath}?journal=journal-release&entry=entry-00`; + +async function ordinaryAnchor(link: Locator) { + assertEquals(await link.evaluate((el) => el.tagName), "A"); + assertEquals(await link.getAttribute("target"), null); + assert(!(await link.textContent())?.includes("↗")); + assert((await link.getAttribute("href"))?.startsWith("/w/")); +} +async function restoreTop(page: Page, expected: number) { + await page.waitForFunction( + (top) => { + const main = (globalThis as any).document.querySelector("main"); + return main && Math.abs(main.scrollTop - top) <= 3; + }, + expected, + { timeout: 10_000 }, + ); + assert(Math.abs((await mainScrollState(page)).scrollTop - expected) <= 3); +} +async function capture(page: Page, name: string) { + await assertNoPageWideOverflow(page); + await assertMainOwnsVerticalScroll(page); + await page.screenshot({ path: join(artifacts, `${name}.png`) }); +} + +Deno.test("Journal fixture UI keeps scoped navigation, bounded links and distinct read states", async (t) => { + await Deno.stat(join(root, "web/workspace/build/index.html")); + await Deno.mkdir(artifacts, { recursive: true }); + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + join( + root, + "tools/web-ux/browser-tests/memory_document_fixture_server.ts", + ), + String(port), + join(root, "web/workspace/build"), + ], + stdout: "null", + stderr: "null", + }).spawn(); + try { + await waitForServer(`${base}/health`); + const browser = await chromium.launch({ headless: true }); + const requests: { + path: string; + method: string; + payload: Record | null; + }[] = []; + const errors: string[] = []; + try { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + }); + context.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (path.startsWith("/api/")) { + requests.push({ + path, + method: request.method(), + payload: request.method() === "POST" + ? request.postDataJSON() + : null, + }); + } + }); + context.on( + "page", + (page) => page.on("pageerror", (e) => errors.push(String(e))), + ); + const page = await context.newPage(); + await t.step( + "Subject entry and multiple Sessions retain pending/source state and continuation", + async () => { + await page.goto(`${base}${subjectPath}`); + const entry = page.getByRole("link", { + name: "Journal · 最近の出来事とSessionを探す →", + exact: true, + }); + await entry.waitFor(); + await ordinaryAnchor(entry); + await entry.click(); + await page.locator("[data-journal-view='list']").waitFor(); + await page.getByRole("link", { + name: "SessionごとのJournal", + exact: true, + }).click(); + await page.getByRole("link", { + name: "Release coordination Session →", + exact: true, + }) + .waitFor(); + assertEquals(await page.locator(".session-list > li").count(), 2); + await page.getByText("記録待ち・未反映の原資料があります。", { + exact: true, + }).waitFor(); + await capture(page, "sessions-desktop-light"); + const next = page.getByRole("link", { + name: "次のページ →", + exact: true, + }); + await next.click(); + await page.getByRole("link", { + name: "Archived planning Session →", + exact: true, + }) + .waitFor(); + assertEquals( + new URL(page.url()).searchParams.get("view"), + "sessions", + ); + assertEquals( + new URL(page.url()).searchParams.get("after"), + "fixture-sessions-2", + ); + await page.getByRole("link", { name: "一覧の先頭へ", exact: true }) + .click(); + await page.getByRole("link", { + name: "Release coordination Session →", + exact: true, + }) + .click(); + await page.getByRole("heading", { name: "Current Session position" }) + .waitFor(); + await page.getByRole("heading", { name: "出来事の経緯", exact: true }) + .waitFor(); + assert((await page.locator(".entry-list > li").count()) > 1); + assertEquals( + new URL(page.url()).searchParams.get("journal"), + "journal-release", + ); + }, + ); + + await t.step( + "literal search and bounded continuation preserve the actual URL query", + async () => { + await page.goto(`${base}${journalPath}`); + await page.getByRole("searchbox", { name: "出来事を検索" }).fill( + fixture.literalQuery, + ); + await page.getByRole("button", { name: "検索", exact: true }).click(); + await page.waitForURL((url) => + url.searchParams.get("q") === fixture.literalQuery + ); + await page.getByRole("link", { + name: new RegExp(entryTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + }).waitFor(); + assertEquals(await page.locator(".entry-list > li").count(), 16); + await page.getByRole("link", { name: "次のページ →", exact: true }) + .click(); + await page.waitForURL((url) => + url.searchParams.get("after") === "fixture-entries-2" + ); + assertEquals( + new URL(page.url()).searchParams.get("q"), + fixture.literalQuery, + ); + assertEquals( + await page.getByRole("searchbox").inputValue(), + fixture.literalQuery, + ); + await page.getByText("Release milestone 16 · release [a+b] %_", { + exact: true, + }) + .waitFor(); + assertEquals(await page.locator(".entry-list > li").count(), 12); + const searchRequests = requests.filter((r) => + r.payload?.action === "entries" && + r.payload.query === fixture.literalQuery + ); + assert( + searchRequests.some((r) => + r.payload?.after === "fixture-entries-2" + ), + ); + }, + ); + + for ( + const viewport of [ + { width: 1440, height: 900, theme: "light" as const }, + { width: 768, height: 900, theme: "dark" as const }, + { width: 390, height: 844, theme: "light" as const }, + ] + ) { + await t.step( + `list → entry → Memory → Back → Back restores main scroll at ${viewport.width}`, + async () => { + await page.setViewportSize(viewport); + await page.emulateMedia({ colorScheme: viewport.theme }); + await page.goto( + `${base}${journalPath}?q=${ + encodeURIComponent(fixture.literalQuery) + }`, + ); + const target = page.locator(".entry-list a").filter({ + hasText: "Release milestone 12", + }); + await target.waitFor(); + await target.scrollIntoViewIfNeeded(); + const listTop = (await mainScrollState(page)).scrollTop; + assert( + listTop > 100, + "list must actually be scrolled before testing restoration", + ); + const listUrl = page.url(); + await ordinaryAnchor(target); + await target.click(); + await page.getByRole("heading", { + name: "Release milestone 12 · release [a+b] %_", + exact: true, + }).waitFor(); + const memoryLink = page.locator(".related-list").getByRole("link", { + name: `${claim} →`, + exact: true, + }); + await memoryLink.scrollIntoViewIfNeeded(); + const detailTop = (await mainScrollState(page)).scrollTop; + assert( + detailTop > 100, + "detail must actually be scrolled before testing restoration", + ); + const detailUrl = page.url(); + await capture( + page, + `detail-before-memory-${viewport.width}-${viewport.theme}`, + ); + await ordinaryAnchor(memoryLink); + assertEquals(await memoryLink.getAttribute("href"), memoryPath); + await memoryLink.click(); + await page.getByRole("heading", { name: claim, exact: true }) + .waitFor(); + assertEquals( + new URL(page.url()).pathname, + `${subjectPath}/${fixture.memoryId}`, + ); + assertEquals( + new URL(page.url()).searchParams.get("change_id"), + "change-current", + ); + const journalLinks = page.locator( + "#related-journal .journal-link-list a", + ); + assertEquals(await journalLinks.count(), 3); + const journalLink = journalLinks.filter({ hasText: entryTitle }); + await ordinaryAnchor(journalLink); + assertEquals(await journalLink.getAttribute("href"), detailPath); + assert( + (await journalLink.textContent())!.length > entryTitle.length + 4, + "Journal link includes date as well as title", + ); + await journalLink.scrollIntoViewIfNeeded(); + await capture( + page, + `memory-related-${viewport.width}-${viewport.theme}`, + ); + await page.goBack(); + await page.locator("[data-journal-view='detail']").waitFor(); + assertEquals(page.url(), detailUrl); + await restoreTop(page, detailTop); + await page.goBack(); + await page.getByRole("searchbox").waitFor(); + assertEquals(page.url(), listUrl); + assertEquals( + await page.getByRole("searchbox").inputValue(), + fixture.literalQuery, + ); + await restoreTop(page, listTop); + await capture( + page, + `list-back-${viewport.width}-${viewport.theme}`, + ); + }, + ); + } + + await t.step( + "related previews, immutable candidates, normal same-tab and new-tab links", + async () => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(`${base}${detailPath}`); + await page.getByRole("heading", { name: entryTitle, exact: true }) + .waitFor(); + assertEquals(await page.locator(".related-list a").count(), 3); + await page.getByRole("link", { + name: "すべて見る(関連Memoryの続き) →", + exact: true, + }) + .click(); + await page.getByText("Related release lesson 3 →", { exact: true }) + .waitFor(); + assertEquals(await page.locator(".related-list a").count(), 5); + assertEquals(new URL(page.url()).searchParams.get("related"), "all"); + assert(new URL(page.url()).searchParams.has("related_after")); + await page.locator(".related-list").scrollIntoViewIfNeeded(); + await capture(page, "journal-all-related-1440-light"); + await page.getByRole("link", { + name: "関連Memoryの先頭へ", + exact: true, + }).click(); + const memoryLink = page.locator(".related-list a").filter({ + hasText: claim, + }); + const popupReady = context.waitForEvent("page"); + await memoryLink.click({ button: "middle" }); + const popup = await popupReady; + await popup.getByRole("heading", { name: claim, exact: true }) + .waitFor(); + assertEquals( + new URL(popup.url()).searchParams.get("change_id"), + "change-current", + ); + const backLink = popup.locator("#related-journal a").filter({ + hasText: entryTitle, + }); + const journalPopupReady = context.waitForEvent("page"); + await backLink.click({ button: "middle" }); + const journalPopup = await journalPopupReady; + await journalPopup.getByRole("heading", { + name: entryTitle, + exact: true, + }).waitFor(); + await journalPopup.getByRole("heading", { + name: "Preserved Journal narrative", + exact: true, + }).waitFor(); + await journalPopup.close(); + await popup.getByRole("link", { + name: "すべて見る(関連Journalの続き) →", + exact: true, + }) + .click(); + await popup.locator("#related-journal a").filter({ + hasText: "Release milestone 03", + }) + .waitFor(); + assertEquals( + await popup.locator("#related-journal .journal-link-list a") + .count(), + 5, + ); + assertEquals( + new URL(popup.url()).searchParams.get("journal_source_offset"), + "3", + ); + await popup.locator("#related-journal").scrollIntoViewIfNeeded(); + await capture(popup, "memory-all-related-1440-light"); + await popup.locator("#related-journal a").filter({ + hasText: "Release milestone 03", + }) + .click(); + await popup.getByRole("heading", { + name: "Release milestone 03 · release [a+b] %_", + exact: true, + }).waitFor(); + assertEquals( + new URL(popup.url()).searchParams.get("entry"), + "entry-03", + ); + await popup.getByText("関連するMemoryは未接続です。", { exact: true }) + .waitFor(); + assertEquals(await popup.locator(".related-list a").count(), 0); + await popup.close(); + const candidateRequests = requests.filter((r) => + r.payload?.action === "memory_sources" + ); + assert(candidateRequests.length > 0); + for (const r of candidateRequests) { + assertEquals(r.payload?.selector.candidate_id, fixture.candidateId); + assertEquals(r.payload?.selector.change_id, "change-current"); + assertEquals(r.payload?.selector.memory_id, fixture.memoryId); + } + }, + ); + + await t.step( + "bounded narrative continuation carries its content token and recovers from stale text", + async () => { + await page.goto(`${base}${detailPath}`); + const body = page.getByRole("region", { + name: "出来事の本文", + exact: true, + }); + await body.getByRole("link", { name: "次の本文区間 →", exact: true }) + .click(); + await page.getByRole("heading", { + name: "Journal narrative complete", + exact: true, + }) + .waitFor(); + assertEquals( + new URL(page.url()).searchParams.get("body_token"), + "content-entry-0", + ); + assert(Number(new URL(page.url()).searchParams.get("body_byte")) > 0); + await body.getByRole("link", { name: "本文の先頭へ", exact: true }) + .click(); + await page.getByRole("heading", { + name: "Preserved Journal narrative", + exact: true, + }) + .waitFor(); + await page.goto( + `${base}${detailPath}&body_byte=8192&body_token=stale-content-token`, + ); + await body.getByRole("alert").waitFor(); + await body.getByRole("link", { + name: "先頭から読み直す", + exact: true, + }).click(); + await page.getByRole("heading", { + name: "Preserved Journal narrative", + exact: true, + }) + .waitFor(); + }, + ); + + await t.step( + "correction targets and correcting-entry links resolve directly", + async () => { + await page.goto(`${base}${detailPath}`); + const correction = page.getByRole("link").filter({ + hasText: "Corrected release evidence", + }); + await correction.waitFor(); + await ordinaryAnchor(correction); + await correction.click(); + await page.getByRole("heading", { + name: "Corrected release evidence", + exact: true, + }) + .waitFor(); + const target = page.getByRole("link", { + name: `${entryTitle} →`, + exact: true, + }); + await ordinaryAnchor(target); + assertEquals(await target.getAttribute("href"), detailPath); + await target.click(); + await page.getByRole("heading", { name: entryTitle, exact: true }) + .waitFor(); + await page.getByRole("region", { name: "継続中の経緯", exact: true }) + .waitFor(); + }, + ); + + await t.step( + "missing and unavailable original sources do not prevent saved Journal reading", + async () => { + for ( + const state of [ + { + journal: "journal-release", + entry: "entry-00", + text: "元の会話は削除済み", + }, + { + journal: "journal-review", + entry: "entry-01", + text: "原資料は取得不能(存在・閲覧可否を確認できません)", + }, + ] + ) { + await page.goto( + `${base}${journalPath}?journal=${state.journal}&entry=${state.entry}`, + ); + await page.getByRole("heading", { + name: "Preserved Journal narrative", + exact: true, + }) + .waitFor(); + await page.getByRole("region", { name: "原資料", exact: true }) + .getByText(state.text, { + exact: true, + }).waitFor(); + assertEquals( + await page.getByRole("link", { + name: "元の会話を開く →", + exact: true, + }).count(), + 0, + ); + assertEquals(await page.locator(".related-list a").count(), 3); + } + await page.goto( + `${base}${journalPath}?journal=journal-planning&entry=entry-04`, + ); + await page.getByText("原資料の本文は未確認です。", { exact: true }) + .waitFor(); + const originalLink = page.getByRole("link", { + name: "元の会話を確認する →", + exact: true, + }); + await ordinaryAnchor(originalLink); + assertEquals( + await originalLink.getAttribute("href"), + "/w/memory-review/workers/W-fixture/console", + ); + await originalLink.scrollIntoViewIfNeeded(); + await capture(page, "retained-original-navigation-1440-light"); + }, + ); + + await t.step( + "empty, pending, unconnected, no search result and request failures remain distinct", + async () => { + for ( + const state of [ + { + path: + `/w/${fixture.workspaceId}/memory/empty-subject/journal?view=sessions`, + text: "Journalはまだありません。", + role: "status" as const, + }, + { + path: `${journalPath}?q=definitely-no-such-literal`, + text: "検索結果は0件です。", + role: "status" as const, + }, + { + path: `${journalPath}?q=fixture-failure`, + text: "出来事一覧を取得できません。", + role: "alert" as const, + }, + { + path: `${journalPath}?journal=journal-denied&entry=entry-00`, + text: "Journalエントリを取得できません", + role: "alert" as const, + }, + ] + ) { + await page.goto(`${base}${state.path}`); + await page.getByRole(state.role).filter({ hasText: state.text }) + .waitFor(); + assertEquals( + await page.locator(".entry-list a, .related-list a").count(), + 0, + ); + await assertNoPageWideOverflow(page); + } + await page.goto( + `${base}${subjectPath}/memory-resolved-0002?change_id=change-earlier`, + ); + await page.locator("#related-journal").getByText( + "対応するJournalは未接続", + { + exact: true, + }, + ).waitFor(); + assertEquals(await page.locator("#related-journal a").count(), 0); + assert( + !(await page.locator("#related-journal").textContent())?.includes( + "記録待ち", + ), + ); + assert(requests.some((r) => + r.payload?.action === "memory_sources" && + r.payload.selector.memory_id === "memory-resolved-0002" && + r.payload.selector.change_id === "change-earlier" && + r.payload.selector.candidate_id === fixture.candidateId + )); + await page.goto( + `${base}${subjectPath}/memory-sources-failure?change_id=change-current`, + ); + await page.getByRole("alert").filter({ + hasText: "Journal参照を取得できません。", + }) + .waitFor(); + await page.getByRole("article", { + name: "Committed Memory body", + exact: true, + }).waitFor(); + assertEquals( + await page.locator("#related-journal .journal-link-list a").count(), + 0, + ); + }, + ); + assertEquals(errors, []); + assertEquals( + requests.filter((r) => + r.method !== "GET" && + !(r.method === "POST" && r.path.endsWith("/journal-browser")) + ), + [], + ); + assertEquals( + requests.filter((r) => /history|restore|start/.test(r.path)), + [], + ); + for ( + const request of requests.filter((r) => + r.path.endsWith("/journal-browser") + ) + ) { + assertEquals(request.method, "POST"); + if (request.payload?.limit != null) { + assert(request.payload.limit > 0 && request.payload.limit <= 20); + } + if (request.payload?.action === "text") { + assert( + request.payload.max_bytes > 0 && request.payload.max_bytes <= 8192, + ); + } + } + await Deno.writeTextFile( + join(artifacts, "requests.json"), + JSON.stringify(requests, null, 2), + ); + await context.close(); + } finally { + await browser.close(); + } + } finally { + server.kill("SIGTERM"); + await server.status; + } +}); diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 2932d4c36..069b413e3 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -1,4 +1,5 @@ import { extname, join, normalize } from "jsr:@std/path@1.1.4"; +import { journalBrowserFixture } from "./journal_browser_fixture.ts"; const port = Number(Deno.args[0]); const buildRoot = Deno.args[1]; @@ -491,6 +492,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const foundSubject = subjects.find((item) => item.id === subjectId); if (!foundSubject) return json({}, 404); + if (parts.length === 2 && parts[1] === "journal-browser") { + return await journalBrowserFixture(request, subjectId); + } if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") { const payload = await request.json().catch(() => null) as { expected_behavior_md?: unknown; diff --git a/tools/web-ux/browser-tests/memory_document_harness.ts b/tools/web-ux/browser-tests/memory_document_harness.ts new file mode 100644 index 000000000..c19c8b747 --- /dev/null +++ b/tools/web-ux/browser-tests/memory_document_harness.ts @@ -0,0 +1,55 @@ +import { assertEquals } from "@std/assert"; +import type { Page } from "playwright"; + +export async function freePort(): Promise { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + return port; +} + +export async function waitForServer(url: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + const response = await fetch(url); + await response.body?.cancel(); + if (response.ok) return; + } catch { + // 所有するfixture serverの起動を有界に待つ。 + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`fixture server did not become ready: ${url}`); +} + +export async function mainScrollState(page: Page) { + return await page.locator("main").evaluate((main) => ({ + scrollTop: main.scrollTop, + scrollHeight: main.scrollHeight, + clientHeight: main.clientHeight, + })); +} + +export async function assertNoPageWideOverflow(page: Page): Promise { + assertEquals( + await page.evaluate(() => { + const document = (globalThis as any).document; + return document.documentElement.scrollWidth === + document.documentElement.clientWidth; + }), + true, + ); +} + +export async function assertMainOwnsVerticalScroll(page: Page): Promise { + const nested = await page.locator("main").evaluate((main) => + [...main.querySelectorAll("*")] + .filter((element) => { + const style = (globalThis as any).getComputedStyle(element); + return ["auto", "scroll"].includes(style.overflowY) && + element.scrollHeight > element.clientHeight + 1; + }) + .map((element) => `${element.tagName.toLowerCase()}.${element.className}`) + ); + assertEquals(nested, []); +} diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index a5cb76b78..c5ac54311 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -1,6 +1,13 @@ import { assert, assertEquals } from "@std/assert"; import { dirname, fromFileUrl, join } from "@std/path"; import { chromium, type Page } from "playwright"; +import { + assertMainOwnsVerticalScroll, + assertNoPageWideOverflow, + freePort, + mainScrollState, + waitForServer, +} from "./memory_document_harness.ts"; const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../.."); const workspaceRoot = join(repositoryRoot, "web/workspace"); @@ -19,33 +26,6 @@ const createInteractionArtifacts = join( "target/web-ux/t-692-browser-interactions", ); -async function freePort(): Promise { - const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); - const port = (listener.addr as Deno.NetAddr).port; - listener.close(); - return port; -} - -async function waitForServer(url: string): Promise { - for (let attempt = 0; attempt < 200; attempt += 1) { - try { - if ((await fetch(url)).ok) return; - } catch { - // Retry until the bounded deadline while the owned server starts. - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(`fixture server did not become ready: ${url}`); -} - -async function mainScrollState(page: Page) { - return await page.locator("main").evaluate((main) => ({ - scrollTop: main.scrollTop, - scrollHeight: main.scrollHeight, - clientHeight: main.clientHeight, - })); -} - async function markerIsInsideMain(page: Page): Promise { return await page.evaluate(() => { const document = (globalThis as any).document; @@ -60,29 +40,6 @@ async function markerIsInsideMain(page: Page): Promise { }); } -async function assertNoPageWideOverflow(page: Page): Promise { - assertEquals( - await page.evaluate(() => { - const document = (globalThis as any).document; - return document.documentElement.scrollWidth === document.documentElement.clientWidth; - }), - true, - ); -} - -async function assertMainOwnsVerticalScroll(page: Page): Promise { - const nested = await page.locator("main").evaluate((main) => - [...main.querySelectorAll("*")] - .filter((element) => { - const style = (globalThis as any).getComputedStyle(element); - return ["auto", "scroll"].includes(style.overflowY) && - element.scrollHeight > element.clientHeight + 1; - }) - .map((element) => `${element.tagName.toLowerCase()}.${element.className}`) - ); - assertEquals(nested, []); -} - async function openSubjectPage( page: Page, baseUrl: string, @@ -324,9 +281,18 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco assertEquals(await detailPage.getByText("Memory information design source").count(), 1); assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1); assertEquals(await detailPage.getByRole("heading", { name: "Change history" }).count(), 1); - assertEquals(await detailPage.getByText("Memory change change-current", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Memory change change-earlier", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Memory change change-original", { exact: true }).count(), 0); + assertEquals( + await detailPage.getByText("Memory change change-current", { exact: true }).count(), + 1, + ); + assertEquals( + await detailPage.getByText("Memory change change-earlier", { exact: true }).count(), + 1, + ); + assertEquals( + await detailPage.getByText("Memory change change-original", { exact: true }).count(), + 0, + ); await detailPage.getByRole("navigation", { name: "Change history pages" }).getByRole( "link", { name: "Next page →" }, @@ -367,7 +333,10 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco ).count(), 1, ); - assertEquals(await paginationPage.getByText("Memory fingerprint", { exact: true }).count(), 0); + assertEquals( + await paginationPage.getByText("Memory fingerprint", { exact: true }).count(), + 0, + ); assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0); assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0); await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 6947c8c57..c9ebdca89 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/console_image_visibility_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/journal_browser_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/console_image_visibility_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/journal-browser-dark.json b/tools/web-ux/scenarios/journal-browser-dark.json new file mode 100644 index 000000000..fd5c3ce7b --- /dev/null +++ b/tools/web-ux/scenarios/journal-browser-dark.json @@ -0,0 +1,280 @@ +{ + "schemaVersion": 1, + "id": "journal-browser-dark", + "title": "Journal browser UI fixture — dark", + "baseUrl": "http://127.0.0.1:15287", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "HTTP-only Journal UI fixture", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "sessions", + "label": "Session journals", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?view=sessions", + "goal": "Choose among saved Session journals and distinguish pending work from source presence.", + "dataState": "Two Sessions, one with unreflected sources; deleted versus unavailable originals; bounded next page.", + "ready": { + "kind": "selector", + "selector": ".session-list > li" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Multiple Sessions" + }, + { + "id": "source-state", + "label": "Missing and unavailable originals", + "interaction": [ + { + "action": "click", + "selector": ".session-list li:first-child summary" + } + ] + } + ] + }, + { + "id": "list", + "label": "Literal search results", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=release%20%5Ba%2Bb%5D%20%25_", + "goal": "Find literal text across Sessions and continue a bounded search.", + "dataState": "Sixteen matching events across two Sessions with fixed/open states and a continuation.", + "ready": { + "kind": "selector", + "selector": ".entry-list > li" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Literal search results" + } + ] + }, + { + "id": "detail-missing", + "label": "Saved Journal with deleted original", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-release&entry=entry-00", + "goal": "Read narrative, correction and related Memory without restoring a Worker or original Session.", + "dataState": "Open event, three of eight related Memories, correcting entry; original conversation is deleted.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=detail] .related-list a" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Narrative and open event" + }, + { + "id": "related", + "label": "Correction and related Memory preview", + "interaction": [ + { + "action": "click", + "selector": "#related-memory-title" + } + ] + }, + { + "id": "source", + "label": "Deleted original; saved narrative independent", + "interaction": [ + { + "action": "click", + "selector": "section[aria-label=原資料] h2" + } + ] + } + ] + }, + { + "id": "detail-unavailable", + "label": "Journal with unavailable original", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-review&entry=entry-01", + "goal": "Distinguish inability to check the original from confirmed deletion.", + "dataState": "Fixed event remains readable, but original presence/permission is unavailable.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=detail] .related-list a" + }, + "capturePoints": [ + { + "id": "source", + "label": "Unavailable original, not deleted", + "interaction": [ + { + "action": "click", + "selector": "section[aria-label=原資料] h2" + } + ] + } + ] + }, + { + "id": "memory", + "label": "Memory links back to exact Journal events", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001?change_id=change-current", + "goal": "Identify Journal targets by title and date, with bounded continuation and separate original-source status.", + "dataState": "Three of eight persisted candidate links, including missing/unavailable originals and reconsideration.", + "ready": { + "kind": "selector", + "selector": "#related-journal .journal-link-list a" + }, + "capturePoints": [ + { + "id": "related", + "label": "Journal title/date preview", + "interaction": [ + { + "action": "click", + "selector": "#related-journal-heading" + } + ] + }, + { + "id": "source", + "label": "Originals separate from Journal links", + "interaction": [ + { + "action": "click", + "selector": "section[aria-label=原資料] h2" + } + ] + } + ] + }, + { + "id": "memory-unconnected", + "label": "Unconnected Memory", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-resolved-0002?change_id=change-earlier", + "goal": "Recognize a missing persisted connection without fabricating a target or pending reason.", + "dataState": "Immutable historical change with insufficient_source; no Journal anchors.", + "ready": { + "kind": "selector", + "selector": "#related-journal .empty-copy" + }, + "capturePoints": [ + { + "id": "unconnected", + "label": "Unconnected is not pending", + "interaction": [ + { + "action": "click", + "selector": "#related-journal-heading" + } + ] + } + ] + }, + { + "id": "no-journal", + "label": "No saved Journal", + "path": "/w/memory-review/memory/empty-subject/journal?view=sessions", + "goal": "Distinguish no saved Journal from pending, no search results and read failure.", + "dataState": "Successful empty Session-journal page.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=list] [role=status]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "No saved Journal" + } + ] + }, + { + "id": "no-search-result", + "label": "No literal search matches", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=definitely-no-such-literal", + "goal": "Keep the query visible when search succeeds with zero results.", + "dataState": "Successful empty search, not an unavailable Journal.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=list] [role=status]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "No literal search matches" + } + ] + }, + { + "id": "denied", + "label": "Journal read denied", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-denied&entry=entry-00", + "goal": "Show a read failure rather than inventing an empty or deleted Journal.", + "dataState": "Intentional fixture HTTP 403; verifies error presentation only, not backend authorization.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=detail] [role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Journal read denied" + } + ] + }, + { + "id": "request-failure", + "label": "Journal list unavailable", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=fixture-failure", + "goal": "Keep request failure distinct from zero matching entries.", + "dataState": "Intentional fixture HTTP 503 with the search query retained.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=list] [role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Journal list unavailable" + } + ] + } + ], + "processes": [ + { + "id": "memory-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/memory_document_fixture_server.ts", + "15287", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15287/health" + } + ] +} diff --git a/tools/web-ux/scenarios/journal-browser-light.json b/tools/web-ux/scenarios/journal-browser-light.json new file mode 100644 index 000000000..df719dd7a --- /dev/null +++ b/tools/web-ux/scenarios/journal-browser-light.json @@ -0,0 +1,280 @@ +{ + "schemaVersion": 1, + "id": "journal-browser-light", + "title": "Journal browser UI fixture — light", + "baseUrl": "http://127.0.0.1:15287", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "HTTP-only Journal UI fixture", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "sessions", + "label": "Session journals", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?view=sessions", + "goal": "Choose among saved Session journals and distinguish pending work from source presence.", + "dataState": "Two Sessions, one with unreflected sources; deleted versus unavailable originals; bounded next page.", + "ready": { + "kind": "selector", + "selector": ".session-list > li" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Multiple Sessions" + }, + { + "id": "source-state", + "label": "Missing and unavailable originals", + "interaction": [ + { + "action": "click", + "selector": ".session-list li:first-child summary" + } + ] + } + ] + }, + { + "id": "list", + "label": "Literal search results", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=release%20%5Ba%2Bb%5D%20%25_", + "goal": "Find literal text across Sessions and continue a bounded search.", + "dataState": "Sixteen matching events across two Sessions with fixed/open states and a continuation.", + "ready": { + "kind": "selector", + "selector": ".entry-list > li" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Literal search results" + } + ] + }, + { + "id": "detail-missing", + "label": "Saved Journal with deleted original", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-release&entry=entry-00", + "goal": "Read narrative, correction and related Memory without restoring a Worker or original Session.", + "dataState": "Open event, three of eight related Memories, correcting entry; original conversation is deleted.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=detail] .related-list a" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Narrative and open event" + }, + { + "id": "related", + "label": "Correction and related Memory preview", + "interaction": [ + { + "action": "click", + "selector": "#related-memory-title" + } + ] + }, + { + "id": "source", + "label": "Deleted original; saved narrative independent", + "interaction": [ + { + "action": "click", + "selector": "section[aria-label=原資料] h2" + } + ] + } + ] + }, + { + "id": "detail-unavailable", + "label": "Journal with unavailable original", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-review&entry=entry-01", + "goal": "Distinguish inability to check the original from confirmed deletion.", + "dataState": "Fixed event remains readable, but original presence/permission is unavailable.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=detail] .related-list a" + }, + "capturePoints": [ + { + "id": "source", + "label": "Unavailable original, not deleted", + "interaction": [ + { + "action": "click", + "selector": "section[aria-label=原資料] h2" + } + ] + } + ] + }, + { + "id": "memory", + "label": "Memory links back to exact Journal events", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001?change_id=change-current", + "goal": "Identify Journal targets by title and date, with bounded continuation and separate original-source status.", + "dataState": "Three of eight persisted candidate links, including missing/unavailable originals and reconsideration.", + "ready": { + "kind": "selector", + "selector": "#related-journal .journal-link-list a" + }, + "capturePoints": [ + { + "id": "related", + "label": "Journal title/date preview", + "interaction": [ + { + "action": "click", + "selector": "#related-journal-heading" + } + ] + }, + { + "id": "source", + "label": "Originals separate from Journal links", + "interaction": [ + { + "action": "click", + "selector": "section[aria-label=原資料] h2" + } + ] + } + ] + }, + { + "id": "memory-unconnected", + "label": "Unconnected Memory", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-resolved-0002?change_id=change-earlier", + "goal": "Recognize a missing persisted connection without fabricating a target or pending reason.", + "dataState": "Immutable historical change with insufficient_source; no Journal anchors.", + "ready": { + "kind": "selector", + "selector": "#related-journal .empty-copy" + }, + "capturePoints": [ + { + "id": "unconnected", + "label": "Unconnected is not pending", + "interaction": [ + { + "action": "click", + "selector": "#related-journal-heading" + } + ] + } + ] + }, + { + "id": "no-journal", + "label": "No saved Journal", + "path": "/w/memory-review/memory/empty-subject/journal?view=sessions", + "goal": "Distinguish no saved Journal from pending, no search results and read failure.", + "dataState": "Successful empty Session-journal page.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=list] [role=status]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "No saved Journal" + } + ] + }, + { + "id": "no-search-result", + "label": "No literal search matches", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=definitely-no-such-literal", + "goal": "Keep the query visible when search succeeds with zero results.", + "dataState": "Successful empty search, not an unavailable Journal.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=list] [role=status]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "No literal search matches" + } + ] + }, + { + "id": "denied", + "label": "Journal read denied", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-denied&entry=entry-00", + "goal": "Show a read failure rather than inventing an empty or deleted Journal.", + "dataState": "Intentional fixture HTTP 403; verifies error presentation only, not backend authorization.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=detail] [role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Journal read denied" + } + ] + }, + { + "id": "request-failure", + "label": "Journal list unavailable", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=fixture-failure", + "goal": "Keep request failure distinct from zero matching entries.", + "dataState": "Intentional fixture HTTP 503 with the search query retained.", + "ready": { + "kind": "selector", + "selector": "[data-journal-view=list] [role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Journal list unavailable" + } + ] + } + ], + "processes": [ + { + "id": "memory-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/memory_document_fixture_server.ts", + "15287", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15287/health" + } + ] +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 7924a15b2..64adf61b4 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,8 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", "test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts", diff --git a/web/workspace/src/lib/workspace/memory/OriginalSource.svelte b/web/workspace/src/lib/workspace/memory/OriginalSource.svelte new file mode 100644 index 000000000..0c132a647 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/OriginalSource.svelte @@ -0,0 +1,13 @@ + +{#if source.state === 'missing'} +

元の会話は削除済み

+{:else if source.history_href} +

原資料の本文は未確認です。

+

元の会話を確認する →

+{:else} +

原資料は取得不能(存在・閲覧可否を確認できません)

+{/if} + diff --git a/web/workspace/src/lib/workspace/memory/ReadingPosition.svelte b/web/workspace/src/lib/workspace/memory/ReadingPosition.svelte new file mode 100644 index 000000000..b5c42ab06 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/ReadingPosition.svelte @@ -0,0 +1,16 @@ + diff --git a/web/workspace/src/lib/workspace/memory/journal-api.ts b/web/workspace/src/lib/workspace/memory/journal-api.ts new file mode 100644 index 000000000..b67c6c1e0 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/journal-api.ts @@ -0,0 +1,432 @@ +import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; + +export type JournalMetadata = { + id: string; + subject_id: string; + source_session_id: string; + title: string; + current_position_bytes: number; + process_order: number; + process_byte: number; + state_token: string; + created_at: string; + updated_at: string; +}; +export type JournalEntry = { + id: string; + journal_id: string; + position: number; + state: "open" | "fixed"; + title: string; + correction_refs: string[]; + content_token: string; + created_at: string; + updated_at: string; + fixed_at: string | null; +}; +export type SourceStatus = { + state: "missing" | "unavailable"; + history_href: string | null; +}; +export type JournalPage = { items: T[]; next: string | null }; +export type TextPage = { + text: string; + next_byte: number | null; + content_token: string; +}; +export type MemorySelector = { + memory_id: string; + change_id: string; + candidate_id: string; +}; +export type RelatedCursor = MemorySelector & { source_index: number }; +export type SourceLink = { + source_index: number; + original_source: "unknown" | "missing" | "available"; + connection: { state: "unconnected"; reason: string } | { + state: "linked"; + journal_id: string; + entry_id: string; + target: "available" | "unavailable" | "erased"; + reconsideration: boolean; + }; +}; +export type MemorySources = { + selector: MemorySelector; + sources: { + link: SourceLink; + entry: JournalEntry | null; + original_source: SourceStatus; + }[]; + total_sources: number; + next_source_index: number | null; +}; +export type JournalResponses = { + sessions: JournalPage< + { + journal: JournalMetadata; + unreflected_sources: boolean; + original_source: SourceStatus; + } + >; + entries: JournalPage; + entry: { + journal: JournalMetadata; + entry: JournalEntry; + correction_targets: JournalEntry[]; + original_source: SourceStatus; + }; + text: TextPage; + corrections: JournalPage; + memory_sources: MemorySources; + related_memory: { + items: { + related: { + selector: MemorySelector; + source_index: number; + reconsideration: boolean; + }; + claim: string; + }[]; + next: RelatedCursor | null; + }; +}; +export type JournalRequest = + | { action: "sessions"; after: string | null; limit: number } + | { + action: "entries"; + journal_id: string | null; + query: string | null; + after: string | null; + limit: number; + } + | { action: "entry"; journal_id: string; entry_id: string } + | { + action: "text"; + journal_id: string; + entry_id: string | null; + field: "body" | "ongoing" | "current_position"; + expected_token: string | null; + byte_offset: number; + max_bytes: number; + } + | { + action: "corrections"; + journal_id: string; + entry_id: string; + after: string | null; + limit: number; + } + | { + action: "memory_sources"; + selector: MemorySelector; + source_offset: number; + limit: number; + } + | { + action: "related_memory"; + journal_id: string; + entry_id: string; + after: RelatedCursor | null; + limit: number; + }; + +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid Journal object"); + } + return value as Record; +} +function text(value: unknown, max = 16384): string { + if ( + typeof value !== "string" || new TextEncoder().encode(value).length > max + ) throw new Error("Invalid Journal text"); + return value; +} +function number(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error("Invalid Journal number"); + } + return value; +} +function boolean(value: unknown): boolean { + if (typeof value !== "boolean") throw new Error("Invalid Journal boolean"); + return value; +} +function nullable(value: unknown, parse: (value: unknown) => T): T | null { + return value == null ? null : parse(value); +} +function choice(value: unknown, values: readonly T[]): T { + if (typeof value !== "string" || !values.includes(value as T)) { + throw new Error("Invalid Journal state"); + } + return value as T; +} +function array(value: unknown, parse: (value: unknown) => T, max = 32): T[] { + if (!Array.isArray(value) || value.length > max) { + throw new Error("Invalid Journal list"); + } + return value.map(parse); +} +const id = (value: unknown) => text(value, 512); +function selector(value: unknown): MemorySelector { + const v = object(value); + return { + memory_id: id(v.memory_id), + change_id: id(v.change_id), + candidate_id: id(v.candidate_id), + }; +} +export function parseJournalEntry(value: unknown): JournalEntry { + const v = object(value); + return { + id: id(v.id), + journal_id: id(v.journal_id), + title: text(v.title), + position: number(v.position), + state: choice(v.state, ["open", "fixed"]), + correction_refs: array(v.correction_refs, id, 128), + content_token: id(v.content_token), + created_at: text(v.created_at), + updated_at: text(v.updated_at), + fixed_at: nullable(v.fixed_at, text), + }; +} +function metadata(value: unknown): JournalMetadata { + const v = object(value); + return { + id: id(v.id), + subject_id: id(v.subject_id), + source_session_id: id(v.source_session_id), + title: text(v.title), + current_position_bytes: number(v.current_position_bytes), + process_order: number(v.process_order), + process_byte: number(v.process_byte), + state_token: id(v.state_token), + created_at: text(v.created_at), + updated_at: text(v.updated_at), + }; +} +function sourceStatus(value: unknown): SourceStatus { + const v = object(value); + const href = nullable(v.history_href, text); + if ( + href !== null && + (!href.startsWith("/w/") || href.includes("\\") || + /[\u0000-\u001f]/.test(href)) + ) throw new Error("Invalid history link"); + return { + state: choice(v.state, ["missing", "unavailable"]), + history_href: href, + }; +} +function page(value: unknown, parse: (v: unknown) => T): JournalPage { + const v = object(value); + return { items: array(v.items, parse), next: nullable(v.next, text) }; +} +function sourceLink(value: unknown): SourceLink { + const v = object(value), c = object(v.connection); + const common = { + source_index: number(v.source_index), + original_source: choice( + v.original_source, + ["unknown", "missing", "available"] as const, + ), + }; + if (c.state === "unconnected") { + return { + ...common, + connection: { + state: "unconnected", + reason: choice(c.reason, [ + "not_reconciled", + "insufficient_source", + "source_not_admitted", + "ambiguous_source", + "journal_not_fixed", + ]), + }, + }; + } + if (c.state !== "linked") throw new Error("Invalid Journal connection"); + return { + ...common, + connection: { + state: "linked", + journal_id: id(c.journal_id), + entry_id: id(c.entry_id), + target: choice(c.target, ["available", "unavailable", "erased"]), + reconsideration: boolean(c.reconsideration), + }, + }; +} +export function parseJournalResponse( + value: unknown, + kind: K, + subjectId?: string, +): JournalResponses[K] { + const envelope = object(value); + if (envelope.kind !== kind) throw new Error("Unexpected Journal response"); + const v = object(envelope.value); + let result: JournalResponses[keyof JournalResponses]; + switch (kind) { + case "sessions": + result = page(v, (item) => { + const i = object(item); + const journal = metadata(i.journal); + if (subjectId && journal.subject_id !== subjectId) { + throw new Error("Journal Subject mismatch"); + } + return { + journal, + unreflected_sources: boolean(i.unreflected_sources), + original_source: sourceStatus(i.original_source), + }; + }); + break; + case "entries": + case "corrections": + result = page(v, parseJournalEntry); + break; + case "entry": { + const journal = metadata(v.journal); + const entry = parseJournalEntry(v.entry); + if ( + (subjectId && journal.subject_id !== subjectId) || + entry.journal_id !== journal.id + ) throw new Error("Journal identity mismatch"); + result = { + journal, + entry, + correction_targets: array(v.correction_targets, parseJournalEntry, 16), + original_source: sourceStatus(v.original_source), + }; + break; + } + case "text": + result = { + text: text(v.text), + next_byte: nullable(v.next_byte, number), + content_token: id(v.content_token), + }; + break; + case "memory_sources": + result = { + selector: selector(v.selector), + total_sources: number(v.total_sources), + next_source_index: nullable(v.next_source_index, number), + sources: array(v.sources, (source) => { + const s = object(source); + const link = sourceLink(s.link); + const entry = nullable(s.entry, parseJournalEntry); + if ( + entry && + (link.connection.state !== "linked" || + entry.id !== link.connection.entry_id || + entry.journal_id !== link.connection.journal_id) + ) throw new Error("Journal link mismatch"); + return { + link, + entry, + original_source: sourceStatus(s.original_source), + }; + }), + }; + break; + case "related_memory": + result = { + items: array(v.items, (item) => { + const i = object(item), r = object(i.related); + return { + related: { + selector: selector(r.selector), + source_index: number(r.source_index), + reconsideration: boolean(r.reconsideration), + }, + claim: text(i.claim), + }; + }), + next: nullable(v.next, (cursor) => { + const c = object(cursor); + return { ...selector(c), source_index: number(c.source_index) }; + }), + }; + break; + default: + throw new Error("Unknown Journal response"); + } + return result as JournalResponses[K]; +} +export function loadJournal( + fetchFn: typeof fetch, + workspaceId: string, + subjectId: string, + request: Extract, +) { + return loadJson( + fetchFn, + workspaceApiPath( + workspaceId, + `/subjektiv/subjects/${encodeURIComponent(subjectId)}/journal-browser`, + ), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }, + (value) => { + const parsed = parseJournalResponse(value, request.action, subjectId); + const identity: JournalRequest = request; + if (identity.action === "entry") { + const detail = parsed as JournalResponses["entry"]; + if ( + detail.entry.id !== identity.entry_id || + detail.journal.id !== identity.journal_id + ) throw new Error("Journal request identity mismatch"); + } + if (identity.action === "memory_sources") { + const links = parsed as MemorySources; + for ( + const key of ["memory_id", "change_id", "candidate_id"] as const + ) { + if (links.selector[key] !== identity.selector[key]) { + throw new Error( + "Memory selector mismatch", + ); + } + } + } + return parsed; + }, + { diagnosticLabel: "Journal API", maxResponseBytes: 65536 }, + ); +} +export function journalHref( + workspaceId: string, + subjectId: string, + values: Record = {}, +): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value != null && value !== "") params.set(key, value); + } + const suffix = params.toString(); + return `/w/${encodeURIComponent(workspaceId)}/memory/${ + encodeURIComponent(subjectId) + }/journal${suffix ? `?${suffix}` : ""}`; +} +export function boundedParam( + params: URLSearchParams, + key: string, + max = 16384, +): string | null { + const value = params.get(key); + return value && new TextEncoder().encode(value).length <= max ? value : null; +} +export function offsetParam(params: URLSearchParams, key: string): number { + const v = params.get(key); + return v && /^(0|[1-9][0-9]{0,14})$/.test(v) && + Number.isSafeInteger(Number(v)) + ? Number(v) + : 0; +} diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte index 6c6278582..03c8f5d76 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte @@ -170,6 +170,8 @@ Behavior editable + + {#if subject}
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte index 2772fddf5..9bb06f7bb 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte @@ -1,13 +1,20 @@ {#snippet children(resolved)} + { position = null; }} /> {/snippet} diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts index f68f0b549..67dbc8c5a 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts @@ -5,6 +5,11 @@ import { parseSubjektivMemoryReadResponse, parseSubjektivSubjectResponse, } from "#lib/workspace/memory/api.ts"; +import { + boundedParam, + loadJournal, + offsetParam, +} from "#lib/workspace/memory/journal-api.ts"; import { deferPage } from "#lib/workspace/navigation/deferred.ts"; import type { PageLoad } from "./$types"; @@ -33,65 +38,106 @@ function safeIntegerQuery( return String(parsed); } -export const load = deferPage(async ({ fetch, params, url }: Parameters[0]) => { - const subjectId = params.subjectId; - const memoryId = params.memoryId; - const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`; - const memoryPath = `${subjectPath}/memories/${encodeURIComponent(memoryId)}`; - const detailQuery = new URLSearchParams({ limit: "1000" }); - for ( - const [key, positive] of [ - ["offset", false], - ["byte_offset", false], - ] as const - ) { - const value = safeIntegerQuery(url.searchParams, key, positive); - if (value !== null) detailQuery.set(key, value); - } - const changeId = boundedCursor(url.searchParams.get("change_id"), 512); - if (changeId) detailQuery.set("change_id", changeId); - const evidenceCursor = boundedCursor( - url.searchParams.get("evidence_cursor"), - 512, - ); - if (evidenceCursor) detailQuery.set("evidence_cursor", evidenceCursor); - const changeCursor = boundedCursor(url.searchParams.get("change_cursor")); - const changeQuery = new URLSearchParams({ limit: "100" }); - if (changeCursor) changeQuery.set("cursor", changeCursor); +export const load = deferPage( + async ({ fetch, params, url }: Parameters[0]) => { + const subjectId = params.subjectId; + const memoryId = params.memoryId; + const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`; + const memoryPath = `${subjectPath}/memories/${ + encodeURIComponent(memoryId) + }`; + const detailQuery = new URLSearchParams({ limit: "1000" }); + for ( + const [key, positive] of [ + ["offset", false], + ["byte_offset", false], + ] as const + ) { + const value = safeIntegerQuery(url.searchParams, key, positive); + if (value !== null) detailQuery.set(key, value); + } + const changeId = boundedCursor(url.searchParams.get("change_id"), 512); + if (changeId) detailQuery.set("change_id", changeId); + const evidenceCursor = boundedCursor( + url.searchParams.get("evidence_cursor"), + 512, + ); + if (evidenceCursor) detailQuery.set("evidence_cursor", evidenceCursor); + const changeCursor = boundedCursor(url.searchParams.get("change_cursor")); + const changeQuery = new URLSearchParams({ limit: "100" }); + if (changeCursor) changeQuery.set("cursor", changeCursor); - const [subject, memory, changes] = await Promise.all([ - loadJson( - fetch, - workspaceApiPath(params.workspaceId, subjectPath), - undefined, - (value) => parseSubjektivSubjectResponse(value, subjectId), - MEMORY_API_LOAD_POLICY, - ), - loadJson( - fetch, - `${workspaceApiPath(params.workspaceId, memoryPath)}?${detailQuery}`, - undefined, - (value) => parseSubjektivMemoryReadResponse(value, memoryId), - MEMORY_API_LOAD_POLICY, - ), - loadJson( - fetch, - `${ - workspaceApiPath(params.workspaceId, `${memoryPath}/changes`) - }?${changeQuery}`, - undefined, - (value) => parseSubjektivMemoryListChangesResponse(value, memoryId), - MEMORY_API_LOAD_POLICY, - ), - ]); + const [subject, memory, changes] = await Promise.all([ + loadJson( + fetch, + workspaceApiPath(params.workspaceId, subjectPath), + undefined, + (value) => parseSubjektivSubjectResponse(value, subjectId), + MEMORY_API_LOAD_POLICY, + ), + loadJson( + fetch, + `${workspaceApiPath(params.workspaceId, memoryPath)}?${detailQuery}`, + undefined, + (value) => parseSubjektivMemoryReadResponse(value, memoryId), + MEMORY_API_LOAD_POLICY, + ), + loadJson( + fetch, + `${ + workspaceApiPath(params.workspaceId, `${memoryPath}/changes`) + }?${changeQuery}`, + undefined, + (value) => parseSubjektivMemoryListChangesResponse(value, memoryId), + MEMORY_API_LOAD_POLICY, + ), + ]); - return { - workspaceId: params.workspaceId, - subjectId, - memoryId, - changeCursor, - subject, - memory, - changes, - }; -}); + const candidateOffset = offsetParam( + url.searchParams, + "journal_candidate_offset", + ); + const sourceOffset = offsetParam(url.searchParams, "journal_source_offset"); + const selectedCandidate = boundedParam( + url.searchParams, + "journal_candidate", + 512, + ); + const candidates = memory.data?.source_candidates ?? []; + const selected = selectedCandidate + ? candidates.filter((c) => c.candidate_id === selectedCandidate) + : candidates.slice(candidateOffset, candidateOffset + 3); + // 関連はimmutable change/candidateの保存済み参照だけ。本文取得の失敗から未接続を推測しない。 + const journalSources = memory.data + ? await Promise.all(selected.map(async (candidate) => ({ + candidateId: candidate.candidate_id, + result: await loadJournal(fetch, params.workspaceId, subjectId, { + action: "memory_sources", + selector: { + memory_id: memoryId, + change_id: memory.data!.change_id, + candidate_id: candidate.candidate_id, + }, + source_offset: selectedCandidate ? sourceOffset : 0, + limit: selectedCandidate ? 20 : 3, + }), + }))) + : []; + + return { + workspaceId: params.workspaceId, + journalSources, + journalCandidateNext: + !selectedCandidate && candidateOffset + 3 < candidates.length + ? candidateOffset + 3 + : null, + journalQuery: url.searchParams.toString(), + subjectId, + memoryId, + changeCursor, + subject, + memory, + changes, + }; + }, +); diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/PageContent.svelte index 55a6f2f64..5875829db 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/PageContent.svelte @@ -1,6 +1,8 @@ + + {#snippet children(resolved)} + + { position = null; }} /> + {/snippet} + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/journal/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/journal/+page.ts new file mode 100644 index 000000000..c65572628 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/journal/+page.ts @@ -0,0 +1,165 @@ +import { + boundedParam, + loadJournal, + offsetParam, + type RelatedCursor, +} from "#lib/workspace/memory/journal-api.ts"; +import { deferPage } from "#lib/workspace/navigation/deferred.ts"; +import type { PageLoad } from "./$types"; + +export const load = deferPage( + async ({ fetch, params, url }: Parameters[0]) => { + const workspaceId = params.workspaceId, + subjectId = params.subjectId, + p = url.searchParams; + const journalId = boundedParam(p, "journal", 512), + entryId = boundedParam(p, "entry", 512); + const view = p.get("view") === "sessions" ? "sessions" : "entries"; + const query = boundedParam(p, "q", 1024), after = boundedParam(p, "after"); + const call = [3]["action"]>( + request: Extract[3], { action: K }>, + ) => loadJournal(fetch, workspaceId, subjectId, request); + const base = { + workspaceId, + subjectId, + journalId, + entryId, + query, + view, + after, + urlQuery: p.toString(), + }; + if (!journalId || !entryId) { + const sessions = view === "sessions" + ? await call({ action: "sessions", after, limit: 20 }) + : null; + const entries = view === "entries" + ? await call({ + action: "entries", + journal_id: journalId, + query, + after, + limit: 20, + }) + : null; + const position = journalId + ? await call({ + action: "text", + journal_id: journalId, + entry_id: null, + field: "current_position", + expected_token: boundedParam(p, "position_token"), + byte_offset: offsetParam(p, "position_byte"), + max_bytes: 8192, + }) + : null; + return { + ...base, + mode: "list" as const, + sessions, + entries, + position, + detail: null, + }; + } + const entry = await call({ + action: "entry", + journal_id: journalId, + entry_id: entryId, + }); + if (!entry.data) { + return { + ...base, + mode: "detail" as const, + sessions: null, + entries: null, + position: null, + detail: { + entry, + body: null, + ongoing: null, + position: null, + corrections: null, + targets: [], + targetOffset: 0, + targetNext: null, + related: null, + }, + }; + } + const observed = entry.data; + const read = ( + field: "body" | "ongoing" | "current_position", + key: string, + ) => + call({ + action: "text", + journal_id: journalId, + entry_id: field === "current_position" ? null : entryId, + field, + expected_token: boundedParam(p, `${key}_token`) ?? + (field === "current_position" + ? observed.journal.state_token + : observed.entry.content_token), + byte_offset: offsetParam(p, `${key}_byte`), + max_bytes: 8192, + }); + let relatedAfter: RelatedCursor | null = null; + const cursor = boundedParam(p, "related_after"); + if (cursor) { + try { + const v = JSON.parse(cursor); + if ( + typeof v.memory_id === "string" && typeof v.change_id === "string" && + typeof v.candidate_id === "string" && + Number.isSafeInteger(v.source_index) && v.source_index >= 0 + ) relatedAfter = v; + } catch { /* 不正cursorは先頭へ戻し、参照を推測しない。 */ } + } + const targetOffset = offsetParam(p, "target_offset"); + const targets = entry.data.correction_targets.slice( + targetOffset, + targetOffset + 3, + ); + const [body, ongoing, position, corrections, related] = await Promise.all([ + read("body", "body"), + read("ongoing", "ongoing"), + read("current_position", "position"), + call({ + action: "corrections", + journal_id: journalId, + entry_id: entryId, + after: boundedParam(p, "corrections_after"), + limit: 10, + }), + call({ + action: "related_memory", + journal_id: journalId, + entry_id: entryId, + after: relatedAfter, + limit: p.get("related") === "all" ? 20 : 3, + }), + ]); + return { + ...base, + mode: "detail" as const, + sessions: null, + entries: null, + position: null, + detail: { + entry, + body, + ongoing, + position, + corrections, + related, + targets, + targetOffset, + targetNext: targetOffset + targets.length < + entry.data.correction_targets.length + ? targetOffset + targets.length + : null, + }, + }; + }, +); diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/journal/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/journal/PageContent.svelte new file mode 100644 index 000000000..fb61d78fa --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/journal/PageContent.svelte @@ -0,0 +1,138 @@ + +{entry?.title ?? 'Journal'} · Yoi Workspace + +{#snippet textSection(title: string, key: string, result: ApiResult | null | undefined)} +
+

{title}

+ {#if result?.data} + {#if result.data.text.trim()}{:else}

{title}は空です。

{/if} + {#if result.data.next_byte !== null}次の本文区間 →{/if} + {#if new URLSearchParams(data.urlQuery).has(`${key}_byte`)}本文の先頭へ{/if} + {:else if result?.error} + + {/if} +
+{/snippet} +{#snippet entryList(items: JournalEntry[])} +
    + {#each items as item (`${item.journal_id}:${item.id}`)} +
  1. + {item.title} +

    {item.state} · 出来事 {item.position}{#if item.correction_refs.length} · 訂正あり{/if}

    +
  2. + {/each} +
+{/snippet} + +
+ ← SubjectのMemory +

{entry?.title ?? 'Journal'}

{#if entry}

{entry.state} · 出来事 {entry.position} ·

{/if}
+

Journalは活動の経緯を記録した文章です。原発言・tool結果そのものではなく、Memoryの直接の抽出元や主張の証明ではありません。

+ {#if data.mode === 'detail'} + {#if record && entry && detail} + + {@render textSection('出来事の本文', 'body', detail.body)} + {#if entry.state === 'open'}{@render textSection('継続中の経緯', 'ongoing', detail.ongoing)}{/if} +

訂正と対象

+ {#if entry.correction_refs.length === 0}

この出来事からの訂正対象はありません。

{/if} + {#each detail.targets as target} +

訂正対象: {target.title} →

+ {/each} + {#if detail.targetNext !== null}すべて見る(訂正対象の続き) →{/if} + {#if detail.corrections?.data} + {#if detail.corrections.data.items.length}

この出来事を訂正する記録

{@render entryList(detail.corrections.data.items)}{:else}

{new URLSearchParams(data.urlQuery).has('corrections_after') ? 'このページに訂正はありません。' : 'この出来事への訂正はありません。'}

{/if} + {#if detail.corrections.data.next}訂正の続き →{/if} + {:else if detail.corrections?.error}{/if} +
+
+ {#if detail.related?.data} + {#if detail.related.data.items.length === 0}

{new URLSearchParams(data.urlQuery).has('related_after') ? 'このページに関連Memoryはありません。' : '関連するMemoryは未接続です。'}

{/if} + + {#if detail.related.data.next}すべて見る(関連Memoryの続き) →{/if} + {#if new URLSearchParams(data.urlQuery).has('related_after')}関連Memoryの先頭へ{/if} + {:else if detail.related?.error}{/if} +
+

原資料

+ {@render textSection('Sessionの現在地', 'position', detail.position)} + {:else} + + {/if} + {:else} + + {#if data.view === 'entries'} +
+ {#if data.journalId}{/if} +
+
+ {#if data.journalId}{@render textSection('Sessionの現在地', 'position', data.position)}

出来事の経緯

{/if} + {#if data.entries?.data} + {#if data.entries.data.items.length}{@render entryList(data.entries.data.items)} + {:else if data.query}

検索結果は0件です。

+ {:else if data.after}

このページの出来事はありません。

+ {:else}

保存済みの出来事はありません。SessionのJournal・記録状態を見る →

{/if} + {#if data.entries.data.next}次のページ →{/if} + {:else if data.entries?.error}{/if} + {:else} + {#if data.sessions?.data} + {#if data.sessions.data.items.length === 0}

{data.after ? 'このページのJournalはありません。' : 'Journalはまだありません。'}

{/if} +
    {#each data.sessions.data.items as session (session.journal.id)}
  • +

    {session.journal.title} →

    +

    + {#if session.unreflected_sources}

    記録待ち・未反映の原資料があります。

    {:else}

    保存された原資料に未反映は確認されていません。

    {/if} +
    原資料とSession識別情報{session.journal.source_session_id}
    +
  • {/each}
+ {#if data.sessions.data.next}次のページ →{/if} + {:else if data.sessions?.error}{/if} + {/if} + {#if data.after}一覧の先頭へ{/if} + {/if} +
+ diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/journal-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/journal-page.browser.spec.ts new file mode 100644 index 000000000..b6f45b091 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/journal-page.browser.spec.ts @@ -0,0 +1,322 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, within } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import JournalPage from "./[subjectId]/journal/PageContent.svelte"; +import MemoryPage from "./[subjectId]/[memoryId]/PageContent.svelte"; + +afterEach(cleanup); +const result = (data: T | null, error: string | null = null) => ({ + data, + error, +}); +const entry = (id = "entry-1", title = "Passkeyへ統一した") => ({ + id, + journal_id: "journal-1", + position: 2, + state: "fixed", + title, + correction_refs: [], + content_token: "content-1", + created_at: "2026-10-16T12:00:00Z", + updated_at: "2026-10-16T12:00:00Z", + fixed_at: "2026-10-16T12:00:00Z", +}); +const metadata = { + id: "journal-1", + subject_id: "subject-1", + source_session_id: "deleted-session", + title: "認証方式を整理したSession", + current_position_bytes: 10, + process_order: 4, + process_byte: 10, + state_token: "state-1", + created_at: "2026-10-16T12:00:00Z", + updated_at: "2026-10-16T12:00:00Z", +}; +const source = { state: "missing", history_href: null }; +const base = { + workspaceId: "workspace-1", + subjectId: "subject-1", + journalId: null, + entryId: null, + query: null, + view: "entries", + after: null, + urlQuery: "", + mode: "list", + sessions: null, + entries: result({ + items: [entry(), { + ...entry("entry-2", "別Sessionの出来事"), + journal_id: "journal-2", + }], + next: "page-2", + }), + position: null, + detail: null, +}; +const text = (body: string) => + result({ text: body, next_byte: null, content_token: "token-1" }); +function detail() { + return { + ...base, + mode: "detail", + journalId: "journal-1", + entryId: "entry-1", + urlQuery: "journal=journal-1&entry=entry-1", + detail: { + entry: result({ + journal: metadata, + entry: entry(), + original_source: source, + }), + body: text("保存された本文は原履歴から独立しています。"), + ongoing: text(""), + position: text("現在の位置"), + targets: [{ + ...entry("earlier", "訂正される判断"), + journal_id: "earlier-journal", + }], + targetNext: null, + corrections: result({ + items: [entry("later", "後から修正した")], + next: null, + }), + related: result({ + items: [{ + claim: "復旧経路を先に確認する", + related: { + selector: { + memory_id: "memory-1", + change_id: "change-1", + candidate_id: "candidate-1", + }, + source_index: 0, + reconsideration: true, + }, + }], + next: { + memory_id: "memory-1", + change_id: "change-1", + candidate_id: "candidate-1", + source_index: 1, + }, + }), + }, + }; +} + +test("recent entries preserve literal search/filter/cursor in navigation and directly identify each entry", () => { + render( + JournalPage, + { + data: { + ...base, + query: "認証 & 復旧", + urlQuery: "q=%E8%AA%8D%E8%A8%BC+%26+%E5%BE%A9%E6%97%A7", + }, + } as never, + ); + expect((screen.getByRole("searchbox") as HTMLInputElement).value).toBe( + "認証 & 復旧", + ); + expect( + screen.getByRole("link", { name: /別Sessionの出来事/ }).getAttribute( + "href", + ), + ).toContain("journal=journal-2&entry=entry-2"); + const next = new URL( + screen.getByRole("link", { name: "次のページ →" }).getAttribute("href")!, + "https://example.com", + ); + expect(next.searchParams.get("q")).toBe("認証 & 復旧"); + expect(next.searchParams.get("after")).toBe("page-2"); +}); + +test.each([ + [ + { ...base, entries: result({ items: [], next: null }) }, + "保存済みの出来事はありません。", + false, + ], + [ + { ...base, query: "not-found", entries: result({ items: [], next: null }) }, + "検索結果は0件です。", + false, + ], + [ + { ...base, entries: result(null, "HTTP 403") }, + "出来事一覧を取得できません。HTTP 403", + true, + ], + [ + { ...base, view: "sessions", sessions: result({ items: [], next: null }) }, + "Journalはまだありません。", + false, + ], +])( + "empty, zero search, denied and no Journal remain distinct", + (data, message, alert) => { + render(JournalPage, { data } as never); + expect(screen.getByText(message, { exact: false })).not.toBeNull(); + expect(screen.queryByRole("alert") !== null).toBe(alert); + expect(screen.queryByText("記録待ち・未反映の原資料があります。")) + .toBeNull(); + }, +); + +test("sessions display confirmed unreflected source state, not a fabricated recording completion", () => { + render(JournalPage, { + data: { + ...base, + view: "sessions", + sessions: result({ + items: [{ + journal: metadata, + unreflected_sources: true, + original_source: source, + }, { + journal: { ...metadata, id: "journal-2", title: "別のSession" }, + unreflected_sources: false, + original_source: { state: "unavailable", history_href: null }, + }], + next: "sessions-2", + }), + }, + } as never); + expect(screen.getByText("記録待ち・未反映の原資料があります。")).not + .toBeNull(); + expect(screen.getByText("保存された原資料に未反映は確認されていません。")).not + .toBeNull(); + expect( + screen.getByRole("link", { name: "別のSession →" }).getAttribute("href"), + ).toContain("journal=journal-2"); + expect(screen.getByText("元の会話は削除済み")).not.toBeNull(); + expect(screen.getByText(/原資料は取得不能/)).not.toBeNull(); +}); + +test("deleted originals do not hide saved entry, correction links or titled immutable Memory links", () => { + render(JournalPage, { data: detail() } as never); + expect(screen.getByText("保存された本文は原履歴から独立しています。")).not + .toBeNull(); + expect(screen.getByRole("heading", { name: "Sessionの現在地" })).not + .toBeNull(); + expect( + screen.getByRole("link", { name: "復旧経路を先に確認する →" }).getAttribute( + "href", + ), + ).toBe("/w/workspace-1/memory/subject-1/memory-1?change_id=change-1"); + expect( + screen.getByRole("link", { name: "復旧経路を先に確認する →" }).getAttribute( + "target", + ), + ).toBeNull(); + expect( + screen.getByRole("link", { name: "訂正される判断 →" }).getAttribute("href"), + ).toContain("journal=earlier-journal&entry=earlier"); + expect( + screen.getByRole("link", { name: /後から修正した/ }).getAttribute("href"), + ).toContain("entry=later"); + expect(screen.getByText("元の会話は削除済み")).not.toBeNull(); + expect( + screen.getByRole("link", { name: /すべて見る(関連Memory/ }).getAttribute( + "href", + ), + ).toContain("related_after="); + expect(screen.queryByText(/source Journal/)).toBeNull(); +}); + +test("an unavailable related API is not treated as unconnected and does not replace saved body", () => { + const data = detail(); + data.detail.related = result(null, "HTTP 403") as never; + render(JournalPage, { data } as never); + expect(screen.getByRole("alert").textContent).toContain( + "関連するMemoryを取得できません。", + ); + expect(screen.getByText("保存された本文は原履歴から独立しています。")).not + .toBeNull(); + expect(screen.queryByText("関連するMemoryは未接続です。")).toBeNull(); +}); + +test("Memory shows dated entry links separately from original sources and limits preview via explicit continuation", () => { + const selector = { + memory_id: "memory-1", + change_id: "change-1", + candidate_id: "candidate-1", + }; + const linked = { + link: { + source_index: 0, + original_source: "missing", + connection: { + state: "linked", + journal_id: "journal-1", + entry_id: "entry-1", + target: "available", + reconsideration: false, + }, + }, + entry: entry(), + original_source: source, + }; + render(MemoryPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + memoryId: "memory-1", + subject: result(null), + changes: result({ items: [], has_more: false }), + memory: result({ + memory_id: "memory-1", + change_id: "change-1", + current_change_id: "change-1", + claim: "復旧経路の学び", + kind: "lesson", + state: "active", + body_md: "原履歴から抽出したMemory", + source_candidates: [], + derived_from: [], + created_at: entry().created_at, + updated_at: entry().updated_at, + }), + journalSources: [{ + candidateId: "candidate-1", + result: result({ + selector, + sources: [linked, { + ...linked, + link: { + ...linked.link, + source_index: 1, + connection: { state: "unconnected", reason: "not_reconciled" }, + }, + entry: null, + original_source: { state: "unavailable", history_href: null }, + }], + total_sources: 5, + next_source_index: 2, + }), + }], + journalCandidateNext: null, + journalQuery: "change_id=change-1", + }, + } as never); + const related = screen.getByRole("region", { name: "関連するJournal" }); + const link = within(related).getByRole("link", { name: /Passkeyへ統一した/ }); + expect(link.textContent).toMatch(/2026|10/); + expect(link.getAttribute("href")).toContain( + "journal=journal-1&entry=entry-1", + ); + expect(link.getAttribute("target")).toBeNull(); + expect(within(related).getByText("対応するJournalは未接続")).not.toBeNull(); + expect(within(related).queryByText("元の会話は削除済み")).toBeNull(); + const original = screen.getByRole("region", { name: "原資料" }); + expect(within(original).getByText("元の会話は削除済み")).not.toBeNull(); + expect(within(original).getByText(/原資料は取得不能/)).not.toBeNull(); + expect( + within(related).getByRole("link", { name: /すべて見る/ }).getAttribute( + "href", + ), + ).toContain("journal_source_offset=2"); + expect(screen.queryByText(/記録待ち/)).toBeNull(); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/original-source.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/original-source.browser.spec.ts new file mode 100644 index 000000000..da91d9270 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/original-source.browser.spec.ts @@ -0,0 +1,22 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import OriginalSource from "#lib/workspace/memory/OriginalSource.svelte"; + +afterEach(cleanup); +test("a retained navigation option does not attest original bytes or imply deletion", () => { + render(OriginalSource, { + source: { + state: "unavailable", + history_href: "/w/workspace/workers/W-1/console", + }, + }); + expect(screen.getByText("原資料の本文は未確認です。")).not.toBeNull(); + expect( + screen.getByRole("link", { name: "元の会話を確認する →" }).getAttribute( + "href", + ), + ).toBe("/w/workspace/workers/W-1/console"); + expect(screen.queryByText("元の会話は削除済み")).toBeNull(); + expect(screen.queryByText(/原資料は取得不能/)).toBeNull(); +}); diff --git a/web/workspace/test/journal-backend-contract.ts b/web/workspace/test/journal-backend-contract.ts new file mode 100644 index 000000000..d82117c3b --- /dev/null +++ b/web/workspace/test/journal-backend-contract.ts @@ -0,0 +1,64 @@ +// 実router/store試験がserializeした7 actionの応答を、本番Web parserと呼出契約へ通す。 +// HTTP・認証・保存はRust側、ここではその実応答のWeb受理を検証する。画面のE2Eではない。 +import { + type JournalRequest, + loadJournal, +} from "../src/lib/workspace/memory/journal-api.ts"; +const path = Deno.args[0]; +if (!path) { + throw new Error( + "Expected the captured journal_browser_web_contract test output path", + ); +} +const log = await Deno.readTextFile(path); +const marker = "JOURNAL_BROWSER_CONTRACT "; +const records = log.split("\n").filter((line) => line.includes(marker)).map(( + line, +) => JSON.parse(line.slice(line.indexOf(marker) + marker.length))); +const actions = new Set(); +for (const record of records) { + const request = record.request as JournalRequest; + let seen = false; + const fetcher = ((url: string, init: RequestInit) => { + if ( + url !== + `/api/w/${record.workspace_id}/subjektiv/subjects/${record.subject_id}/journal-browser` + ) throw new Error("Web endpoint differs from actual API"); + if ( + init.method !== "POST" || + JSON.stringify(JSON.parse(String(init.body))) !== JSON.stringify(request) + ) throw new Error("Web request differs from actual API"); + seen = true; + return Promise.resolve(Response.json(record.response)); + }) as typeof fetch; + const parsed = await loadJournal( + fetcher, + record.workspace_id, + record.subject_id, + request, + ); + if (!seen || parsed.error || !parsed.data) { + throw new Error( + `${request.action}: ${parsed.error ?? "No parsed response"}`, + ); + } + actions.add(request.action); +} +for ( + const action of [ + "sessions", + "entries", + "entry", + "text", + "corrections", + "memory_sources", + "related_memory", + ] +) { + if (!actions.has(action)) { + throw new Error(`No actual serialized response for ${action}`); + } +} +console.log( + `Actual Backend responses accepted by production Web contract: ${records.length} records, ${actions.size} actions`, +); diff --git a/web/workspace/tests/journal-api.test.ts b/web/workspace/tests/journal-api.test.ts new file mode 100644 index 000000000..903475248 --- /dev/null +++ b/web/workspace/tests/journal-api.test.ts @@ -0,0 +1,235 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} +function assertThrows(fn: () => void): void { + try { + fn(); + } catch { + return; + } + throw new Error("Expected rejection"); +} +import { + boundedParam, + journalHref, + loadJournal, + offsetParam, + parseJournalResponse, +} from "../src/lib/workspace/memory/journal-api.ts"; + +// transport/parserの局所契約。認可と保存接続はRustの実router/store試験が所有する。 +const selector = { + memory_id: "memory", + change_id: "change", + candidate_id: "candidate", +}; +const entry = { + id: "entry", + journal_id: "journal", + position: 1, + state: "fixed", + title: "認証方式を整理した", + correction_refs: [], + content_token: "token", + created_at: "2026-10-16T00:00:00Z", + updated_at: "2026-10-16T00:00:00Z", + fixed_at: null, +}; +const journal = { + id: "journal", + subject_id: "subject", + source_session_id: "session", + title: "認証のSession", + current_position_bytes: 0, + process_order: 1, + process_byte: 0, + state_token: "token", + created_at: entry.created_at, + updated_at: entry.updated_at, +}; +const original = { state: "missing", history_href: null }; +Deno.test("Journal parser preserves missing originals independently of linked saved entries", () => { + const value = { + selector, + sources: [{ + link: { + source_index: 0, + original_source: "missing", + connection: { + state: "linked", + journal_id: "journal", + entry_id: "entry", + target: "available", + reconsideration: true, + }, + }, + entry, + original_source: original, + }], + total_sources: 8, + next_source_index: 1, + }; + const parsed = parseJournalResponse( + { kind: "memory_sources", value }, + "memory_sources", + ); + assertEquals(parsed.sources[0].entry?.title, entry.title); + assertEquals(parsed.sources[0].original_source.state, "missing"); + assertEquals(parsed.next_source_index, 1); + assertThrows(() => + parseJournalResponse({ + kind: "memory_sources", + value: { + ...value, + sources: [{ ...value.sources[0], entry: { ...entry, id: "other" } }], + }, + }, "memory_sources") + ); +}); +Deno.test("Journal parser rejects wrong Subject, response kind, unbounded lists and unsafe history hrefs", () => { + const value = { + journal, + entry, + correction_targets: [{ + ...entry, + journal_id: "another-journal", + id: "another-entry", + }], + original_source: original, + }; + assertEquals( + parseJournalResponse({ kind: "entry", value }, "entry", "subject") + .correction_targets[0].journal_id, + "another-journal", + ); + assertThrows(() => + parseJournalResponse({ kind: "entry", value }, "entry", "other-subject") + ); + assertThrows(() => parseJournalResponse({ kind: "text", value }, "entry")); + assertThrows(() => + parseJournalResponse({ + kind: "entries", + value: { items: Array(33).fill(entry), next: null }, + }, "entries") + ); + assertThrows(() => + parseJournalResponse({ + kind: "entry", + value: { + ...value, + original_source: { + state: "available", + history_href: "javascript:alert(1)", + }, + }, + }, "entry") + ); + assertThrows(() => + parseJournalResponse({ + kind: "entry", + value: { ...value, entry: { ...entry, state: "recorded" } }, + }, "entry") + ); + assertThrows(() => + parseJournalResponse({ + kind: "text", + value: { + text: "x".repeat(16385), + next_byte: null, + content_token: "token", + }, + }, "text") + ); +}); +Deno.test("Journal transport reads immutable links without reconciling and rejects a mismatched selector", async () => { + const requests: { path: string; request: unknown }[] = []; + const fetcher = ((path: string, init: RequestInit) => { + requests.push({ path, request: JSON.parse(String(init.body)) }); + return Promise.resolve( + Response.json({ + kind: "memory_sources", + value: { + selector, + sources: [], + total_sources: 0, + next_source_index: null, + }, + }), + ); + }) as typeof fetch; + const request = { + action: "memory_sources" as const, + selector, + source_offset: 3, + limit: 20, + }; + assertEquals( + (await loadJournal(fetcher, "workspace", "subject", request)).error, + null, + ); + assertEquals(requests, [{ + path: "/api/w/workspace/subjektiv/subjects/subject/journal-browser", + request, + }]); + const mismatch = await loadJournal(fetcher, "workspace", "subject", { + ...request, + selector: { ...selector, change_id: "other" }, + }); + assertEquals(mismatch.data, null); + assertEquals(mismatch.error, "Journal API response is invalid"); +}); +Deno.test("Journal HTTP denial and transport failure remain failures, not empty pages", async () => { + for ( + const [fetcher, status] of [ + [ + () => Promise.resolve(new Response("not authorized", { status: 403 })), + 403, + ], + [() => Promise.reject(new Error("offline")), undefined], + ] as const + ) { + const value = await loadJournal( + fetcher as typeof fetch, + "workspace", + "subject", + { + action: "entries", + journal_id: null, + query: null, + after: null, + limit: 20, + }, + ); + assertEquals(value.data, null); + assertEquals(value.status, status); + assertEquals(value.error !== null, true); + } +}); +Deno.test("Journal links retain exact selected entry and bounded URL offsets", () => { + const url = new URL( + journalHref("workspace", "subject/a", { + journal: "journal", + entry: "entry&selected", + q: "日本語", + }), + "https://example.com", + ); + assertEquals(url.pathname, "/w/workspace/memory/subject%2Fa/journal"); + assertEquals(url.searchParams.get("entry"), "entry&selected"); + assertEquals( + boundedParam(new URLSearchParams({ q: "あ".repeat(5) }), "q", 14), + null, + ); + assertEquals(offsetParam(new URLSearchParams({ offset: "-1" }), "offset"), 0); + assertEquals( + offsetParam(new URLSearchParams({ offset: "27" }), "offset"), + 27, + ); +});