feat: browse Subject Journals and persistent Memory links
This commit is contained in:
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
+57
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
+138
@@ -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`,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user