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
+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,
);
});