feat: browse Subject Journals and persistent Memory links
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
/** UI向けのHTTP代替データ。Journalの正本・認可・永続化の実装ではない。 */
|
||||
export const journalFixture = {
|
||||
workspaceId: "memory-review",
|
||||
subjectId:
|
||||
"release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review",
|
||||
memoryId:
|
||||
"memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001",
|
||||
candidateId: "candidate-release-decision-0001",
|
||||
literalQuery: "release [a+b] %_",
|
||||
};
|
||||
|
||||
function journal(id: string, subjectId: string) {
|
||||
return {
|
||||
id,
|
||||
subject_id: subjectId,
|
||||
source_session_id: `session-${id}`,
|
||||
title: id === "journal-release"
|
||||
? "Release coordination Session"
|
||||
: id === "journal-review"
|
||||
? "Independent review Session"
|
||||
: "Archived planning Session",
|
||||
current_position_bytes: 120,
|
||||
process_order: 24,
|
||||
process_byte: 120,
|
||||
state_token: `state-${id}`,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
updated_at: "2026-01-04T03:04:05Z",
|
||||
};
|
||||
}
|
||||
function source(journalId: string) {
|
||||
return journalId === "journal-release"
|
||||
? { state: "missing", history_href: null }
|
||||
: journalId === "journal-review"
|
||||
? { state: "unavailable", history_href: null }
|
||||
: {
|
||||
state: "unavailable",
|
||||
history_href: "/w/memory-review/workers/W-fixture/console",
|
||||
};
|
||||
}
|
||||
function entry(index: number) {
|
||||
const journalId = index === 4
|
||||
? "journal-planning"
|
||||
: index % 2
|
||||
? "journal-review"
|
||||
: "journal-release";
|
||||
return {
|
||||
id: `entry-${String(index).padStart(2, "0")}`,
|
||||
journal_id: journalId,
|
||||
position: index + 1,
|
||||
state: index === 0 ? "open" : "fixed",
|
||||
title: index === 2
|
||||
? "Corrected release evidence"
|
||||
: `Release milestone ${
|
||||
String(index).padStart(2, "0")
|
||||
} · ${journalFixture.literalQuery}`,
|
||||
correction_refs: index === 2 ? ["entry-00"] : [],
|
||||
content_token: `content-entry-${index}`,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
updated_at: "2026-01-03T03:04:05Z",
|
||||
fixed_at: index === 0 ? null : "2026-01-03T03:04:05Z",
|
||||
};
|
||||
}
|
||||
const entries = Array.from({ length: 28 }, (_, index) => entry(index));
|
||||
const related = Array.from({ length: 8 }, (_, index) => ({
|
||||
related: {
|
||||
selector: {
|
||||
memory_id: index === 0
|
||||
? journalFixture.memoryId
|
||||
: `memory-related-${index}`,
|
||||
change_id: "change-current",
|
||||
candidate_id: journalFixture.candidateId,
|
||||
},
|
||||
source_index: index,
|
||||
reconsideration: index === 0,
|
||||
},
|
||||
claim: index === 0
|
||||
? "Keep subject Memory provenance typed and visible"
|
||||
: `Related release lesson ${index}`,
|
||||
}));
|
||||
const body =
|
||||
`# Preserved Journal narrative\n\nThis saved Journal remains readable without the original Worker or Session. It is not the original transcript or proof of each Memory claim.\n\n${
|
||||
Array.from({ length: 64 }, (_, i) =>
|
||||
`## Release event ${
|
||||
i + 1
|
||||
}\n\nThe team compared the available evidence and recorded the release decision. Literal search token: ${journalFixture.literalQuery}.`)
|
||||
.join("\n\n")
|
||||
}\n\n## Journal narrative complete`;
|
||||
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
const failure = (status: number, message: string) =>
|
||||
json(
|
||||
{ error: status === 403 ? "Forbidden" : "Unavailable", message },
|
||||
status,
|
||||
);
|
||||
|
||||
export async function journalBrowserFixture(
|
||||
request: Request,
|
||||
subjectId: string,
|
||||
): Promise<Response> {
|
||||
if (request.method !== "POST") {
|
||||
return failure(405, "read-only Journal queries require POST");
|
||||
}
|
||||
const r = await request.json().catch(() => null) as
|
||||
| Record<string, any>
|
||||
| null;
|
||||
if (!r || typeof r.action !== "string") {
|
||||
return failure(400, "missing query action");
|
||||
}
|
||||
const reply = (value: unknown) => json({ kind: r.action, value });
|
||||
if (subjectId === "failed-subject") {
|
||||
return failure(503, "Journal read temporarily unavailable");
|
||||
}
|
||||
if (r.journal_id === "journal-denied") {
|
||||
return failure(403, "Journal read permission denied");
|
||||
}
|
||||
const empty = subjectId === "empty-subject" ||
|
||||
subjectId === "ungenerated-subject";
|
||||
switch (r.action) {
|
||||
case "sessions": {
|
||||
if (empty) return reply({ items: [], next: null });
|
||||
const journals = ["journal-release", "journal-review", "journal-archive"];
|
||||
const offset = r.after === "fixture-sessions-2" ? 2 : 0;
|
||||
return reply({
|
||||
items: journals.slice(offset, offset + 2).map((id) => ({
|
||||
journal: journal(id, subjectId),
|
||||
original_source: source(id),
|
||||
unreflected_sources: id === "journal-review",
|
||||
})),
|
||||
next: offset === 0 ? "fixture-sessions-2" : null,
|
||||
});
|
||||
}
|
||||
case "entries": {
|
||||
if (r.query === "fixture-denied") {
|
||||
return failure(403, "Journal read permission denied");
|
||||
}
|
||||
if (r.query === "fixture-failure") {
|
||||
return failure(503, "Journal list unavailable");
|
||||
}
|
||||
const filtered = empty
|
||||
? []
|
||||
: entries.filter((e) =>
|
||||
(!r.journal_id || r.journal_id === e.journal_id) &&
|
||||
(!r.query ||
|
||||
`${e.title} ${body}`.toLowerCase().includes(
|
||||
String(r.query).toLowerCase(),
|
||||
))
|
||||
);
|
||||
const offset = r.after === "fixture-entries-2" ? 16 : 0;
|
||||
const limit = Math.min(Number(r.limit) || 20, 16);
|
||||
return reply({
|
||||
items: filtered.slice(offset, offset + limit),
|
||||
next: offset + limit < filtered.length ? "fixture-entries-2" : null,
|
||||
});
|
||||
}
|
||||
case "entry": {
|
||||
const e = entries.find((e) =>
|
||||
e.id === r.entry_id && e.journal_id === r.journal_id
|
||||
);
|
||||
return e
|
||||
? reply({
|
||||
journal: journal(e.journal_id, subjectId),
|
||||
entry: e,
|
||||
correction_targets: entries.filter((target) =>
|
||||
e.correction_refs.includes(target.id)
|
||||
),
|
||||
original_source: source(e.journal_id),
|
||||
})
|
||||
: failure(404, "Journal entry not found");
|
||||
}
|
||||
case "text": {
|
||||
const e = r.entry_id
|
||||
? entries.find((e) =>
|
||||
e.id === r.entry_id && e.journal_id === r.journal_id
|
||||
)
|
||||
: null;
|
||||
const token = r.field === "current_position"
|
||||
? `state-${r.journal_id}`
|
||||
: e?.content_token;
|
||||
if (!token) return failure(404, "Journal text not found");
|
||||
if (r.expected_token && r.expected_token !== token) {
|
||||
return failure(409, "Journal text changed; reread from the beginning");
|
||||
}
|
||||
const text = r.field === "body"
|
||||
? body
|
||||
: r.field === "ongoing"
|
||||
? "## Ongoing release context\n\nThe next review is still open."
|
||||
: "## Current Session position\n\nRelease review is awaiting the next evidence check. This current position does not replace the event history.";
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const start = Number(r.byte_offset) || 0;
|
||||
let end = Math.min(
|
||||
bytes.length,
|
||||
start + Math.min(Number(r.max_bytes) || 16384, 16384),
|
||||
);
|
||||
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
||||
return reply({
|
||||
text: new TextDecoder().decode(bytes.slice(start, end)),
|
||||
next_byte: end < bytes.length ? end : null,
|
||||
content_token: token,
|
||||
});
|
||||
}
|
||||
case "corrections":
|
||||
return reply({
|
||||
items: r.entry_id === "entry-00" ? [entry(2)] : [],
|
||||
next: null,
|
||||
});
|
||||
case "related_memory": {
|
||||
if (r.entry_id === "entry-03") return reply({ items: [], next: null });
|
||||
const offset = r.after ? Number(r.after.source_index) + 1 : 0;
|
||||
const limit = Math.min(Number(r.limit) || 3, 20);
|
||||
const items = related.slice(offset, offset + limit);
|
||||
const last = items.at(-1)?.related;
|
||||
return reply({
|
||||
items,
|
||||
next: offset + limit < related.length && last
|
||||
? { ...last.selector, source_index: last.source_index }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
case "memory_sources": {
|
||||
const s = r.selector;
|
||||
// 不変selectorは本番Memory loaderから受け取り、routeだけから推測しない。
|
||||
if (
|
||||
!s || s.candidate_id !== journalFixture.candidateId ||
|
||||
![
|
||||
"change-current",
|
||||
"change-earlier",
|
||||
"change-original",
|
||||
"change-parent",
|
||||
].includes(
|
||||
s.change_id,
|
||||
)
|
||||
) return failure(404, "Immutable candidate not found");
|
||||
if (s.memory_id === "memory-sources-failure") {
|
||||
return failure(503, "Related Journal unavailable");
|
||||
}
|
||||
if (s.memory_id === "memory-resolved-0002") {
|
||||
return reply({
|
||||
selector: s,
|
||||
sources: [{
|
||||
link: {
|
||||
source_index: 0,
|
||||
original_source: "unknown",
|
||||
connection: {
|
||||
state: "unconnected",
|
||||
reason: "insufficient_source",
|
||||
},
|
||||
},
|
||||
entry: null,
|
||||
original_source: { state: "unavailable", history_href: null },
|
||||
}],
|
||||
total_sources: 1,
|
||||
next_source_index: null,
|
||||
});
|
||||
}
|
||||
const offset = Number(r.source_offset) || 0;
|
||||
const limit = Math.min(Number(r.limit) || 3, 20);
|
||||
const sources = entries.slice(offset, Math.min(8, offset + limit)).map((
|
||||
e,
|
||||
index,
|
||||
) => ({
|
||||
link: {
|
||||
source_index: offset + index,
|
||||
original_source: e.journal_id === "journal-release"
|
||||
? "missing"
|
||||
: "unknown",
|
||||
connection: {
|
||||
state: "linked",
|
||||
journal_id: e.journal_id,
|
||||
entry_id: e.id,
|
||||
target: "available",
|
||||
reconsideration: e.id === "entry-00",
|
||||
},
|
||||
},
|
||||
entry: e,
|
||||
original_source: source(e.journal_id),
|
||||
}));
|
||||
return reply({
|
||||
selector: s,
|
||||
sources,
|
||||
total_sources: 8,
|
||||
next_source_index: offset + limit < 8 ? offset + limit : null,
|
||||
});
|
||||
}
|
||||
default:
|
||||
return failure(400, "unsupported read-only Journal query");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user