feat: browse Subject Journals and persistent Memory links

This commit is contained in:
2026-10-18 02:03:51 +09:00
parent 5ee42b8db8
commit 75b1557d63
30 changed files with 4594 additions and 120 deletions
+181
View File
@@ -0,0 +1,181 @@
//! Bounded saved-Journal browser projections. No history fetch, reconciliation or execution.
use super::store::{check_host, entry, fit_page, journal, summary};
use super::*;
use rusqlite::params;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EntryCursor {
scope: String,
subject: String,
journal: Option<String>,
query: Option<String>,
correcting: Option<String>,
created_at: String,
id: String,
}
fn invalid(message: &str) -> JournalError {
JournalError::InvalidInput(message.into())
}
impl SubjektivStore {
pub fn journal_entry_summary(
&self,
host: &JournalHostContext,
journal_id: &str,
entry_id: &str,
) -> JournalResult<JournalEntrySummary> {
self.database.try_with_connection(|tx| {
check_host(self, tx, host, false)?;
journal(tx, host, journal_id, false)?;
summary(tx, entry(tx, journal_id, entry_id)?)
})
}
/// Correction targets are capped at sixteen by the writer contract, and may be in
/// another Journal of this Subject. Include actual Journal IDs rather than guessing.
pub fn journal_correction_targets(
&self,
host: &JournalHostContext,
journal_id: &str,
entry_id: &str,
) -> JournalResult<Vec<JournalEntrySummary>> {
self.database.try_with_connection(|tx| {
check_host(self, tx, host, false)?;
journal(tx, host, journal_id, false)?;
entry(tx, journal_id, entry_id)?;
let mut stmt = tx.prepare(
"SELECT e.journal_id,e.id FROM journal_corrections c
JOIN journal_entries e ON e.subject_id=c.subject_id AND e.id=c.target_id
WHERE c.subject_id=?1 AND c.entry_id=?2 ORDER BY e.created_at,e.id LIMIT 16",
)?;
let rows = stmt
.query_map(params![host.subject_id, entry_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
rows.into_iter()
.map(|(journal_id, id)| summary(tx, entry(tx, &journal_id, &id)?))
.collect()
})
}
/// Recent entries use immutable creation time and ID as a stable descending keyset.
/// Search is literal substring matching (not SQL LIKE wildcards), bounded to 256 bytes.
/// A correcting selector filters saved correction edges, not inferred text or intervals.
pub fn browse_journal_entries(
&self,
host: &JournalHostContext,
journal_id: Option<&str>,
query: Option<&str>,
correcting: Option<&str>,
after: Option<&str>,
limit: usize,
) -> JournalResult<JournalPage<JournalEntrySummary>> {
if !(1..=MAX_JOURNAL_PAGE).contains(&limit) {
return Err(invalid("page limit must be 1..32"));
}
if query.is_some_and(|q| q.len() > 256 || q.chars().any(char::is_control)) {
return Err(invalid(
"search query must be at most 256 bytes without controls",
));
}
if correcting.is_some() && journal_id.is_none() {
return Err(invalid("correction lookup requires a Journal"));
}
let cursor: Option<EntryCursor> = after
.map(|raw| {
if raw.len() > 4096 {
return Err(invalid("entry cursor exceeds byte limit"));
}
let cursor: EntryCursor =
serde_json::from_str(raw).map_err(|_| invalid("invalid entry cursor"))?;
if cursor.scope != host.scope_id
|| cursor.subject != host.subject_id
|| cursor.journal.as_deref() != journal_id
|| cursor.query.as_deref() != query
|| cursor.correcting.as_deref() != correcting
{
return Err(invalid(
"entry cursor belongs to another scope, Subject or filter",
));
}
Ok(cursor)
})
.transpose()?;
self.database.try_with_connection(|tx| {
check_host(self, tx, host, false)?;
if let Some(id) = journal_id {
journal(tx, host, id, false)?;
}
if let Some(id) = correcting {
entry(tx, journal_id.unwrap(), id)?;
}
let mut stmt = tx.prepare(
"SELECT e.journal_id,e.id,e.created_at FROM journal_entries e
WHERE e.subject_id=?1 AND (?4 IS NOT NULL OR ?2 IS NULL OR e.journal_id=?2)
AND (?3 IS NULL OR instr(lower(e.title),lower(?3))>0
OR instr(lower(e.body),lower(?3))>0 OR instr(lower(e.ongoing),lower(?3))>0)
AND (?4 IS NULL OR EXISTS(SELECT 1 FROM journal_corrections c
WHERE c.subject_id=?1 AND c.entry_id=e.id AND c.target_id=?4))
AND (?5 IS NULL OR (e.created_at,e.id)<(?5,?6))
ORDER BY e.created_at DESC,e.id DESC LIMIT ?7",
)?;
let rows = stmt
.query_map(
params![
host.subject_id,
journal_id,
query,
correcting,
cursor.as_ref().map(|c| &c.created_at),
cursor.as_ref().map(|c| &c.id),
limit + 1
],
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
},
)?
.collect::<Result<Vec<_>, _>>()?;
let more = rows.len() > limit;
let mut items = Vec::new();
for (journal_id_value, id, created_at) in rows.into_iter().take(limit) {
let next = serde_json::to_string(&EntryCursor {
scope: host.scope_id.clone(),
subject: host.subject_id.clone(),
journal: journal_id.map(str::to_owned),
query: query.map(str::to_owned),
correcting: correcting.map(str::to_owned),
created_at,
id: id.clone(),
})?;
items.push((next, summary(tx, entry(tx, &journal_id_value, &id)?)?));
}
fit_page(items, more)
})
}
/// True only for admitted source bytes which the durable checkpoint has not reflected.
/// False does not claim that all original Session activity was admitted or recorded.
pub fn journal_has_unreflected_sources(
&self,
host: &JournalHostContext,
journal_id: &str,
) -> JournalResult<bool> {
self.database.try_with_connection(|tx| {
check_host(self, tx, host, false)?;
journal(tx, host, journal_id, false)?;
Ok(tx.query_row(
"SELECT EXISTS(SELECT 1 FROM journal_sources s, json_each(s.accepted_json) b
WHERE s.journal_id=?1 AND json_extract(b.value,'$.end')>s.reflected_bytes)",
[journal_id],
|r| r.get(0),
)?)
})
}
}
+1
View File
@@ -40,6 +40,7 @@
pub mod binding;
pub use binding::JournalWriterBinding;
pub(crate) use binding::migrate as migrate_binding;
pub mod browser;
mod lifecycle;
pub mod memory_links;
pub(crate) use lifecycle::migrate as migrate_lifecycle;
+5 -2
View File
@@ -70,7 +70,10 @@ fn page_limit(limit: usize) -> JournalResult<usize> {
}
Ok(limit)
}
fn fit_page<T: Serialize>(rows: Vec<(String, T)>, mut more: bool) -> JournalResult<JournalPage<T>> {
pub(super) fn fit_page<T: Serialize>(
rows: Vec<(String, T)>,
mut more: bool,
) -> JournalResult<JournalPage<T>> {
let mut page = JournalPage {
items: Vec::new(),
next: None,
@@ -162,7 +165,7 @@ pub(super) fn journal(
}
Ok(j)
}
fn entry(tx: &Connection, journal_id: &str, id: &str) -> JournalResult<JournalEntry> {
pub(super) fn entry(tx: &Connection, journal_id: &str, id: &str) -> JournalResult<JournalEntry> {
let row = tx.query_row("SELECT id, journal_id, position, state, title, body, ongoing, source_end_json, created_at, updated_at, fixed_at FROM journal_entries WHERE journal_id=?1 AND id=?2", params![journal_id, id], |r| Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,u64>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?,r.get::<_,String>(6)?,r.get::<_,Option<String>>(7)?,r.get::<_,String>(8)?,r.get::<_,String>(9)?,r.get::<_,Option<String>>(10)?))).optional()?.ok_or_else(|| JournalError::NotFound(id.into()))?;
let mut stmt = tx.prepare(
"SELECT target_id FROM journal_corrections WHERE entry_id=?1 ORDER BY target_id",
+9 -2
View File
@@ -28,6 +28,7 @@ use subjektiv::api::*;
mod backend_job_profile;
mod subjektiv_job;
mod subjektiv_journal;
mod subjektiv_journal_browser;
mod subjektiv_memory_journal;
mod worker_operations;
mod worker_protocol;
@@ -3133,7 +3134,11 @@ async fn authorize_scoped_workspace_request(
));
}
}
if mutating || request.headers().contains_key(axum::http::header::UPGRADE) {
// The Journal browser uses POST only to carry bounded typed read selectors. Its
// action set is read-only and follows the same user read authority as Memory GET.
if (mutating && !subjektiv_journal_browser::is_read_path(&request_path, workspace_id))
|| request.headers().contains_key(axum::http::header::UPGRADE)
{
let workspace = api
.store
.get_workspace(workspace_id)
@@ -3309,7 +3314,8 @@ async fn authorize_workspace_api_request(
}
}
if !public_server_api
&& (mutating || request.headers().contains_key(axum::http::header::UPGRADE))
&& ((mutating && !subjektiv_journal_browser::is_read_path(&request_path, &workspace_id))
|| request.headers().contains_key(axum::http::header::UPGRADE))
{
let workspace = match api.store.get_workspace(&workspace_id).await {
Ok(workspace) => workspace,
@@ -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))
@@ -0,0 +1,429 @@
//! User-authenticated, read-only Web Journal API. Saved Journal and immutable Memory edges
//! never depend on a live source Worker, original history reads, or model execution.
use super::*;
use subjektiv::journal::memory_links::*;
use subjektiv::journal::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum BrowserRequest {
Sessions {
after: Option<String>,
limit: usize,
},
Entries {
journal_id: Option<String>,
query: Option<String>,
after: Option<String>,
limit: usize,
},
Entry {
journal_id: String,
entry_id: String,
},
Text {
journal_id: String,
entry_id: Option<String>,
field: JournalTextField,
expected_token: Option<String>,
byte_offset: usize,
max_bytes: usize,
},
Corrections {
journal_id: String,
entry_id: String,
after: Option<String>,
limit: usize,
},
MemorySources {
selector: MemoryJournalSelector,
source_offset: usize,
limit: usize,
},
RelatedMemory {
journal_id: String,
entry_id: String,
after: Option<RelatedMemoryCursor>,
limit: usize,
},
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub(super) enum BrowserResponse {
Sessions(JournalPage<BrowserSession>),
Entries(JournalPage<JournalEntrySummary>),
Entry(BrowserEntry),
Text(JournalTextPage),
Corrections(JournalPage<JournalEntrySummary>),
MemorySources(BrowserMemorySources),
RelatedMemory(BrowserRelatedMemories),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum SourceState {
Missing,
Unavailable,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct SourceStatus {
/// `missing` requires confirmed metadata absence; `unavailable` includes unknown readability.
pub state: SourceState,
/// Only supplied for a retained, exact Session mapping, under this user's Workspace authority.
/// A navigation option is not an attestation that all original bytes still exist.
pub history_href: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct BrowserSession {
pub journal: JournalMetadata,
pub unreflected_sources: bool,
pub original_source: SourceStatus,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct BrowserEntry {
pub journal: JournalMetadata,
pub entry: JournalEntrySummary,
pub correction_targets: Vec<JournalEntrySummary>,
pub original_source: SourceStatus,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct BrowserMemorySource {
pub link: MemoryJournalSourceLink,
pub entry: Option<JournalEntrySummary>,
pub original_source: SourceStatus,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct BrowserMemorySources {
pub selector: MemoryJournalSelector,
pub sources: Vec<BrowserMemorySource>,
pub total_sources: usize,
pub next_source_index: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct BrowserRelatedMemory {
pub related: RelatedMemory,
pub claim: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct BrowserRelatedMemories {
pub items: Vec<BrowserRelatedMemory>,
pub next: Option<RelatedMemoryCursor>,
}
fn domain(error: JournalError) -> Error {
match error {
JournalError::PermissionDenied(message) => Error::WorkspacePermissionDenied(message),
JournalError::Conflict(message) => Error::RepositoryConflict(message),
JournalError::InvalidInput(message) => Error::InvalidInput(message),
JournalError::NotFound(id) => {
Error::InvalidInput(format!("Journal record not found: {id}"))
}
JournalError::Subject(error) => subjektiv_store_error(error),
other => Error::Store(other.to_string()),
}
}
fn source_status(
api: &WorkspaceApi,
store: &crate::subjektiv::SubjektivStore,
subject_id: &str,
session: Option<&str>,
) -> SourceStatus {
let unavailable = || SourceStatus {
state: SourceState::Unavailable,
history_href: None,
};
let Some(session) = session else {
return unavailable();
};
let Ok(Some(attr)) = store.session_attribution(session) else {
return unavailable();
};
if attr.subject_id != subject_id {
return unavailable();
}
let worker = RuntimeWorkerRef::new(&attr.runtime_id, &attr.worker_id);
let Ok(archive) =
api.config_store
.worker_session_archive(&api.config.workspace_id, &worker, session)
else {
return unavailable();
};
let Ok(registry) = api
.store
.get_worker_registry(&api.config.workspace_id, &worker)
else {
return unavailable();
};
if archive.is_none() && registry.is_none() {
return SourceStatus {
state: SourceState::Missing,
history_href: None,
};
}
// The console addresses the current Session only. Never point an old attributed
// Session at a different current conversation, or restore a Worker to manufacture a link.
let exact = registry
.as_ref()
.is_some_and(|r| r.session_ref.as_deref() == Some(session));
let history_href = if exact {
api.store
.worker_resource_key(&api.config.workspace_id, &worker)
.ok()
.flatten()
.map(|key| browser_worker_console_href(&api.config.workspace_id, &key))
} else {
None
};
SourceStatus {
state: SourceState::Unavailable,
history_href,
}
}
/// Exact route recognition for the normal user read-authority check. Do not include
/// any action that writes or reconciles state in this endpoint's tagged request type.
pub(super) fn is_read_path(path: &str, workspace: &str) -> bool {
path.strip_prefix(&format!("/api/w/{workspace}/subjektiv/subjects/"))
.and_then(|tail| tail.strip_suffix("/journal-browser"))
.is_some_and(|subject| !subject.is_empty() && !subject.contains('/'))
}
pub(super) async fn route(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
actor: Option<Extension<RequestActor>>,
Json(request): Json<BrowserRequest>,
) -> ApiResult<Json<BrowserResponse>> {
// Runtime signatures, singleton leases and Job grants are not user browser authority.
if actor.is_none() {
return Err(Error::WorkspacePermissionDenied(
"Journal browser requires user authentication".into(),
)
.into());
}
api.store
.db_executor()
.run(move || {
validate_workspace_scope(&api, &path.workspace_id)?;
let store = open_subjektiv_store(&api)?;
require_subjektiv_subject(&store, &path.subject_id)?;
let host =
JournalHostContext::validated_reader(&store, &path.subject_id).map_err(domain)?;
let response =
execute(&api, &store, &host, &path.subject_id, request).map_err(domain)?;
if serde_json::to_vec(&response)
.map_err(|e| Error::Store(e.to_string()))?
.len()
> MAX_JOURNAL_RESPONSE_BYTES
{
return Err(Error::InvalidInput(
"Journal browser response exceeds byte budget; request a smaller page".into(),
)
.into());
}
Ok(Json(response))
})
.await
}
fn execute(
api: &WorkspaceApi,
store: &crate::subjektiv::SubjektivStore,
host: &JournalHostContext,
subject_id: &str,
request: BrowserRequest,
) -> JournalResult<BrowserResponse> {
Ok(match request {
BrowserRequest::Sessions { after, limit } => {
if after.as_ref().is_some_and(|s| s.len() > 256) {
return Err(JournalError::InvalidInput(
"Journal cursor exceeds byte limit".into(),
));
}
let page = store.list_journals(host, after.as_deref(), limit)?;
let mut items = Vec::new();
for journal in page.items {
let unreflected_sources =
store.journal_has_unreflected_sources(host, &journal.id)?;
let original_source =
source_status(api, store, subject_id, Some(&journal.source_session_id));
items.push(BrowserSession {
journal,
unreflected_sources,
original_source,
});
}
BrowserResponse::Sessions(JournalPage {
items,
next: page.next,
})
}
BrowserRequest::Entries {
journal_id,
query,
after,
limit,
} => BrowserResponse::Entries(store.browse_journal_entries(
host,
journal_id.as_deref(),
query.as_deref(),
None,
after.as_deref(),
limit,
)?),
BrowserRequest::Entry {
journal_id,
entry_id,
} => {
let journal = store.read_journal(host, &journal_id)?;
let entry = store.journal_entry_summary(host, &journal_id, &entry_id)?;
let original_source =
source_status(api, store, subject_id, Some(&journal.source_session_id));
let correction_targets =
store.journal_correction_targets(host, &journal_id, &entry_id)?;
BrowserResponse::Entry(BrowserEntry {
journal,
entry,
correction_targets,
original_source,
})
}
BrowserRequest::Text {
journal_id,
entry_id,
field,
expected_token,
byte_offset,
max_bytes,
} => BrowserResponse::Text(store.read_journal_text(
host,
&journal_id,
entry_id.as_deref(),
field,
expected_token.as_deref(),
byte_offset,
max_bytes,
)?),
BrowserRequest::Corrections {
journal_id,
entry_id,
after,
limit,
} => BrowserResponse::Corrections(store.browse_journal_entries(
host,
Some(&journal_id),
None,
Some(&entry_id),
after.as_deref(),
limit,
)?),
BrowserRequest::MemorySources {
selector,
source_offset,
limit,
} => {
if !(1..=MAX_JOURNAL_PAGE).contains(&limit) {
return Err(JournalError::InvalidInput(
"page limit must be 1..32".into(),
));
}
let response = store.execute_memory_journal(
host,
MemoryJournalRequest::LinksPage {
selector: selector.clone(),
source_offset,
limit: limit.min(crate::subjektiv::MAX_STAGING_ANCHORS),
},
)?;
let MemoryJournalResponse::Links(page) = response else {
unreachable!()
};
let candidate = store.staging_candidate(subject_id, &selector.candidate_id)?;
let mut sources = Vec::new();
for mut link in page.sources {
let source_session = candidate
.as_ref()
.and_then(|c| c.source_refs.get(link.source_index))
.and_then(|s| s.session_id.as_deref());
let mut original_source = source_status(api, store, subject_id, source_session);
let entry = if let SourceConnection::Linked {
journal_id,
entry_id,
target: JournalTargetAvailability::Available,
..
} = &link.connection
{
let journal = store.read_journal(host, journal_id)?;
original_source =
source_status(api, store, subject_id, Some(&journal.source_session_id));
Some(store.journal_entry_summary(host, journal_id, entry_id)?)
} else {
None
};
if link.original_source == OriginalSourceAvailability::Missing {
// A saved missing-byte attestation does not prove deletion of the whole
// original conversation. Keep only independently confirmed metadata absence
// as `missing`; otherwise expose unavailable material without a history link.
if original_source.state != SourceState::Missing {
original_source.state = SourceState::Unavailable;
}
original_source.history_href = None;
}
if original_source.state == SourceState::Missing {
link.original_source = OriginalSourceAvailability::Missing;
}
sources.push(BrowserMemorySource {
link,
entry,
original_source,
});
}
BrowserResponse::MemorySources(BrowserMemorySources {
selector,
sources,
total_sources: page.total_sources,
next_source_index: page.next_source_index,
})
}
BrowserRequest::RelatedMemory {
journal_id,
entry_id,
after,
limit,
} => {
let response = store.execute_memory_journal(
host,
MemoryJournalRequest::Related {
journal_id,
entry_id,
after,
limit,
},
)?;
let MemoryJournalResponse::Related(page) = response else {
unreachable!()
};
let mut items = Vec::new();
for related in page.items {
let memory = store
.memory_change(
subject_id,
&related.selector.memory_id,
related.selector.change_id.clone(),
)?
.ok_or_else(|| JournalError::NotFound("related Memory change".into()))?;
items.push(BrowserRelatedMemory {
related,
claim: bounded_memory_excerpt(&memory.claim, 256),
});
}
BrowserResponse::RelatedMemory(BrowserRelatedMemories {
items,
next: page.next,
})
}
})
}
@@ -0,0 +1,784 @@
//! Real user-authenticated production router tests, sharing the Memory/Journal provenance fixture.
//! There is deliberately no Runtime adapter: reads must use saved state, not a source Worker.
use super::*;
use sha2::{Digest, Sha256};
fn subject(f: &Fixture) -> String {
f.store
.read_journal(&f.host, &f.journal_id)
.unwrap()
.subject_id
}
fn browser_path(subject: &str) -> String {
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/journal-browser")
}
fn browser_request(subject: &str, token: Option<&str>, body: serde_json::Value) -> Request<Body> {
let mut request = Request::builder()
.method("POST")
.uri(browser_path(subject))
.header(CONTENT_TYPE, "application/json");
if let Some(token) = token {
request = request.header("authorization", format!("Bearer {token}"));
}
request
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
async fn browser_call(f: &Fixture, token: &str, body: serde_json::Value) -> serde_json::Value {
let (status, bytes) = response(
build_router(f.api.clone()),
browser_request(&subject(f), Some(token), body),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&bytes)
);
assert!(bytes.len() <= MAX_JOURNAL_RESPONSE_BYTES);
serde_json::from_slice(&bytes).unwrap()
}
fn detail(f: &Fixture) -> serde_json::Value {
json!({"action":"entry","journal_id":f.journal_id,"entry_id":f.entry_id})
}
fn text(f: &Fixture, field: &str) -> serde_json::Value {
json!({"action":"text","journal_id":f.journal_id,
"entry_id": if field == "current_position" { None } else { Some(&f.entry_id) },
"field":field,"expected_token":null,"byte_offset":0,"max_bytes":8192})
}
/// Adds another real saved Session/Journal, optionally correcting an entry in the first Session.
fn later_journal(f: &Fixture, correcting: Vec<String>) -> (String, String) {
let session = Uuid::new_v4().to_string();
let attr = f
.store
.record_session_attribution(
crate::subjektiv::SubjectSessionAttribution::new(
subject(f),
RUNTIME,
&f.source_worker.worker_id,
&session,
)
.unwrap(),
)
.unwrap();
let writer = f
.store
.claim_journal_writer(&subject(f), RUNTIME, &f.source_worker.worker_id)
.unwrap();
let host = f.store.bind_journal_session(&writer, attr).unwrap();
let content = "A later Session revisits the constraint.";
let range = JournalSourceRange {
source: JournalSource {
session_id: session,
pointer: JournalSourcePointer {
segment_id: Uuid::new_v4(),
entry_ref: "E0".into(),
},
order: 0,
content_sha256: Sha256::digest(content.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect(),
total_bytes: content.len() as u64,
},
bytes: JournalByteRange {
start: 0,
end: content.len() as u64,
},
};
let id = f
.store
.accept_journal_sources(&host, "Later Session", &[range.clone()])
.unwrap();
let binding = subjektiv::api::JobAttemptBinding {
job_id: "later-fixture".into(),
attempt_id: "one".into(),
};
let generation = f
.store
.prepare_journal_generation(&host, &id, binding.clone(), vec![range.clone()])
.unwrap();
let job = JournalJobContext::validated(&f.store, &host, &generation.id, binding).unwrap();
subjektiv::journal::service::execute_bounded(
&f.store,
&job,
JournalJobRequest::ReadText {
read: JournalTextRead {
target: JournalTextTarget::Source {
pointer: range.source.pointer.clone(),
},
offset: 0,
limit: 8192,
content_token: None,
},
},
|_, _, _| {
Ok(subjektiv::journal::recording::JournalMaterial {
range: range.clone(),
text: content.into(),
})
},
)
.unwrap();
let entry = f
.store
.execute_journal(
&job,
JournalOperation {
request_id: "later-entry".into(),
expected_state: generation.initial_journal.state_token,
action: JournalAction::Create {
title: "Later qualification".into(),
body: content.into(),
ongoing: String::new(),
state: JournalEntryState::Fixed,
source_end: Some(range.source.pointer.clone()),
correction_refs: correcting,
},
covered_ranges: vec![range],
},
)
.unwrap()
.entry_id
.unwrap();
(id, entry)
}
#[tokio::test]
async fn journal_browser_user_auth_subject_boundary_and_read_only_actions() {
let f = Fixture::new().await;
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "journal-browser-user").await;
let app = build_router(f.api.clone());
assert_eq!(
response(app.clone(), browser_request(&subject(&f), None, detail(&f)))
.await
.0,
StatusCode::UNAUTHORIZED
);
let signed = runtime_source_request(
&f.identity,
Some(&f.worker.worker_id),
"POST",
&browser_path(&subject(&f)),
serde_json::to_vec(&detail(&f)).unwrap(),
);
assert_eq!(response(app.clone(), signed).await.0, StatusCode::FORBIDDEN);
let other = f
.store
.create_subject(crate::subjektiv::SubjectRole::new("other-browser").unwrap())
.unwrap();
for body in [
detail(&f),
text(&f, "body"),
json!({"action":"entries","journal_id":f.journal_id,"query":null,"after":null,"limit":10}),
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":10}),
json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":10}),
] {
let (status, bytes) =
response(app.clone(), browser_request(&other.id, Some(&token), body)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!String::from_utf8_lossy(&bytes).contains(JOURNAL_BODY));
}
let empty = response(
app.clone(),
browser_request(
&other.id,
Some(&token),
json!({"action":"sessions","after":null,"limit":10}),
),
)
.await;
assert_eq!(empty.0, StatusCode::OK);
assert!(
serde_json::from_slice::<serde_json::Value>(&empty.1).unwrap()["value"]["items"]
.as_array()
.unwrap()
.is_empty()
);
let unknown_action = browser_request(
&subject(&f),
Some(&token),
json!({"action":"reconcile","selector":f.selector}),
);
assert_eq!(
response(app, unknown_action).await.0,
StatusCode::UNPROCESSABLE_ENTITY
);
let entry = browser_call(&f, &token, detail(&f)).await;
assert_eq!(entry["value"]["entry"]["state"], "open");
assert_eq!(entry["value"]["entry"]["title"], "Choosing the approach");
assert_eq!(entry["value"]["original_source"]["state"], "unavailable");
}
#[tokio::test]
async fn journal_browser_multiple_sessions_recent_search_and_filter_bound_paging() {
let f = Fixture::new().await;
f.fix_initial();
let (later, later_entry) = later_journal(&f, vec![]);
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-pages").await;
let first = browser_call(
&f,
&token,
json!({"action":"sessions","after":null,"limit":1}),
)
.await;
assert_eq!(first["value"]["items"].as_array().unwrap().len(), 1);
assert_eq!(first["value"]["items"][0]["unreflected_sources"], true);
let second = browser_call(
&f,
&token,
json!({"action":"sessions","after":first["value"]["next"],"limit":1}),
)
.await;
assert_eq!(second["value"]["items"].as_array().unwrap().len(), 1);
assert_ne!(
first["value"]["items"][0]["journal"]["id"],
second["value"]["items"][0]["journal"]["id"]
);
assert!(second["value"]["next"].is_null());
let first = browser_call(
&f,
&token,
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":1}),
)
.await;
assert_eq!(first["value"]["items"][0]["id"], later_entry);
let second = browser_call(&f, &token, json!({"action":"entries","journal_id":null,"query":null,"after":first["value"]["next"],"limit":1})).await;
assert_eq!(second["value"]["items"][0]["id"], f.entry_id);
assert!(second["value"]["next"].is_null());
let filtered = browser_call(
&f,
&token,
json!({"action":"entries","journal_id":later,"query":"REVISITS","after":null,"limit":32}),
)
.await;
assert_eq!(filtered["value"]["items"].as_array().unwrap().len(), 1);
assert_eq!(filtered["value"]["items"][0]["id"], later_entry);
for query in ["no such event", "%"] {
let empty = browser_call(
&f,
&token,
json!({"action":"entries","journal_id":null,"query":query,"after":null,"limit":32}),
)
.await;
assert!(empty["value"]["items"].as_array().unwrap().is_empty());
}
let changed_filter = browser_request(
&subject(&f),
Some(&token),
json!({"action":"entries","journal_id":later,
"query":null,"after":first["value"]["next"],"limit":1}),
);
assert_eq!(
response(build_router(f.api.clone()), changed_filter)
.await
.0,
StatusCode::BAD_REQUEST
);
let other = f
.store
.create_subject(crate::subjektiv::SubjectRole::new("cursor-other").unwrap())
.unwrap();
let cross_subject = browser_request(
&other.id,
Some(&token),
json!({"action":"entries","journal_id":null,
"query":null,"after":first["value"]["next"],"limit":1}),
);
assert_eq!(
response(build_router(f.api.clone()), cross_subject).await.0,
StatusCode::BAD_REQUEST
);
}
#[tokio::test]
async fn journal_browser_text_bounds_tokens_position_and_ongoing_are_independent() {
let f = Fixture::new().await;
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-text").await;
assert_eq!(
browser_call(&f, &token, text(&f, "ongoing")).await["value"]["text"],
"Validation pending"
);
assert_eq!(
browser_call(&f, &token, text(&f, "current_position")).await["value"]["text"],
""
);
let mut request = text(&f, "body");
request["max_bytes"] = json!(8);
let first = browser_call(&f, &token, request.clone()).await;
assert_eq!(first["value"]["text"], &JOURNAL_BODY[..8]);
request["byte_offset"] = first["value"]["next_byte"].clone();
let (status, _) = response(
build_router(f.api.clone()),
browser_request(&subject(&f), Some(&token), request.clone()),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
request["expected_token"] = first["value"]["content_token"].clone();
let next = browser_call(&f, &token, request.clone()).await;
assert_eq!(next["value"]["text"], &JOURNAL_BODY[8..16]);
f.fix_initial(); // changes entry token, never tears the old continuation
assert_eq!(
response(
build_router(f.api.clone()),
browser_request(&subject(&f), Some(&token), request)
)
.await
.0,
StatusCode::CONFLICT
);
for body in [
json!({"action":"sessions","after":null,"limit":0}),
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":33}),
json!({"action":"entries","journal_id":null,"query":"x".repeat(257),"after":null,"limit":10}),
{
let mut body = text(&f, "body");
body["max_bytes"] = json!(16385);
body
},
] {
assert_eq!(
response(
build_router(f.api.clone()),
browser_request(&subject(&f), Some(&token), body)
)
.await
.0,
StatusCode::BAD_REQUEST
);
}
}
#[tokio::test]
async fn journal_browser_saved_bidirectional_links_titles_and_cross_session_corrections_survive_deletion()
{
let f = Fixture::new().await;
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-links").await;
let sources =
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":1});
let unconnected = browser_call(&f, &token, sources.clone()).await;
assert_eq!(
unconnected["value"]["sources"][0]["link"]["connection"]["state"],
"unconnected"
);
assert_eq!(
unconnected["value"]["sources"][0]["link"]["connection"]["reason"],
"not_reconciled"
);
f.fix_initial();
f.call(MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
})
.await;
let (later, correction) = later_journal(&f, vec![f.entry_id.clone()]);
let linked = browser_call(&f, &token, sources.clone()).await;
assert_eq!(
linked["value"]["sources"][0]["entry"]["title"],
"Choosing the approach"
);
assert!(linked["value"]["sources"][0]["entry"]["created_at"].is_string());
assert_eq!(
linked["value"]["sources"][0]["link"]["connection"]["entry_id"],
f.entry_id
);
let corrections = browser_call(
&f,
&token,
json!({"action":"corrections","journal_id":f.journal_id,
"entry_id":f.entry_id,"after":null,"limit":1}),
)
.await;
assert_eq!(corrections["value"]["items"][0]["id"], correction);
assert_eq!(corrections["value"]["items"][0]["journal_id"], later);
let corrected = browser_call(
&f,
&token,
json!({"action":"entry","journal_id":later,"entry_id":correction}),
)
.await;
assert_eq!(
corrected["value"]["correction_targets"][0]["id"],
f.entry_id
);
assert_eq!(
corrected["value"]["correction_targets"][0]["journal_id"],
f.journal_id
);
let related_request = json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":1});
let related = browser_call(&f, &token, related_request.clone()).await;
assert_eq!(
related["value"]["items"][0]["claim"],
"Check the constraint before choosing"
);
assert_eq!(
related["value"]["items"][0]["related"]["selector"]["change_id"],
f.selector.change_id
);
assert_eq!(
related["value"]["items"][0]["related"]["reconsideration"],
true
);
fs::remove_dir_all(&f.source_root).unwrap();
f.api
.store
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap();
let deleted = browser_call(&f, &token, detail(&f)).await;
assert_eq!(deleted["value"]["original_source"]["state"], "missing");
assert!(deleted["value"]["original_source"]["history_href"].is_null());
assert_eq!(
browser_call(&f, &token, text(&f, "body")).await["value"]["text"],
JOURNAL_BODY
);
let linked = browser_call(&f, &token, sources).await;
assert_eq!(
linked["value"]["sources"][0]["original_source"]["state"],
"missing"
);
assert_eq!(linked["value"]["sources"][0]["entry"]["id"], f.entry_id);
assert_eq!(
browser_call(&f, &token, related_request).await["value"]["items"][0]["claim"],
"Check the constraint before choosing"
);
}
#[tokio::test]
async fn journal_browser_history_navigation_requires_exact_session_mapping_not_registry_presence() {
let f = Fixture::new().await;
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-original").await;
let mut registry = f
.api
.store
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap()
.unwrap();
let initial = browser_call(&f, &token, detail(&f)).await;
assert!(initial["value"]["original_source"]["history_href"].is_null());
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
f.api.store.upsert_worker_registry(&registry).unwrap();
let exact = browser_call(&f, &token, detail(&f)).await;
let resource_key = f
.api
.store
.worker_resource_key(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap()
.unwrap();
assert_eq!(
exact["value"]["original_source"]["history_href"],
browser_worker_console_href(TEST_WORKSPACE_ID, &resource_key)
);
assert!(resource_key.starts_with("W-"));
// Metadata does not attest original bytes. A later Session must not be linked as the old one.
assert_eq!(exact["value"]["original_source"]["state"], "unavailable");
registry.session_ref = Some("different-session".into());
f.api.store.upsert_worker_registry(&registry).unwrap();
assert!(
browser_call(&f, &token, detail(&f)).await["value"]["original_source"]["history_href"]
.is_null()
);
}
#[tokio::test]
async fn journal_browser_many_saved_links_page_without_reconciliation_and_use_immutable_claims() {
let f = Fixture::new().await;
f.fix_initial();
let correction = f.add_correction();
f.call(MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
})
.await;
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-many-links").await;
let mut candidate = f
.store
.staging_candidate(&subject(&f), &f.selector.candidate_id)
.unwrap()
.unwrap();
candidate.id = "second-browser-candidate".into();
candidate.claim = "Another immutable lesson from both events".into();
let mut source = candidate.source_refs[0].clone();
source.evidence_id = Some(f.ranges[1].source.pointer.entry_ref.clone());
source.entry_range = Some([1, 1]);
let mut evidence = candidate.evidence[0].clone();
evidence.id = source.evidence_id.clone().unwrap();
evidence.entry_range = Some([1, 1]);
candidate.source_refs.push(source);
candidate.evidence.push(evidence);
let (candidate, _) = f
.store
.stage_candidate_with_attribution(
candidate,
f.store
.session_attribution(&f.ranges[0].source.session_id)
.unwrap()
.unwrap(),
)
.unwrap();
let (memory, _) = f
.store
.apply_candidate(
&subject(&f),
&candidate.id,
crate::subjektiv::MemoryChangeTarget::Create,
crate::subjektiv::MemoryDraft::active(
candidate.kind.clone(),
&candidate.claim,
"Saved Memory body",
&candidate.why_useful,
"Second adoption",
),
"Reviewed",
)
.unwrap();
let selector = MemoryJournalSelector {
memory_id: memory.id.clone(),
change_id: memory.change_id.clone(),
candidate_id: candidate.id.clone(),
};
let first = browser_call(
&f,
&token,
json!({"action":"memory_sources","selector":selector,"source_offset":0,"limit":1}),
)
.await;
assert_eq!(first["value"]["total_sources"], 2);
assert_eq!(first["value"]["next_source_index"], 1);
assert_eq!(
first["value"]["sources"][0]["link"]["connection"]["reason"],
"not_reconciled"
);
// Reading a browser page never reconciles. Only the existing domain fixture explicitly does so.
f.call(MemoryJournalRequest::Reconcile {
selector: selector.clone(),
})
.await;
let first = browser_call(
&f,
&token,
json!({"action":"memory_sources","selector":selector,"source_offset":0,"limit":1}),
)
.await;
let next = browser_call(&f,&token,json!({"action":"memory_sources","selector":selector,"source_offset":first["value"]["next_source_index"],"limit":1})).await;
assert_eq!(first["value"]["sources"][0]["entry"]["id"], f.entry_id);
assert_eq!(next["value"]["sources"][0]["entry"]["id"], correction);
assert!(next["value"]["next_source_index"].is_null());
f.store
.revise_memory(
&subject(&f),
&memory.id,
memory.change_id,
crate::subjektiv::MemoryDraft::active(
candidate.kind,
"Changed current claim",
"New body",
"Updated lesson",
"Later qualification",
),
)
.unwrap();
let first = browser_call(&f,&token,json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":1})).await;
let next = browser_call(&f,&token,json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":first["value"]["next"],"limit":1})).await;
assert!(first["value"]["next"].is_object());
assert!(next["value"]["next"].is_null());
let claims = [
first["value"]["items"][0]["claim"].as_str().unwrap(),
next["value"]["items"][0]["claim"].as_str().unwrap(),
];
assert!(claims.contains(&"Another immutable lesson from both events"));
assert!(claims.contains(&"Check the constraint before choosing"));
assert!(!claims.contains(&"Changed current claim"));
}
#[tokio::test]
async fn journal_browser_read_post_has_memory_get_user_authority_but_no_mutation_authority() {
let f = Fixture::new().await;
let token = seed_test_api_token(f.api.store.as_ref(), "browser-reader-not-owner");
// Ordinary authenticated read authority is the same as Memory GET, even though browser
// selectors use POST. This exception is exact-route only; no mutations gain authority.
assert_eq!(
browser_call(&f, &token, detail(&f)).await["value"]["entry"]["id"],
f.entry_id
);
let request = Request::builder()
.method("POST")
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"))
.header(CONTENT_TYPE, "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(r#"{"role":"not-allowed"}"#))
.unwrap();
assert_eq!(
response(build_router(f.api.clone()), request).await.0,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn journal_browser_saved_missing_bytes_do_not_claim_original_conversation_deleted() {
let f = Fixture::new().await;
f.fix_initial();
f.call(MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
})
.await;
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-missing-byte").await;
let mut registry = f
.api
.store
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap()
.unwrap();
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
f.api.store.upsert_worker_registry(&registry).unwrap();
let sources =
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":1});
let present = browser_call(&f, &token, sources.clone()).await;
assert!(present["value"]["sources"][0]["original_source"]["history_href"].is_string());
// The Host attests only a missing byte of the referenced source. Original Session
// metadata and material still exist; neither this attestation nor its saved link
// projection certifies deletion of the conversation as a whole.
let mut missing_byte = f.ranges[0].clone();
missing_byte.bytes.end = missing_byte.bytes.start + 1;
f.store
.mark_journal_sources_unavailable(&f.host, &f.journal_id, &[missing_byte])
.unwrap();
assert!(f.source_root.exists());
assert!(
f.api
.store
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap()
.is_some()
);
let partial = browser_call(&f, &token, sources.clone()).await;
let source = &partial["value"]["sources"][0];
assert_eq!(source["link"]["original_source"], "missing");
assert_eq!(source["original_source"]["state"], "unavailable");
assert!(source["original_source"]["history_href"].is_null());
assert_eq!(source["entry"]["id"], f.entry_id);
assert_eq!(
browser_call(&f, &token, text(&f, "body")).await["value"]["text"],
JOURNAL_BODY
);
// Only independently confirmed registry/archive absence changes the conversation
// status to missing. The same saved Journal and immutable link remain readable.
fs::remove_dir_all(&f.source_root).unwrap();
f.api
.store
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap();
let deleted = browser_call(&f, &token, sources).await;
assert_eq!(
deleted["value"]["sources"][0]["original_source"]["state"],
"missing"
);
assert!(deleted["value"]["sources"][0]["original_source"]["history_href"].is_null());
assert_eq!(deleted["value"]["sources"][0]["entry"]["id"], f.entry_id);
}
/// Emits actual authenticated router/store responses for the production Web contract runner.
/// The paired `test:journal:backend-contract` task validates all action payloads with the real
/// Web parser; this Rust test remains usable without a JavaScript runtime or product process.
#[tokio::test]
async fn journal_browser_web_contract_serializes_saved_router_responses() {
let f = Fixture::new().await;
f.fix_initial();
f.call(MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
})
.await;
let (later, correction) = later_journal(&f, vec![f.entry_id.clone()]);
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-web-contract").await;
let mut registry = f
.api
.store
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap()
.unwrap();
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
f.api.store.upsert_worker_registry(&registry).unwrap();
let sources =
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":3});
let requests = [
json!({"action":"sessions","after":null,"limit":20}),
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":20}),
detail(&f),
text(&f, "body"),
json!({"action":"corrections","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":10}),
sources.clone(),
json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":3}),
json!({"action":"entry","journal_id":later,"entry_id":correction}),
];
for request in requests {
let response = browser_call(&f, &token, request.clone()).await;
println!(
"JOURNAL_BROWSER_CONTRACT {}",
json!({"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject(&f),"request":request,"response":response})
);
}
fs::remove_dir_all(&f.source_root).unwrap();
f.api
.store
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap();
for request in [detail(&f), sources] {
let response = browser_call(&f, &token, request.clone()).await;
println!(
"JOURNAL_BROWSER_CONTRACT {}",
json!({"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject(&f),"request":request,"response":response})
);
}
}
#[tokio::test]
async fn journal_browser_cookie_read_post_retains_origin_check_for_non_owner_reader() {
let f = Fixture::new().await;
seed_test_api_token(f.api.store.as_ref(), "journal-cookie-reader");
f.api
.store
.create_browser_session(&BrowserSessionRecord {
token_hash: crate::auth::token_hash("journal-cookie-session"),
session_id: "journal-cookie-session-id".into(),
user_id: "user-journal-cookie-reader".into(),
created_at: "2026-01-01T00:00:00Z".into(),
expires_at: "2099-01-01T00:00:00Z".into(),
revoked_at: None,
})
.unwrap();
let AuthConfig::Passkey {
origin,
cookie_name,
..
} = &f.api.config.auth;
for (presented, expected) in [
(None, StatusCode::FORBIDDEN),
(Some("https://foreign.example"), StatusCode::FORBIDDEN),
(Some(origin.as_str()), StatusCode::OK),
] {
let mut request = Request::builder()
.method("POST")
.uri(browser_path(&subject(&f)))
.header(CONTENT_TYPE, "application/json")
.header(
axum::http::header::COOKIE,
format!("{cookie_name}=journal-cookie-session"),
);
if let Some(origin) = presented {
request = request.header(axum::http::header::ORIGIN, origin);
}
let (status, bytes) = response(
build_router(f.api.clone()),
request
.body(Body::from(serde_json::to_vec(&detail(&f)).unwrap()))
.unwrap(),
)
.await;
assert_eq!(status, expected);
if status == StatusCode::OK {
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["value"]["entry"]["id"], f.entry_id);
}
}
}
@@ -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;
@@ -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<Response> {
if (request.method !== "POST") {
return failure(405, "read-only Journal queries require POST");
}
const r = await request.json().catch(() => null) as
| Record<string, any>
| 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");
}
}
@@ -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<string, any> | 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;
}
});
@@ -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;
@@ -0,0 +1,55 @@
import { assertEquals } from "@std/assert";
import type { Page } from "playwright";
export async function freePort(): Promise<number> {
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<void> {
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<void> {
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<void> {
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, []);
}
@@ -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<number> {
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<void> {
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<boolean> {
return await page.evaluate(() => {
const document = (globalThis as any).document;
@@ -60,29 +40,6 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
});
}
async function assertNoPageWideOverflow(page: Page): Promise<void> {
assertEquals(
await page.evaluate(() => {
const document = (globalThis as any).document;
return document.documentElement.scrollWidth === document.documentElement.clientWidth;
}),
true,
);
}
async function assertMainOwnsVerticalScroll(page: Page): Promise<void> {
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(
+1 -1
View File
@@ -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,
@@ -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"
}
]
}
@@ -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"
}
]
}
+2 -1
View File
@@ -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",
@@ -0,0 +1,13 @@
<script lang="ts">
import type { SourceStatus } from './journal-api.ts';
let { source }: { source: SourceStatus } = $props();
</script>
{#if source.state === 'missing'}
<p>元の会話は削除済み</p>
{:else if source.history_href}
<p>原資料の本文は未確認です。</p>
<p><a href={source.history_href}>元の会話を確認する →</a></p>
{:else}
<p>原資料は取得不能(存在・閲覧可否を確認できません)</p>
{/if}
<style>p { margin: 0; color: var(--text-muted); overflow-wrap: anywhere; }</style>
@@ -0,0 +1,16 @@
<script lang="ts">
import { tick } from 'svelte';
let { position, restored }: { position: { top: number } | null; restored: () => void } = $props();
// mainがscroll owner。deferred本文が描画される前のrestoreでは高さが足りず位置を失う。
$effect(() => {
if (!position) return;
const top = position.top;
let cancelled = false;
void tick().then(() => requestAnimationFrame(() => {
if (cancelled) return;
document.querySelector('main')?.scrollTo({ top, behavior: 'instant' });
restored();
}));
return () => { cancelled = true; };
});
</script>
@@ -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<T> = { 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<JournalEntry>;
entry: {
journal: JournalMetadata;
entry: JournalEntry;
correction_targets: JournalEntry[];
original_source: SourceStatus;
};
text: TextPage;
corrections: JournalPage<JournalEntry>;
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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Invalid Journal object");
}
return value as Record<string, unknown>;
}
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<T>(value: unknown, parse: (value: unknown) => T): T | null {
return value == null ? null : parse(value);
}
function choice<T extends string>(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<T>(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<T>(value: unknown, parse: (v: unknown) => T): JournalPage<T> {
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<K extends keyof JournalResponses>(
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<K extends JournalRequest["action"]>(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: Extract<JournalRequest, { action: K }>,
) {
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, string | null | undefined> = {},
): 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;
}
@@ -170,6 +170,8 @@
<span class="read-only-label">Behavior editable</span>
</header>
<nav aria-label="Subjectの記録"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a></nav>
{#if subject}
<section class="subject-overview" aria-label="Subject status">
<div>
@@ -1,13 +1,20 @@
<script lang="ts">
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import ReadingPosition from '#lib/workspace/memory/ReadingPosition.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let position = $state<{ top: number } | null>(null);
export const snapshot = {
capture: () => ({ top: document.querySelector('main')?.scrollTop ?? 0 }),
restore: (saved: { top: number }) => { position = saved; },
};
</script>
<DeferredPage {data} title="Memory">
{#snippet children(resolved)}
<PageContent data={resolved} />
<ReadingPosition {position} restored={() => { position = null; }} />
{/snippet}
</DeferredPage>
@@ -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<PageLoad>[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<PageLoad>[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,
};
},
);
@@ -1,6 +1,8 @@
<script lang="ts">
// current/historicalはchange_idとcurrent_change_idの一致で区別する。
// 本文・evidenceの続きも同じimmutable changeへ固定し、現在版の内容を混ぜない。
import OriginalSource from '#lib/workspace/memory/OriginalSource.svelte';
import { journalHref } from '#lib/workspace/memory/journal-api.ts';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import type {
@@ -64,6 +66,13 @@
return `${memoryPath()}?${query}`;
}
function journalPage(values: Record<string, string | null>): string {
const query = new URLSearchParams(data.journalQuery ?? '');
if (memory) query.set('change_id', memory.change_id);
for (const [key, value] of Object.entries(values)) value == null ? query.delete(key) : query.set(key, value);
return `${memoryPath()}?${query}#related-journal`;
}
function label(value: string): string {
return value.replaceAll('_', ' ');
}
@@ -146,6 +155,51 @@
{/if}
</section>
<section id="related-journal" class="detail-section" aria-labelledby="related-journal-heading">
<h2 id="related-journal-heading">関連するJournal</h2>
{#if data.journalSources}
{#if data.journalSources.length === 0}<p class="empty-copy">{memory.evidence_has_more ? 'この根拠ページにJournal参照はありません。' : 'この根拠ページでは対応するJournalは未接続です。'}</p>{/if}
{#each data.journalSources as group (group.candidateId)}
{#if group.result.data}
{#if group.result.data.sources.length === 0}<p class="empty-copy">{group.result.data.total_sources === 0 ? '対応するJournalは未接続です。' : 'このページに参照はありません。参照一覧の先頭へ戻ってください。'}</p>{/if}
<ul class="journal-link-list">
{#each group.result.data.sources as source (source.link.source_index)}
<li>
{#if source.link.connection.state === 'linked' && source.entry}
<a href={journalHref(data.workspaceId, data.subjectId, { journal: source.entry.journal_id, entry: source.entry.id })}>{formatDate(source.entry.created_at)} · {source.entry.title} →</a>
{#if source.link.connection.reconsideration}<p class="staleness">訂正あり・このMemoryの再検討が必要</p>{/if}
{:else if source.link.connection.state === 'unconnected'}
<p class="empty-copy">対応するJournalは未接続{source.link.connection.reason === 'journal_not_fixed' ? '(Journalの確定前)' : ''}</p>
{:else}<p class="empty-copy">関連Journalは{source.link.connection.target === 'erased' ? '消去済み' : '取得不能'}です。</p>{/if}
</li>
{/each}
</ul>
{#if group.result.data.next_source_index !== null}
<a href={journalPage({ journal_candidate: group.candidateId, journal_source_offset: String(group.result.data.next_source_index) })}>すべて見る(関連Journalの続き) →</a>
{/if}
{:else}<div class="memory-state is-error" role="alert"><p>Journal参照を取得できません。{group.result.error}</p></div>{/if}
{/each}
{#if data.journalCandidateNext != null}<a href={journalPage({ journal_candidate_offset: String(data.journalCandidateNext), journal_candidate: null, journal_source_offset: null })}>すべて見る(次の根拠候補の関連Journal) →</a>{/if}
{#if new URLSearchParams(data.journalQuery ?? '').has('journal_candidate') || new URLSearchParams(data.journalQuery ?? '').has('journal_candidate_offset')}
<a href={journalPage({ journal_candidate: null, journal_source_offset: null, journal_candidate_offset: null })}>Journal参照一覧の先頭へ</a>
{/if}
{#if memory.evidence_has_more && evidenceContinuationHref()}<a href={evidenceContinuationHref() ?? undefined}>次の根拠ページの関連Journalを見る →</a>{/if}
{:else}<p class="empty-copy">Journal参照を取得できません。</p>{/if}
</section>
<section class="detail-section" aria-label="原資料">
<h2>原資料</h2>
{#if data.journalSources?.length}
{#each data.journalSources as group (group.candidateId)}
{#if group.result.data}
{#each group.result.data.sources as source (source.link.source_index)}
<div><span class="empty-copy">原資料 {source.link.source_index + 1}</span><OriginalSource source={source.original_source} /></div>
{/each}
{:else}<p class="empty-copy">原資料の状態を取得できません。</p>{/if}
{/each}
{:else}<p class="empty-copy">この根拠ページに原資料の状態はありません。</p>{/if}
</section>
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
<header class="section-heading">
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
@@ -287,6 +341,9 @@
</section>
<style>
.journal-link-list { display: grid; gap: var(--space-3); padding: 0; margin: 0; list-style: none; }
.journal-link-list a { overflow-wrap: anywhere; }
.memory-page {
display: grid;
flex: 0 0 auto;
@@ -0,0 +1,18 @@
<script lang="ts">
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import ReadingPosition from '#lib/workspace/memory/ReadingPosition.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let position = $state<{ top: number } | null>(null);
export const snapshot = {
capture: () => ({ top: document.querySelector('main')?.scrollTop ?? 0 }),
restore: (saved: { top: number }) => { position = saved; },
};
</script>
<DeferredPage {data} title="Journal">
{#snippet children(resolved)}
<PageContent data={resolved} />
<ReadingPosition {position} restored={() => { position = null; }} />
{/snippet}
</DeferredPage>
@@ -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<PageLoad>[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 = <K extends Parameters<typeof loadJournal>[3]["action"]>(
request: Extract<Parameters<typeof loadJournal>[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,
},
};
},
);
@@ -0,0 +1,138 @@
<script lang="ts">
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import OriginalSource from '#lib/workspace/memory/OriginalSource.svelte';
import { journalHref, type JournalEntry, type TextPage } from '#lib/workspace/memory/journal-api.ts';
import { formatDate, workspaceRoute, type ApiResult } from '#lib/workspace/api/http.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as RouteData } from './$types';
let { data }: { data: ResolvedPageData<RouteData> } = $props();
const detail = $derived(data.detail);
const record = $derived(detail?.entry.data);
const entry = $derived(record?.entry);
const base = $derived(journalHref(data.workspaceId, data.subjectId));
function href(values: Record<string, string | null | undefined>) {
const query = new URLSearchParams(data.urlQuery);
for (const [key, value] of Object.entries(values)) value == null ? query.delete(key) : query.set(key, value);
return `${base}?${query}`;
}
function entryHref(e: JournalEntry) { return journalHref(data.workspaceId, data.subjectId, { journal: e.journal_id, entry: e.id }); }
function nextText(key: string, page: TextPage) { return href({ [`${key}_byte`]: String(page.next_byte), [`${key}_token`]: page.content_token }); }
function memoryHref(memoryId: string, changeId: string) {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}?change_id=${encodeURIComponent(changeId)}`);
}
</script>
<svelte:head><title>{entry?.title ?? 'Journal'} · Yoi Workspace</title></svelte:head>
{#snippet textSection(title: string, key: string, result: ApiResult<TextPage> | null | undefined)}
<section class="text-section" aria-label={title}>
<h2>{title}</h2>
{#if result?.data}
{#if result.data.text.trim()}<DocumentMarkdown text={result.data.text} />{:else}<p class="muted">{title}は空です。</p>{/if}
{#if result.data.next_byte !== null}<a href={nextText(key, result.data)}>次の本文区間 →</a>{/if}
{#if new URLSearchParams(data.urlQuery).has(`${key}_byte`)}<a href={href({ [`${key}_byte`]: null, [`${key}_token`]: null })}>本文の先頭へ</a>{/if}
{:else if result?.error}
<div role="alert" class="error"><p>{title}を取得できません。{result.error}</p><a href={href({ [`${key}_byte`]: null, [`${key}_token`]: null })}>先頭から読み直す</a></div>
{/if}
</section>
{/snippet}
{#snippet entryList(items: JournalEntry[])}
<ol class="entry-list">
{#each items as item (`${item.journal_id}:${item.id}`)}
<li>
<a href={entryHref(item)}><time datetime={item.created_at}>{formatDate(item.created_at)}</time><strong>{item.title}</strong><span aria-hidden="true">→</span></a>
<p class="muted">{item.state} · 出来事 {item.position}{#if item.correction_refs.length} · 訂正あり{/if}</p>
</li>
{/each}
</ol>
{/snippet}
<section class="journal-page" aria-labelledby="journal-title" data-journal-view={data.mode}>
<a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`)}>← SubjectのMemory</a>
<header><h1 id="journal-title">{entry?.title ?? 'Journal'}</h1>{#if entry}<p>{entry.state} · 出来事 {entry.position} · <time datetime={entry.created_at}>{formatDate(entry.created_at)}</time></p>{/if}</header>
<p class="muted">Journalは活動の経緯を記録した文章です。原発言・tool結果そのものではなく、Memoryの直接の抽出元や主張の証明ではありません。</p>
{#if data.mode === 'detail'}
{#if record && entry && detail}
<nav aria-label="Journal内の移動"><a href={journalHref(data.workspaceId, data.subjectId, { journal: entry.journal_id })}>{record.journal.title}の経緯 →</a><a href={base}>最近の出来事 →</a></nav>
{@render textSection('出来事の本文', 'body', detail.body)}
{#if entry.state === 'open'}{@render textSection('継続中の経緯', 'ongoing', detail.ongoing)}{/if}
<section aria-labelledby="correction-title"><h2 id="correction-title">訂正と対象</h2>
{#if entry.correction_refs.length === 0}<p class="muted">この出来事からの訂正対象はありません。</p>{/if}
{#each detail.targets as target}
<p>訂正対象: <a href={entryHref(target)}>{target.title} →</a></p>
{/each}
{#if detail.targetNext !== null}<a href={href({ target_offset: String(detail.targetNext) })}>すべて見る(訂正対象の続き) →</a>{/if}
{#if detail.corrections?.data}
{#if detail.corrections.data.items.length}<h3>この出来事を訂正する記録</h3>{@render entryList(detail.corrections.data.items)}{:else}<p class="muted">{new URLSearchParams(data.urlQuery).has('corrections_after') ? 'このページに訂正はありません。' : 'この出来事への訂正はありません。'}</p>{/if}
{#if detail.corrections.data.next}<a href={href({ corrections_after: detail.corrections.data.next })}>訂正の続き →</a>{/if}
{:else if detail.corrections?.error}<p class="error" role="alert">訂正一覧を取得できません。{detail.corrections.error}</p>{/if}
</section>
<section aria-labelledby="related-memory-title"><h2 id="related-memory-title">関連するMemory</h2>
{#if detail.related?.data}
{#if detail.related.data.items.length === 0}<p class="muted">{new URLSearchParams(data.urlQuery).has('related_after') ? 'このページに関連Memoryはありません。' : '関連するMemoryは未接続です。'}</p>{/if}
<ul class="related-list">{#each detail.related.data.items as item}<li><a href={memoryHref(item.related.selector.memory_id, item.related.selector.change_id)}>{item.claim} →</a>{#if item.related.reconsideration}<p class="muted">訂正あり・Memoryの再検討が必要</p>{/if}</li>{/each}</ul>
{#if detail.related.data.next}<a href={href({ related: 'all', related_after: JSON.stringify(detail.related.data.next) })}>すべて見る(関連Memoryの続き) →</a>{/if}
{#if new URLSearchParams(data.urlQuery).has('related_after')}<a href={href({ related_after: null })}>関連Memoryの先頭へ</a>{/if}
{:else if detail.related?.error}<p class="error" role="alert">関連するMemoryを取得できません。{detail.related.error}</p>{/if}
</section>
<section aria-label="原資料"><h2>原資料</h2><OriginalSource source={record.original_source} /></section>
{@render textSection('Sessionの現在地', 'position', detail.position)}
{:else}
<div role="alert" class="error"><h2>Journalエントリを取得できません</h2><p>{detail?.entry.error}</p></div>
{/if}
{:else}
<nav aria-label="Journalの一覧"><a href={base} aria-current={data.view === 'entries' && !data.journalId ? 'page' : undefined}>最近の出来事</a><a href={`${base}?view=sessions`} aria-current={data.view === 'sessions' ? 'page' : undefined}>SessionごとのJournal</a></nav>
{#if data.view === 'entries'}
<form action={base} method="GET" role="search">
{#if data.journalId}<input type="hidden" name="journal" value={data.journalId} />{/if}
<label for="journal-query">出来事を検索</label><div class="search-row"><input id="journal-query" type="search" name="q" value={data.query ?? ''} maxlength="1024" /><button type="submit">検索</button></div>
</form>
{#if data.journalId}{@render textSection('Sessionの現在地', 'position', data.position)}<h2>出来事の経緯</h2>{/if}
{#if data.entries?.data}
{#if data.entries.data.items.length}{@render entryList(data.entries.data.items)}
{:else if data.query}<p role="status">検索結果は0件です。</p>
{:else if data.after}<p role="status">このページの出来事はありません。</p>
{:else}<p role="status">保存済みの出来事はありません。<a href={`${base}?view=sessions`}>SessionのJournal・記録状態を見る →</a></p>{/if}
{#if data.entries.data.next}<a href={href({ after: data.entries.data.next })}>次のページ →</a>{/if}
{:else if data.entries?.error}<p class="error" role="alert">出来事一覧を取得できません。{data.entries.error}</p>{/if}
{:else}
{#if data.sessions?.data}
{#if data.sessions.data.items.length === 0}<p role="status">{data.after ? 'このページのJournalはありません。' : 'Journalはまだありません。'}</p>{/if}
<ul class="session-list">{#each data.sessions.data.items as session (session.journal.id)}<li>
<h2><a href={journalHref(data.workspaceId, data.subjectId, { journal: session.journal.id })}>{session.journal.title} →</a></h2>
<p><time datetime={session.journal.created_at}>{formatDate(session.journal.created_at)}</time></p>
{#if session.unreflected_sources}<p class="attention">記録待ち・未反映の原資料があります。</p>{:else}<p class="muted">保存された原資料に未反映は確認されていません。</p>{/if}
<details><summary>原資料とSession識別情報</summary><code>{session.journal.source_session_id}</code><OriginalSource source={session.original_source} /></details>
</li>{/each}</ul>
{#if data.sessions.data.next}<a href={href({ after: data.sessions.data.next })}>次のページ →</a>{/if}
{:else if data.sessions?.error}<p class="error" role="alert">SessionのJournalを取得できません。{data.sessions.error}</p>{/if}
{/if}
{#if data.after}<a href={href({ after: null })}>一覧の先頭へ</a>{/if}
{/if}
</section>
<style>
.journal-page { display: grid; gap: var(--space-5); flex: 0 0 auto; width: 100%; min-width: 0; max-width: 78rem; margin-inline: auto; }
h1, h2, h3, p { margin: 0; overflow-wrap: anywhere; }
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
h2, h3 { font-size: var(--font-size-body); }
header { padding-bottom: var(--space-4); border-bottom: 1px solid var(--line); }
header p { margin-top: var(--space-2); color: var(--text-muted); }
section section, form { display: grid; gap: var(--space-3); min-width: 0; }
nav { display: flex; gap: var(--space-4); flex-wrap: wrap; }
a { overflow-wrap: anywhere; }
[aria-current='page'] { color: var(--accent); font-weight: 700; }
.muted, time { color: var(--text-muted); }
.attention { color: var(--warning); }
.error { color: var(--danger); }
.entry-list, .related-list, .session-list { list-style: none; padding: 0; margin: 0; display: grid; min-width: 0; }
.entry-list li, .session-list li { padding-block: var(--space-4); border-bottom: 1px solid var(--line); min-width: 0; }
.entry-list li a { display: flex; gap: var(--space-3); align-items: baseline; }
.entry-list strong { flex: 1; }
.entry-list time { font-size: var(--font-size-compact); }
.entry-list li p { margin-top: var(--space-1); font-size: var(--font-size-compact); }
.related-list { gap: var(--space-3); }
.session-list li { display: grid; gap: var(--space-2); }
.search-row { display: flex; gap: var(--space-2); }
input[type='search'] { flex: 1; min-width: 0; }
details code { display: block; overflow-wrap: anywhere; }
@media(max-width: 600px) { .entry-list li a { display: grid; gap: var(--space-1); grid-template-columns: 1fr auto; } .entry-list time { grid-column: 1 / -1; } }
</style>
@@ -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 = <T>(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();
});
@@ -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();
});
@@ -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<string>();
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`,
);
+235
View File
@@ -0,0 +1,235 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): 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,
);
});