fix subject authority and bounded memory browsing

This commit is contained in:
2026-10-03 01:18:24 +09:00
parent f4966b78ea
commit 13bce155aa
40 changed files with 927 additions and 174 deletions
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -37,7 +37,7 @@ export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, has_more: boolean, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a)
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
+30 -12
View File
@@ -228,7 +228,9 @@ function parseStagingEvidence(value: unknown): MemoryStagingEvidence {
summary: nullableString(record, "summary"),
};
if ("origin" in record) {
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
result.origin = record.origin === null
? null
: parseEvidenceOrigin(record.origin);
}
return result;
}
@@ -259,7 +261,9 @@ function parseSourceEvidenceRef(value: unknown): MemorySourceEvidenceRef {
summary: nullableString(record, "summary"),
};
if ("origin" in record) {
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
result.origin = record.origin === null
? null
: parseEvidenceOrigin(record.origin);
}
return result;
}
@@ -350,10 +354,12 @@ export function parseSubjektivSubjectResponse(
updated_at: requiredString(record, "updated_at"),
};
if ("current_worker" in record) {
result.current_worker = record.current_worker === null ? null : parseWorkerLaunchWorkerSummary(
record.current_worker,
"Subject current worker",
);
result.current_worker = record.current_worker === null
? null
: parseWorkerLaunchWorkerSummary(
record.current_worker,
"Subject current worker",
);
}
return result;
}
@@ -363,8 +369,9 @@ export function parseSubjektivSubjectListResponse(
): SubjektivSubjectListResponse {
const record = strictRecord(
value,
["limit", "items", "has_more"],
["limit", "items", "next_cursor", "has_more"],
"Subject list response",
["next_cursor"],
);
const limit = positiveInteger(record.limit, "limit");
if (limit > MAX_SUBJECTS) invalid("limit exceeds the Subject list bound");
@@ -373,11 +380,16 @@ export function parseSubjektivSubjectListResponse(
);
if (items.length > limit) invalid("items exceeds the declared Subject limit");
assertUnique(items.map((item) => item.id), "Subject ids");
return {
const hasMore = requiredBoolean(record, "has_more");
const nextCursor = optionalNullableString(record, "next_cursor");
assertCursorInvariant(hasMore, nextCursor, "Subject list");
const result: SubjektivSubjectListResponse = {
limit,
items,
has_more: requiredBoolean(record, "has_more"),
has_more: hasMore,
};
if (nextCursor !== undefined) result.next_cursor = nextCursor;
return result;
}
export function parseSubjektivResidentSurfaceResponse(
@@ -562,7 +574,9 @@ export function parseSubjektivMemoryReadResponse(
).map(parseMemoryEvidenceCandidate);
if (
sourceCandidateIds.length !== sourceCandidates.length ||
sourceCandidateIds.some((id, index) => sourceCandidates[index]?.candidate_id !== id)
sourceCandidateIds.some((id, index) =>
sourceCandidates[index]?.candidate_id !== id
)
) {
invalid("source candidate ids do not match source candidates");
}
@@ -690,7 +704,9 @@ function parseMemoryEvidence(value: unknown): SubjektivMemoryEvidence {
: parseEntryRange(record.entry_range, "entry_range");
}
if ("origin" in record) {
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
result.origin = record.origin === null
? null
: parseEvidenceOrigin(record.origin);
}
if ("excerpt" in record) result.excerpt = nullableString(record, "excerpt");
if ("summary" in record) result.summary = nullableString(record, "summary");
@@ -735,7 +751,9 @@ function parseMemorySourceEvidenceRef(
: parseEntryRange(record.entry_range, "entry_range");
}
if ("origin" in record) {
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
result.origin = record.origin === null
? null
: parseEvidenceOrigin(record.origin);
}
return result;
}
@@ -9,6 +9,11 @@
function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
}
function pageHref(cursor?: string | null): string {
const path = workspaceRoute(data.workspaceId, '/memory');
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
</script>
<svelte:head>
@@ -33,6 +38,7 @@
<div class="memory-state" role="status" data-memory-state="empty">
<strong>No Memory subjects.</strong>
<p>Subjects will appear here after they are created for this Workspace.</p>
{#if data.cursor}<p><a href={pageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<div class="subject-list" aria-label="Memory subjects">
@@ -51,9 +57,12 @@
</a>
{/each}
</div>
{#if data.subjects.data.has_more}
<p class="memory-note">Only the first {data.subjects.data.limit} subjects are shown.</p>
{/if}
<nav class="memory-pagination" aria-label="Subject pages">
{#if data.cursor}<a href={pageHref()}>First page</a>{/if}
{#if data.subjects.data.has_more && data.subjects.data.next_cursor}
<a href={pageHref(data.subjects.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.subjects.error}
<div class="memory-state is-error" role="alert" data-memory-state="error">
@@ -203,8 +212,15 @@
color: var(--text-strong);
}
.memory-state p,
.memory-note {
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.memory-state p {
margin: var(--space-1) 0 0;
color: var(--text-muted);
}
@@ -5,12 +5,25 @@ import {
} from "$lib/workspace/memory/api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const MAX_CURSOR_BYTES = 16_384;
function boundedCursor(value: string | null): string | null {
if (value === null || value.length === 0) return null;
return new TextEncoder().encode(value).byteLength <= MAX_CURSOR_BYTES
? value
: null;
}
export const load: PageLoad = async ({ fetch, params, url }) => {
const cursor = boundedCursor(url.searchParams.get("cursor"));
const query = new URLSearchParams({ limit: "100" });
if (cursor) query.set("cursor", cursor);
return {
workspaceId: params.workspaceId,
cursor,
subjects: await loadJson(
fetch,
`${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?limit=100`,
`${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?${query}`,
undefined,
parseSubjektivSubjectListResponse,
MEMORY_API_LOAD_POLICY,
@@ -21,6 +21,14 @@
);
}
function memoryPageHref(cursor?: string | null): string {
const path = workspaceRoute(
data.workspaceId,
`/memory/${encodeURIComponent(data.subjectId)}`,
);
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
function stateLabel(state: SubjektivMemoryState): string {
return state[0].toUpperCase() + state.slice(1);
}
@@ -152,6 +160,7 @@
<div class="memory-state" role="status" data-memories-empty>
<strong>No committed Memories.</strong>
<p>This subject has no current Memory revisions.</p>
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<div class="memory-list" aria-label="Current committed Memories">
@@ -173,9 +182,12 @@
</a>
{/each}
</div>
{#if data.memories.data.has_more}
<p class="memory-note">More current Memories are available after this bounded page.</p>
{/if}
<nav class="memory-pagination" aria-label="Current Memory pages">
{#if data.cursor}<a href={memoryPageHref()}>First page</a>{/if}
{#if data.memories.data.has_more && data.memories.data.next_cursor}
<a href={memoryPageHref(data.memories.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.memories.error}
<div class="memory-state is-error" role="alert">
@@ -388,8 +400,7 @@
font-size: var(--font-size-body);
}
.memory-row p,
.memory-note {
.memory-row p {
margin: 0;
color: var(--text-muted);
}
@@ -417,6 +428,14 @@
color: var(--text-muted);
}
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.memory-state strong {
color: var(--text-strong);
}
@@ -7,9 +7,21 @@ import {
} from "$lib/workspace/memory/api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const MAX_CURSOR_BYTES = 16_384;
function boundedCursor(value: string | null): string | null {
if (value === null || value.length === 0) return null;
return new TextEncoder().encode(value).byteLength <= MAX_CURSOR_BYTES
? value
: null;
}
export const load: PageLoad = async ({ fetch, params, url }) => {
const subjectId = params.subjectId;
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
const cursor = boundedCursor(url.searchParams.get("cursor"));
const memoryQuery = new URLSearchParams({ limit: "100" });
if (cursor) memoryQuery.set("cursor", cursor);
const [subject, surface, memories] = await Promise.all([
loadJson(
fetch,
@@ -27,7 +39,9 @@ export const load: PageLoad = async ({ fetch, params }) => {
),
loadJson(
fetch,
`${workspaceApiPath(params.workspaceId, `${subjectPath}/memories`)}?limit=100`,
`${
workspaceApiPath(params.workspaceId, `${subjectPath}/memories`)
}?${memoryQuery}`,
undefined,
parseSubjektivMemoryQueryResponse,
MEMORY_API_LOAD_POLICY,
@@ -37,6 +51,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
return {
workspaceId: params.workspaceId,
subjectId,
cursor,
subject,
surface,
memories,
@@ -28,6 +28,14 @@
return `${memoryPath()}?revision=${revision}`;
}
function revisionPageHref(cursor?: string | null): string {
const query = new URLSearchParams();
if (memory) query.set('revision', String(memory.revision));
if (cursor) query.set('revision_cursor', cursor);
const encoded = query.toString();
return encoded ? `${memoryPath()}?${encoded}` : memoryPath();
}
function derivedHref(reference: SubjektivMemoryRevisionRef): string {
return `${memoryPath(reference.memory_id)}?revision=${reference.revision}`;
}
@@ -221,7 +229,10 @@
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header>
{#if data.revisions.data}
{#if revisions.length === 0}
<p class="empty-copy">No revision history is available.</p>
<div>
<p class="empty-copy">No revision history is available.</p>
{#if data.revisionCursor}<p><a href={revisionPageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<ol class="revision-list">
{#each revisions as revision (revision.revision)}
@@ -234,7 +245,12 @@
</li>
{/each}
</ol>
{#if data.revisions.data.has_more}<p class="bounded-note">More immutable revisions are available after this bounded page.</p>{/if}
<nav class="memory-pagination" aria-label="Revision history pages">
{#if data.revisionCursor}<a href={revisionPageHref()}>First page</a>{/if}
{#if data.revisions.data.has_more && data.revisions.data.next_cursor}
<a href={revisionPageHref(data.revisions.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.revisions.error}
<div class="memory-state is-error" role="alert"><strong>Revision history unavailable.</strong><p>{data.revisions.error}</p></div>
@@ -433,6 +449,14 @@
margin: 0;
}
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.candidate-list {
display: grid;
gap: var(--space-4);
@@ -8,6 +8,15 @@ import {
import type { PageLoad } from "./$types";
const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/;
const MAX_CURSOR_BYTES = 16_384;
function boundedCursor(
value: string | null,
maxBytes = MAX_CURSOR_BYTES,
): string | null {
if (value === null || value.length === 0) return null;
return new TextEncoder().encode(value).byteLength <= maxBytes ? value : null;
}
function safeIntegerQuery(
source: URLSearchParams,
@@ -39,13 +48,14 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
const value = safeIntegerQuery(url.searchParams, key, positive);
if (value !== null) detailQuery.set(key, value);
}
const evidenceCursor = url.searchParams.get("evidence_cursor");
if (
evidenceCursor !== null && evidenceCursor.length > 0 &&
new TextEncoder().encode(evidenceCursor).byteLength <= 512
) {
detailQuery.set("evidence_cursor", evidenceCursor);
}
const evidenceCursor = boundedCursor(
url.searchParams.get("evidence_cursor"),
512,
);
if (evidenceCursor) detailQuery.set("evidence_cursor", evidenceCursor);
const revisionCursor = boundedCursor(url.searchParams.get("revision_cursor"));
const revisionQuery = new URLSearchParams({ limit: "100" });
if (revisionCursor) revisionQuery.set("cursor", revisionCursor);
const [subject, memory, revisions] = await Promise.all([
loadJson(
@@ -64,7 +74,9 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
),
loadJson(
fetch,
`${workspaceApiPath(params.workspaceId, `${memoryPath}/revisions`)}?limit=100`,
`${
workspaceApiPath(params.workspaceId, `${memoryPath}/revisions`)
}?${revisionQuery}`,
undefined,
(value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId),
MEMORY_API_LOAD_POLICY,
@@ -75,6 +87,7 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
workspaceId: params.workspaceId,
subjectId,
memoryId,
revisionCursor,
subject,
memory,
revisions,
@@ -28,7 +28,13 @@ test("renders the subject index as the Memory product entry", () => {
render(SubjectIndexPage, {
data: {
workspaceId: "workspace-1",
subjects: result({ limit: 100, items: [subject()], has_more: false }),
cursor: "subject-current",
subjects: result({
limit: 100,
items: [subject()],
next_cursor: "subject-next",
has_more: true,
}),
},
} as never);
@@ -37,6 +43,10 @@ test("renders the subject index as the Memory product entry", () => {
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
.toBeNull();
expect(screen.getByText("subject-1")).not.toBeNull();
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
.toBe("/w/workspace-1/memory");
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
.toContain("cursor=subject-next");
expect(screen.queryByText("Staging")).toBeNull();
});
@@ -45,6 +55,7 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
data: {
workspaceId: "workspace-1",
subjectId: "subject-1",
cursor: "memory-current",
subject: result(subject()),
surface: result({
subject_id: "subject-1",
@@ -87,7 +98,8 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
updated_at: "2026-01-02T03:04:05Z",
},
],
has_more: false,
next_cursor: "memory-next",
has_more: true,
}),
},
} as never);
@@ -101,8 +113,12 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
0,
);
expect(screen.getByText("resolved", { exact: true })).not.toBeNull();
expect(screen.getByText("retracted", { exact: true })).not.toBeNull();
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
expect(screen.getByText("Retracted", { exact: true })).not.toBeNull();
expect(screen.getByRole("navigation", { name: "Current Memory pages" }))
.not.toBeNull();
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
.toContain("cursor=memory-next");
});
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
@@ -177,6 +193,7 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
workspaceId: "workspace-1",
subjectId: "subject-1",
memoryId: "memory-1",
revisionCursor: "revision-current",
subject: result(subject()),
memory: result({
memory_id: "memory-1",
@@ -233,7 +250,8 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
change_reason: "Resolved",
updated_at: "2026-01-02T00:00:00Z",
}],
has_more: false,
next_cursor: "revision-next",
has_more: true,
}),
},
} as never);
@@ -248,4 +266,8 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
expect(screen.getByRole("heading", { name: "Revision history" })).not
.toBeNull();
expect(screen.getByText("Historical revision")).not.toBeNull();
expect(screen.getByRole("navigation", { name: "Revision history pages" }))
.not.toBeNull();
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
.toContain("revision_cursor=revision-next");
});
+20 -2
View File
@@ -160,7 +160,8 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
() =>
parseSubjektivSubjectListResponse({
limit: 100,
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
items: Array.from({ length: 101 }, (_, index) =>
subject(`subject-${index}`)),
has_more: false,
}),
"bounded array",
@@ -170,6 +171,22 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
Deno.test("Subject list parser preserves the exact bounded response", () => {
const response = { limit: 100, items: [subject()], has_more: false };
assertEquals(parseSubjektivSubjectListResponse(response), response);
const nextPage = {
limit: 1,
items: [subject()],
next_cursor: "subjektiv.subjects.next",
has_more: true,
};
assertEquals(parseSubjektivSubjectListResponse(nextPage), nextPage);
assertThrows(
() =>
parseSubjektivSubjectListResponse({
limit: 1,
items: [subject()],
has_more: true,
}),
"cursor does not match has_more",
);
assertThrows(
() =>
parseSubjektivSubjectListResponse({
@@ -278,7 +295,8 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
assertThrows(
() =>
parseSubjektivMemoryQueryResponse({
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
items: Array.from({ length: 101 }, (_, index) =>
queryItem(`memory-${index}`)),
has_more: false,
}),
"bounded array",