feat: cut over Memory to subjektiv product surfaces
This commit is contained in:
@@ -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:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// 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
|
||||
|
||||
@@ -11,98 +11,56 @@ export const MEMORY_API_LIMITS = {
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
severity: DiagnosticSeverity;
|
||||
message: string;
|
||||
};
|
||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||
|
||||
export type MemoryDocumentResponse = {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
};
|
||||
export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, };
|
||||
|
||||
export type MemoryCandidateKind =
|
||||
| "preference"
|
||||
| "working_assumption"
|
||||
| "constraint"
|
||||
| "decision"
|
||||
| "open_question"
|
||||
| "lesson";
|
||||
export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson";
|
||||
|
||||
export type MemoryEvidenceOriginKind =
|
||||
| "human_input"
|
||||
| "worker_input"
|
||||
| "flow_instruction"
|
||||
| "backend_instruction"
|
||||
| "model_output"
|
||||
| "tool_output"
|
||||
| "derived_summary"
|
||||
| "legacy_unknown";
|
||||
export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown";
|
||||
|
||||
export type MemoryEvidenceOrigin = {
|
||||
kind: MemoryEvidenceOriginKind;
|
||||
account_id?: string | null;
|
||||
workspace_id?: string | null;
|
||||
runtime_id?: string | null;
|
||||
worker_id?: string | null;
|
||||
flow_selector?: string | null;
|
||||
flow_definition_id?: string | null;
|
||||
flow_definition_revision?: number | null;
|
||||
};
|
||||
export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_revision?: number | null, };
|
||||
|
||||
export type MemorySourceRef = { segment_id: string; range: [number, number] };
|
||||
export type MemorySourceRef = { segment_id: string, range: [number, number], };
|
||||
|
||||
export type MemoryStagingEvidence = {
|
||||
id: string;
|
||||
kind: string;
|
||||
entry_range: [number, number] | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
excerpt: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, };
|
||||
|
||||
export type MemorySourceEvidenceRef = {
|
||||
session_id: string | null;
|
||||
segment_id: string | null;
|
||||
entry_range: [number, number] | null;
|
||||
evidence_id: string | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
evidence_kind: string | null;
|
||||
label: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, };
|
||||
|
||||
export type MemoryStagingRecord = {
|
||||
schema_version: number;
|
||||
id: string;
|
||||
extract_run_id: string;
|
||||
source: MemorySourceRef;
|
||||
kind: MemoryCandidateKind;
|
||||
claim: string;
|
||||
why_useful: string;
|
||||
staleness: string | null;
|
||||
evidence: Array<MemoryStagingEvidence>;
|
||||
source_refs: Array<MemorySourceEvidenceRef>;
|
||||
};
|
||||
export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array<MemoryStagingEvidence>, source_refs: Array<MemorySourceEvidenceRef>, };
|
||||
|
||||
export type MemoryStagingEntry = {
|
||||
id: string;
|
||||
byte_len: number;
|
||||
record: MemoryStagingRecord;
|
||||
};
|
||||
export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, };
|
||||
|
||||
export type MemoryStagingListResponse = {
|
||||
limit: number;
|
||||
returned_count: number;
|
||||
total_valid_count: number;
|
||||
invalid_count: number;
|
||||
truncated: boolean;
|
||||
order: string;
|
||||
record_authority: string;
|
||||
items: Array<MemoryStagingEntry>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array<MemoryStagingEntry>, diagnostics: Array<Diagnostic>, };
|
||||
|
||||
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 SubjektivMemoryState = "active" | "resolved" | "retracted";
|
||||
|
||||
export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, };
|
||||
|
||||
export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready";
|
||||
|
||||
export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array<SubjektivMemoryRevisionRef>, built_from_store_revision: number, created_at: string, };
|
||||
|
||||
export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, };
|
||||
|
||||
export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, };
|
||||
|
||||
export type SubjektivMemoryQueryResponse = { items: Array<SubjektivMemoryQueryItem>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, };
|
||||
|
||||
export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, };
|
||||
|
||||
export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array<SubjektivMemoryEvidence>, evidence_total: number, evidence_truncated: boolean, source_refs: Array<SubjektivMemorySourceEvidenceRef>, source_refs_total: number, source_refs_truncated: boolean, };
|
||||
|
||||
export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array<string>, source_candidates: Array<SubjektivMemoryEvidenceCandidate>, derived_from: Array<SubjektivMemoryRevisionRef>, evidence_next_cursor?: string | null, evidence_has_more: boolean, };
|
||||
|
||||
export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, };
|
||||
|
||||
export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array<SubjektivMemoryRevisionItem>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
@@ -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:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// 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:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// 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:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// 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-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// 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
|
||||
|
||||
@@ -96,11 +96,7 @@ function optional<T>(
|
||||
label: string,
|
||||
parse: (item: unknown, label: string) => T,
|
||||
): T | null | undefined {
|
||||
return value === undefined
|
||||
? undefined
|
||||
: value === null
|
||||
? null
|
||||
: parse(value, label);
|
||||
return value === undefined ? undefined : value === null ? null : parse(value, label);
|
||||
}
|
||||
|
||||
function diagnostic(value: unknown, label: string): Diagnostic {
|
||||
@@ -388,17 +384,13 @@ export function parseWorkerSummary(
|
||||
`${label}.singleton_key`,
|
||||
string,
|
||||
),
|
||||
tags: item.tags === undefined
|
||||
? []
|
||||
: array(item.tags, `${label}.tags`, string),
|
||||
tags: item.tags === undefined ? [] : array(item.tags, `${label}.tags`, string),
|
||||
workspace: workspaceSummary(item.workspace, `${label}.workspace`),
|
||||
availability,
|
||||
state: string(item.state, `${label}.state`),
|
||||
worker_state: workerState,
|
||||
last_seen_at: optional(item.last_seen_at, `${label}.last_seen_at`, string),
|
||||
pinned: item.pinned === undefined
|
||||
? false
|
||||
: boolean(item.pinned, `${label}.pinned`),
|
||||
pinned: item.pinned === undefined ? false : boolean(item.pinned, `${label}.pinned`),
|
||||
retention_state: item.retention_state === undefined
|
||||
? ""
|
||||
: string(item.retention_state, `${label}.retention_state`),
|
||||
@@ -406,13 +398,11 @@ export function parseWorkerSummary(
|
||||
item.implementation,
|
||||
`${label}.implementation`,
|
||||
),
|
||||
workdir_attachments: item.workdir_attachments === undefined
|
||||
? undefined
|
||||
: array(
|
||||
item.workdir_attachments,
|
||||
`${label}.workdir_attachments`,
|
||||
workerWorkdirAttachment,
|
||||
),
|
||||
workdir_attachments: item.workdir_attachments === undefined ? undefined : array(
|
||||
item.workdir_attachments,
|
||||
`${label}.workdir_attachments`,
|
||||
workerWorkdirAttachment,
|
||||
),
|
||||
diagnostics: item.diagnostics === undefined
|
||||
? []
|
||||
: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
|
||||
@@ -622,17 +612,22 @@ function workerSummary(
|
||||
item.implementation,
|
||||
`${label}.implementation`,
|
||||
),
|
||||
workdir_attachments: item.workdir_attachments === undefined
|
||||
? undefined
|
||||
: array(
|
||||
item.workdir_attachments,
|
||||
`${label}.workdir_attachments`,
|
||||
runtimeWorkdirAttachment,
|
||||
),
|
||||
workdir_attachments: item.workdir_attachments === undefined ? undefined : array(
|
||||
item.workdir_attachments,
|
||||
`${label}.workdir_attachments`,
|
||||
runtimeWorkdirAttachment,
|
||||
),
|
||||
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkerLaunchWorkerSummary(
|
||||
value: unknown,
|
||||
label = "Worker launch summary",
|
||||
): WorkerLaunchWorkerSummary {
|
||||
return workerSummary(value, label);
|
||||
}
|
||||
|
||||
export function parseBrowserCreateWorkerResponse(
|
||||
value: unknown,
|
||||
): BrowserCreateWorkerResponse {
|
||||
|
||||
@@ -448,27 +448,39 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
const memoryNav = await Deno.readTextFile(
|
||||
new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
|
||||
);
|
||||
const memoryDocumentLoad = await Deno.readTextFile(
|
||||
const subjectIndexLoad = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryDocumentPage = await Deno.readTextFile(
|
||||
const subjectIndexPage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryStagingLoad = await Deno.readTextFile(
|
||||
const subjectPageLoad = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/staging/+page.ts",
|
||||
"./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryStagingPage = await Deno.readTextFile(
|
||||
const subjectPage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/staging/+page.svelte",
|
||||
"./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryDetailLoad = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryDetailPage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
@@ -478,35 +490,44 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
memoryNav.includes(
|
||||
'<h2 class="sidebar-nav-section__header">Memory</h2>',
|
||||
) &&
|
||||
memoryNav.includes("Document</a>") &&
|
||||
memoryNav.includes("Subjects</a>") &&
|
||||
memoryNav.includes("currentPath.startsWith(`${subjectsHref}/`)") &&
|
||||
memoryNav.includes("sidebar-nav-section--category") &&
|
||||
memoryNav.includes('class="sidebar-link"') &&
|
||||
memoryNav.includes('workspaceRoute(workspaceId, "/memory/staging")') &&
|
||||
memoryNav.includes("Staging</a>") &&
|
||||
!memoryNav.includes("item-meta") &&
|
||||
!memoryNav.includes("durable workspace memory") &&
|
||||
!memoryNav.includes("pending extraction candidates"),
|
||||
"Memory sidebar section should show Document and Staging as compact single-line links",
|
||||
!memoryNav.includes("Staging</a>") &&
|
||||
!memoryNav.includes("Document</a>"),
|
||||
"Memory sidebar section should expose one Subjects link for the full nested route tree",
|
||||
);
|
||||
assert(
|
||||
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
memoryDocumentLoad.includes('"/memory"') &&
|
||||
memoryDocumentPage.includes("DocumentMarkdown from") &&
|
||||
memoryDocumentPage.includes("Read-only") &&
|
||||
memoryDocumentPage.includes("data.memory.data.body_md") &&
|
||||
memoryDocumentPage.includes("data.memory.data.updated_at") &&
|
||||
memoryDocumentPage.includes('role="alert"') &&
|
||||
!memoryDocumentPage.includes('<pre class="memory-document-body"'),
|
||||
"Memory Document page should read the scoped API and expose safe Markdown states without mutation controls",
|
||||
subjectIndexLoad.includes('"/subjektiv/subjects"') &&
|
||||
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
|
||||
subjectIndexPage.includes(">Subjects</h1>") &&
|
||||
subjectIndexPage.includes("subject.store_revision") &&
|
||||
subjectIndexPage.includes('data-memory-view="subjects"'),
|
||||
"Memory product entry should list explicit typed subjects",
|
||||
);
|
||||
assert(
|
||||
memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
memoryStagingLoad.includes('"/memory/staging"') &&
|
||||
memoryStagingPage.includes("Memory Staging") &&
|
||||
memoryStagingPage.includes("Workspace Server memory authority") &&
|
||||
memoryStagingPage.includes("data.staging.data.invalid_count") &&
|
||||
memoryStagingPage.includes("entry.record.evidence"),
|
||||
"Memory Staging page should read the scoped API and expose staged records without mutation controls",
|
||||
subjectPageLoad.includes("`${subjectPath}/surface`") &&
|
||||
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
||||
subjectPage.includes("DocumentMarkdown from") &&
|
||||
subjectPage.includes("Resident surface") &&
|
||||
subjectPage.includes("Current Memories") &&
|
||||
subjectPage.includes("surface?.availability === 'ready'") &&
|
||||
subjectPage.includes("surface?.availability === 'stale'") &&
|
||||
subjectPage.includes("data-surface-ready-empty") &&
|
||||
!subjectPage.includes("overflow-y"),
|
||||
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
|
||||
);
|
||||
assert(
|
||||
memoryDetailLoad.includes("`${memoryPath}/revisions`") &&
|
||||
memoryDetailLoad.includes("parseSubjektivMemoryReadResponse") &&
|
||||
memoryDetailPage.includes("Candidate provenance") &&
|
||||
memoryDetailPage.includes("Source refs") &&
|
||||
memoryDetailPage.includes("Derived from") &&
|
||||
memoryDetailPage.includes("Revision history") &&
|
||||
memoryDetailPage.includes("DocumentMarkdown from") &&
|
||||
!memoryDetailPage.includes("overflow-y"),
|
||||
"Committed Memory detail should preserve bounded provenance and immutable revisions without mutation controls",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -719,8 +740,12 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
|
||||
|
||||
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
|
||||
const files = [
|
||||
"ComposerInput.svelte", "ConsoleLineItem.svelte", "RichMarkdown.svelte",
|
||||
"ConsoleTasks.svelte", "WorkerRunStatus.svelte", "WorkerContextStatus.svelte",
|
||||
"ComposerInput.svelte",
|
||||
"ConsoleLineItem.svelte",
|
||||
"RichMarkdown.svelte",
|
||||
"ConsoleTasks.svelte",
|
||||
"WorkerRunStatus.svelte",
|
||||
"WorkerContextStatus.svelte",
|
||||
"ConsoleDisplayState.svelte",
|
||||
"ConsoleTurnNavigation.svelte",
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
@@ -729,7 +754,8 @@ Deno.test("Console spacing and text metrics use existing design tokens", async (
|
||||
const source = await Deno.readTextFile(new URL(file, import.meta.url));
|
||||
const css = source.split("<style>")[1];
|
||||
assert(
|
||||
!/(?:gap|padding(?:-[\w]+)?|margin(?:-[\w]+)?)\s*:[^;]*\d(?:\.\d+)?rem/.test(css),
|
||||
!/(?:gap|padding(?:-[\w]+)?|margin(?:-[\w]+)?)\s*:[^;]*\d(?:\.\d+)?rem/
|
||||
.test(css),
|
||||
`${file} should use shared spacing tokens, not a local rem spacing scale`,
|
||||
);
|
||||
assert(
|
||||
@@ -737,7 +763,9 @@ Deno.test("Console spacing and text metrics use existing design tokens", async (
|
||||
`${file} should use the body/compact line-height tokens`,
|
||||
);
|
||||
}
|
||||
const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url));
|
||||
const navigation = await Deno.readTextFile(
|
||||
new URL("./ConsoleTurnNavigation.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
navigation.includes("box-shadow: var(--shadow-overlay)") &&
|
||||
navigation.includes("font-size: var(--font-size-body)") &&
|
||||
@@ -764,7 +792,9 @@ Deno.test("Console and app shell keep compact layout spacing", async () => {
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const mainRules = [...layout.matchAll(/^\s*\.app-shell__main\s*\{([^}]+)\}/gm)];
|
||||
const mainRules = [
|
||||
...layout.matchAll(/^\s*\.app-shell__main\s*\{([^}]+)\}/gm),
|
||||
];
|
||||
assert(
|
||||
mainRules.length === 2 &&
|
||||
mainRules.every((rule) => rule[1].includes("padding: var(--space-4);")),
|
||||
@@ -785,7 +815,9 @@ Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves sc
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url));
|
||||
const navigation = await Deno.readTextFile(
|
||||
new URL("./ConsoleTurnNavigation.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
!/timeline/i.test(consolePage) &&
|
||||
consolePage.includes("conversationTurnPreviewsFromLines(lines)") &&
|
||||
@@ -814,11 +846,17 @@ Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves sc
|
||||
consolePage.includes('class="console-scroll"') &&
|
||||
consolePage.includes("overflow-y: auto") &&
|
||||
consolePage.includes("onscroll={handleConsoleScroll}") &&
|
||||
consolePage.includes("autoFollowConsole = isNearConsoleBottom(consoleBodyElement)") &&
|
||||
consolePage.includes(
|
||||
"autoFollowConsole = isNearConsoleBottom(consoleBodyElement)",
|
||||
) &&
|
||||
consolePage.includes("void scrollConsoleToBottom()") &&
|
||||
consolePage.includes("rememberConsoleWorkerViewScroll()") &&
|
||||
consolePage.includes("consoleBodyElement.scrollTop = resolveConsoleViewScrollTop(") &&
|
||||
consolePage.includes('event.key === "PageUp" || event.key === "PageDown"') &&
|
||||
consolePage.includes(
|
||||
"consoleBodyElement.scrollTop = resolveConsoleViewScrollTop(",
|
||||
) &&
|
||||
consolePage.includes(
|
||||
'event.key === "PageUp" || event.key === "PageDown"',
|
||||
) &&
|
||||
consolePage.includes("consoleBodyElement.scrollBy({"),
|
||||
"Native scroll, PageUp/PageDown, conditional bottom-follow and per-Worker scroll restoration must remain",
|
||||
);
|
||||
@@ -896,7 +934,8 @@ Deno.test("Worker Console composer separates the editor from its icon toolbar",
|
||||
const composerInput = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
const footerStyle = consolePage.match(/\.composer-input-footer\s*\{([^}]+)\}/)?.[1];
|
||||
const footerStyle = consolePage.match(/\.composer-input-footer\s*\{([^}]+)\}/)
|
||||
?.[1];
|
||||
assert(
|
||||
footerStyle?.includes("display: grid") &&
|
||||
!/position:\s*(absolute|fixed)/.test(footerStyle) &&
|
||||
@@ -905,7 +944,9 @@ Deno.test("Worker Console composer separates the editor from its icon toolbar",
|
||||
);
|
||||
assert(
|
||||
composerInput.includes("min-height: 2.65rem") &&
|
||||
composerInput.includes("padding: var(--space-2) var(--space-2) var(--space-1)") &&
|
||||
composerInput.includes(
|
||||
"padding: var(--space-2) var(--space-2) var(--space-1)",
|
||||
) &&
|
||||
!composerInput.includes("padding: 0.55rem 3.4rem 3rem 0.65rem"),
|
||||
"Editor must not reserve an overlapping toolbar inside its scrolling content",
|
||||
);
|
||||
@@ -997,19 +1038,32 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
||||
});
|
||||
|
||||
Deno.test("Worker Console routes image paste through the existing attachment upload", async () => {
|
||||
const consolePage = await Deno.readTextFile(new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url,
|
||||
));
|
||||
const input = await Deno.readTextFile(new URL("./ComposerInput.svelte", import.meta.url));
|
||||
const paste = input.slice(input.indexOf("function handlePasteEvent"), input.indexOf("function selectedClipboardContent"));
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const input = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
const paste = input.slice(
|
||||
input.indexOf("function handlePasteEvent"),
|
||||
input.indexOf("function selectedClipboardContent"),
|
||||
);
|
||||
assert(
|
||||
paste.indexOf("if (disabled || view?.state.readOnly)") < paste.indexOf("handleComposerImagePaste(event, onpasteimages)") &&
|
||||
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") < paste.indexOf('getData("text/plain")') &&
|
||||
paste.indexOf("if (disabled || view?.state.readOnly)") <
|
||||
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") &&
|
||||
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") <
|
||||
paste.indexOf('getData("text/plain")') &&
|
||||
consolePage.includes("onpasteimages={addPastedImages}") &&
|
||||
consolePage.includes("addAttachmentFiles(files.map(namePastedImage))"),
|
||||
"image paste must respect editability and take priority over text before using the attachment callback",
|
||||
);
|
||||
const attach = consolePage.slice(consolePage.indexOf("function addAttachmentFiles"), consolePage.indexOf("async function removeAttachment"));
|
||||
const attach = consolePage.slice(
|
||||
consolePage.indexOf("function addAttachmentFiles"),
|
||||
consolePage.indexOf("async function removeAttachment"),
|
||||
);
|
||||
assert(
|
||||
attach.includes("if (!composerEditable) return") &&
|
||||
attach.includes("MAX_FILES_PER_SUBMISSION - attachments.length") &&
|
||||
@@ -1488,43 +1542,98 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
|
||||
});
|
||||
|
||||
Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header buttons", async () => {
|
||||
const page = await Deno.readTextFile(new URL("./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url));
|
||||
const input = await Deno.readTextFile(new URL("./ComposerInput.svelte", import.meta.url));
|
||||
const header = page.slice(page.indexOf('<section class="console-header'), page.indexOf('{#if rewindTargets.length'));
|
||||
assert(!header.includes("Compact") && !header.includes("Rewind") && !page.includes("requestRewindTargets"), "Header must defer Compact/Rewind to commands");
|
||||
assert(input.includes('role="listbox"') && input.includes('role="option"') && input.includes("entry.description") && input.includes("completionStart + 6"), "Completion must expose a bounded selectable list with descriptions");
|
||||
assert(input.indexOf("keydown: completionKeydown") < input.indexOf('key: "ArrowUp"'), "Completion navigation must precede input history");
|
||||
assert(page.includes("fileCompletions.receive(event.data.entries)") && page.includes("fileCompletions.close()"), "File completion lifecycle must be scoped to the transport");
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const input = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
const header = page.slice(
|
||||
page.indexOf('<section class="console-header'),
|
||||
page.indexOf("{#if rewindTargets.length"),
|
||||
);
|
||||
assert(
|
||||
!header.includes("Compact") && !header.includes("Rewind") &&
|
||||
!page.includes("requestRewindTargets"),
|
||||
"Header must defer Compact/Rewind to commands",
|
||||
);
|
||||
assert(
|
||||
input.includes('role="listbox"') && input.includes('role="option"') &&
|
||||
input.includes("entry.description") &&
|
||||
input.includes("completionStart + 6"),
|
||||
"Completion must expose a bounded selectable list with descriptions",
|
||||
);
|
||||
assert(
|
||||
input.indexOf("keydown: completionKeydown") <
|
||||
input.indexOf('key: "ArrowUp"'),
|
||||
"Completion navigation must precede input history",
|
||||
);
|
||||
assert(
|
||||
page.includes("fileCompletions.receive(event.data.entries)") &&
|
||||
page.includes("fileCompletions.close()"),
|
||||
"File completion lifecycle must be scoped to the transport",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("mini task summary owns pane toggling instead of the header", async () => {
|
||||
const page = await Deno.readTextFile(new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url,
|
||||
));
|
||||
const component = await Deno.readTextFile(new URL("./ConsoleTasks.svelte", import.meta.url));
|
||||
const header = page.slice(page.indexOf('<section class="console-header'), page.indexOf('{#if rewindTargets.length'));
|
||||
assert(!header.includes("taskPaneOpen = !taskPaneOpen") && page.includes("onTogglePane={() => {"), "Only the mini summary should own Tasks toggling");
|
||||
assert(component.includes("aria-expanded={paneOpen}") && component.includes("aria-controls={paneId}") && component.includes("onclick={onTogglePane}"), "Tasks summary must be an accessible pane toggle");
|
||||
assert(!/\.task-pane\s*\{[^}]*display:\s*none/.test(component), "Narrow layouts must not hide the opened Tasks pane");
|
||||
assert(page.includes("grid-template-rows: minmax(0, 1fr) minmax(0, 1fr)"), "Narrow layouts should bound transcript and task scrolling separately");
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const component = await Deno.readTextFile(
|
||||
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||
);
|
||||
const header = page.slice(
|
||||
page.indexOf('<section class="console-header'),
|
||||
page.indexOf("{#if rewindTargets.length"),
|
||||
);
|
||||
assert(
|
||||
!header.includes("taskPaneOpen = !taskPaneOpen") &&
|
||||
page.includes("onTogglePane={() => {"),
|
||||
"Only the mini summary should own Tasks toggling",
|
||||
);
|
||||
assert(
|
||||
component.includes("aria-expanded={paneOpen}") &&
|
||||
component.includes("aria-controls={paneId}") &&
|
||||
component.includes("onclick={onTogglePane}"),
|
||||
"Tasks summary must be an accessible pane toggle",
|
||||
);
|
||||
assert(
|
||||
!/\.task-pane\s*\{[^}]*display:\s*none/.test(component),
|
||||
"Narrow layouts must not hide the opened Tasks pane",
|
||||
);
|
||||
assert(
|
||||
page.includes("grid-template-rows: minmax(0, 1fr) minmax(0, 1fr)"),
|
||||
"Narrow layouts should bound transcript and task scrolling separately",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => {
|
||||
const page = await Deno.readTextFile(new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
));
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const pending = page.indexOf('<section class="pending-submissions"');
|
||||
assert(
|
||||
page.indexOf("<ConsoleTasks") < pending && pending < page.indexOf('<form class="console-composer"'),
|
||||
page.indexOf("<ConsoleTasks") < pending &&
|
||||
pending < page.indexOf('<form class="console-composer"'),
|
||||
"Pending inputs should sit between Tasks and Composer",
|
||||
);
|
||||
const section = page.slice(pending, page.indexOf("</section>", pending));
|
||||
assert(
|
||||
!section.includes("<details") && !page.includes('method: "continue_pending"') &&
|
||||
!section.includes("{submission.submission_id}") && !section.includes("segment_count") &&
|
||||
!section.includes("<details") &&
|
||||
!page.includes('method: "continue_pending"') &&
|
||||
!section.includes("{submission.submission_id}") &&
|
||||
!section.includes("segment_count") &&
|
||||
section.includes('submission.preview || "Preview unavailable"') &&
|
||||
section.includes('aria-label={`Cancel queued input ${index + 1}`}') &&
|
||||
section.includes("aria-label={`Cancel queued input ${index + 1}`}") &&
|
||||
section.includes('aria-label="Notifications"') &&
|
||||
section.includes("pendingSubmissions.notification_previews ?? []") &&
|
||||
!page.includes('method: "clear_pending_submissions"'),
|
||||
@@ -1533,18 +1642,23 @@ Deno.test("Pending inputs are single-line previews between Tasks and Composer",
|
||||
const css = page.split("<style>")[1];
|
||||
const previewRule = css.match(/\.pending-submission-preview,[^{]+\{([^}]+)\}/)?.[1] ?? "";
|
||||
assert(
|
||||
previewRule.includes("white-space: nowrap") && previewRule.includes("min-width: 0") &&
|
||||
previewRule.includes("overflow: hidden") && previewRule.includes("text-overflow: ellipsis"),
|
||||
previewRule.includes("white-space: nowrap") &&
|
||||
previewRule.includes("min-width: 0") &&
|
||||
previewRule.includes("overflow: hidden") &&
|
||||
previewRule.includes("text-overflow: ellipsis"),
|
||||
"Preview text must truncate instead of wrapping or expanding the row",
|
||||
);
|
||||
assert(
|
||||
/\.pending-queue li\s*\{[^}]*grid-template-columns: minmax\(0, 1fr\) auto/.test(css) &&
|
||||
/\.pending-queue li\s*\{[^}]*grid-template-columns: minmax\(0, 1fr\) auto/
|
||||
.test(css) &&
|
||||
css.includes("grid-template-columns: repeat(2, minmax(0, 1fr))") &&
|
||||
!section.includes("BevelLine") &&
|
||||
/\.pending-submissions\s*\{[^}]*font-size: var\(--font-size-compact\);[^}]*line-height: var\(--line-height-compact\);/.test(css) &&
|
||||
/\.pending-submissions\s*\{[^}]*font-size: var\(--font-size-compact\);[^}]*line-height: var\(--line-height-compact\);/
|
||||
.test(css) &&
|
||||
css.includes(".pending-queue li:hover .pending-icon-button") &&
|
||||
css.includes(".pending-queue li:focus-within .pending-icon-button") &&
|
||||
css.includes("@media (hover: none)") && section.includes('<path d="M5 12h14"'),
|
||||
css.includes("@media (hover: none)") &&
|
||||
section.includes('<path d="M5 12h14"'),
|
||||
"Cancel icons must retain their column when preview text is long",
|
||||
);
|
||||
});
|
||||
@@ -1581,18 +1695,24 @@ Deno.test("Web Console uses Notify while running and exposes queued input cancel
|
||||
assert(
|
||||
consolePage.includes("handleQueueSubmit") &&
|
||||
consolePage.includes('delivery: "queue"') &&
|
||||
consolePage.includes('disabled={!canQueueDraft}') &&
|
||||
consolePage.includes("disabled={!canQueueDraft}") &&
|
||||
consolePage.includes('aria-label="Queue Submit"') &&
|
||||
consolePage.includes('class="composer-queue-icon"') &&
|
||||
consolePage.includes('{#if workerRunning || workerPaused}') &&
|
||||
consolePage.includes("{#if workerRunning || workerPaused}") &&
|
||||
consolePage.includes('delivery === "queue" && request.kind !== "user"') &&
|
||||
consolePage.includes('method.method === "submit" && delivery === "submit"'),
|
||||
consolePage.includes(
|
||||
'method.method === "submit" && delivery === "submit"',
|
||||
),
|
||||
"busy Console must offer explicit Queue without changing paused state or queueing commands",
|
||||
);
|
||||
const actions = consolePage.slice(consolePage.indexOf('class="composer-submit-actions"'));
|
||||
const actions = consolePage.slice(
|
||||
consolePage.indexOf('class="composer-submit-actions"'),
|
||||
);
|
||||
assert(
|
||||
actions.indexOf('class="composer-queue-button"') < actions.indexOf('class="composer-notify-button"') &&
|
||||
actions.indexOf('class="composer-notify-button"') < actions.indexOf('class="composer-send-button"'),
|
||||
actions.indexOf('class="composer-queue-button"') <
|
||||
actions.indexOf('class="composer-notify-button"') &&
|
||||
actions.indexOf('class="composer-notify-button"') <
|
||||
actions.indexOf('class="composer-send-button"'),
|
||||
"Queue must sit beside Notify and Submit/Stop",
|
||||
);
|
||||
|
||||
|
||||
@@ -12,7 +12,24 @@ import type {
|
||||
MemoryStagingEvidence,
|
||||
MemoryStagingListResponse,
|
||||
MemoryStagingRecord,
|
||||
SubjektivMemoryEvidence,
|
||||
SubjektivMemoryEvidenceCandidate,
|
||||
SubjektivMemoryListRevisionsResponse,
|
||||
SubjektivMemoryQueryItem,
|
||||
SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse,
|
||||
SubjektivMemoryRevisionItem,
|
||||
SubjektivMemoryRevisionRef,
|
||||
SubjektivMemorySourceEvidenceRef,
|
||||
SubjektivMemoryState,
|
||||
SubjektivResidentSurfaceAvailability,
|
||||
SubjektivResidentSurfaceResponse,
|
||||
SubjektivResidentSurfaceSnapshot,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
SubjektivSubjectState,
|
||||
} from "$lib/generated/memory-api";
|
||||
import { parseWorkerLaunchWorkerSummary } from "$lib/workspace/api/workers";
|
||||
|
||||
const MAX_STAGING_ITEMS = MEMORY_API_LIMITS.maxCollectionItems;
|
||||
const MAX_EVIDENCE_PER_RECORD = MEMORY_API_LIMITS.maxCollectionItems;
|
||||
@@ -42,6 +59,27 @@ const diagnosticSeverities = new Set<DiagnosticSeverity>([
|
||||
"warning",
|
||||
"error",
|
||||
]);
|
||||
const subjectStates = new Set<SubjektivSubjectState>(["active", "retired"]);
|
||||
const surfaceAvailabilities = new Set<SubjektivResidentSurfaceAvailability>([
|
||||
"ungenerated",
|
||||
"stale",
|
||||
"failed",
|
||||
"ready",
|
||||
]);
|
||||
const memoryStates = new Set<SubjektivMemoryState>([
|
||||
"active",
|
||||
"resolved",
|
||||
"retracted",
|
||||
]);
|
||||
const MAX_SUBJECTS = 100;
|
||||
const MAX_CURRENT_MEMORIES = 100;
|
||||
const MAX_REVISIONS = 100;
|
||||
const MAX_MEMORY_REFS = MEMORY_API_LIMITS.maxCollectionItems;
|
||||
|
||||
export const MEMORY_API_LOAD_POLICY = {
|
||||
diagnosticLabel: "Memory API",
|
||||
maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes,
|
||||
} as const;
|
||||
|
||||
export function parseMemoryDocumentResponse(
|
||||
value: unknown,
|
||||
@@ -190,9 +228,7 @@ 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;
|
||||
}
|
||||
@@ -223,9 +259,7 @@ 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;
|
||||
}
|
||||
@@ -283,6 +317,542 @@ function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSubjektivSubjectResponse(
|
||||
value: unknown,
|
||||
expectedSubjectId?: string,
|
||||
): SubjektivSubjectResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
[
|
||||
"id",
|
||||
"role",
|
||||
"state",
|
||||
"store_revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"current_worker",
|
||||
],
|
||||
"Subject response",
|
||||
["current_worker"],
|
||||
);
|
||||
const id = requiredIdentifier(record, "id");
|
||||
if (expectedSubjectId !== undefined && id !== expectedSubjectId) {
|
||||
invalid("Subject response identity does not match the requested subject");
|
||||
}
|
||||
const state = requiredString(record, "state") as SubjektivSubjectState;
|
||||
if (!subjectStates.has(state)) invalid("unknown Subject state");
|
||||
const result: SubjektivSubjectResponse = {
|
||||
id,
|
||||
role: requiredString(record, "role"),
|
||||
state,
|
||||
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
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",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSubjektivSubjectListResponse(
|
||||
value: unknown,
|
||||
): SubjektivSubjectListResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["limit", "items", "has_more"],
|
||||
"Subject list response",
|
||||
);
|
||||
const limit = positiveInteger(record.limit, "limit");
|
||||
if (limit > MAX_SUBJECTS) invalid("limit exceeds the Subject list bound");
|
||||
const items = boundedArray(record.items, MAX_SUBJECTS, "items").map((item) =>
|
||||
parseSubjektivSubjectResponse(item)
|
||||
);
|
||||
if (items.length > limit) invalid("items exceeds the declared Subject limit");
|
||||
assertUnique(items.map((item) => item.id), "Subject ids");
|
||||
return {
|
||||
limit,
|
||||
items,
|
||||
has_more: requiredBoolean(record, "has_more"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubjektivResidentSurfaceResponse(
|
||||
value: unknown,
|
||||
expectedSubjectId?: string,
|
||||
): SubjektivResidentSurfaceResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["subject_id", "availability", "snapshot"],
|
||||
"Resident surface response",
|
||||
["snapshot"],
|
||||
);
|
||||
const subjectId = requiredIdentifier(record, "subject_id");
|
||||
if (expectedSubjectId !== undefined && subjectId !== expectedSubjectId) {
|
||||
invalid("Resident surface identity does not match the requested subject");
|
||||
}
|
||||
const availability = requiredString(
|
||||
record,
|
||||
"availability",
|
||||
) as SubjektivResidentSurfaceAvailability;
|
||||
if (!surfaceAvailabilities.has(availability)) {
|
||||
invalid("unknown resident surface availability");
|
||||
}
|
||||
const snapshot = "snapshot" in record && record.snapshot !== null
|
||||
? parseResidentSurfaceSnapshot(record.snapshot)
|
||||
: null;
|
||||
if (availability === "ready" && snapshot === null) {
|
||||
invalid("ready resident surface requires a snapshot");
|
||||
}
|
||||
if (availability !== "ready" && snapshot !== null) {
|
||||
invalid("non-ready resident surface must not include a snapshot");
|
||||
}
|
||||
const result: SubjektivResidentSurfaceResponse = {
|
||||
subject_id: subjectId,
|
||||
availability,
|
||||
};
|
||||
if (snapshot !== null) result.snapshot = snapshot;
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseResidentSurfaceSnapshot(
|
||||
value: unknown,
|
||||
): SubjektivResidentSurfaceSnapshot {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
[
|
||||
"snapshot_id",
|
||||
"body_md",
|
||||
"memory_refs",
|
||||
"built_from_store_revision",
|
||||
"created_at",
|
||||
],
|
||||
"Resident surface snapshot",
|
||||
);
|
||||
return {
|
||||
snapshot_id: requiredIdentifier(record, "snapshot_id"),
|
||||
body_md: requiredString(
|
||||
record,
|
||||
"body_md",
|
||||
MEMORY_API_LIMITS.maxDocumentBytes,
|
||||
),
|
||||
memory_refs: boundedArray(
|
||||
record.memory_refs,
|
||||
MAX_MEMORY_REFS,
|
||||
"memory_refs",
|
||||
).map(parseMemoryRevisionRef),
|
||||
built_from_store_revision: requiredNonNegativeInteger(
|
||||
record,
|
||||
"built_from_store_revision",
|
||||
),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubjektivMemoryQueryResponse(
|
||||
value: unknown,
|
||||
): SubjektivMemoryQueryResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["items", "next_cursor", "has_more"],
|
||||
"Current Memory list response",
|
||||
["next_cursor"],
|
||||
);
|
||||
const items = boundedArray(
|
||||
record.items,
|
||||
MAX_CURRENT_MEMORIES,
|
||||
"items",
|
||||
).map(parseMemoryQueryItem);
|
||||
assertUnique(items.map((item) => item.id), "Memory ids");
|
||||
const hasMore = requiredBoolean(record, "has_more");
|
||||
const nextCursor = optionalNullableString(record, "next_cursor");
|
||||
assertCursorInvariant(hasMore, nextCursor, "Memory list");
|
||||
const result: SubjektivMemoryQueryResponse = { items, has_more: hasMore };
|
||||
if (nextCursor !== undefined) result.next_cursor = nextCursor;
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemoryQueryItem(value: unknown): SubjektivMemoryQueryItem {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["id", "revision", "kind", "state", "claim", "excerpt", "updated_at"],
|
||||
"Current Memory item",
|
||||
);
|
||||
return {
|
||||
id: requiredIdentifier(record, "id"),
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
excerpt: requiredString(record, "excerpt"),
|
||||
updated_at: requiredString(record, "updated_at"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubjektivMemoryReadResponse(
|
||||
value: unknown,
|
||||
expectedMemoryId?: string,
|
||||
): SubjektivMemoryReadResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
[
|
||||
"memory_id",
|
||||
"revision",
|
||||
"current_revision",
|
||||
"kind",
|
||||
"state",
|
||||
"claim",
|
||||
"body_md",
|
||||
"why_useful",
|
||||
"staleness",
|
||||
"change_reason",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"body_offset",
|
||||
"body_byte_offset",
|
||||
"body_next_offset",
|
||||
"body_next_byte_offset",
|
||||
"body_truncated",
|
||||
"source_candidate_ids",
|
||||
"source_candidates",
|
||||
"derived_from",
|
||||
"evidence_next_cursor",
|
||||
"evidence_has_more",
|
||||
],
|
||||
"Memory detail response",
|
||||
[
|
||||
"staleness",
|
||||
"body_next_offset",
|
||||
"body_next_byte_offset",
|
||||
"evidence_next_cursor",
|
||||
],
|
||||
);
|
||||
const memoryId = requiredIdentifier(record, "memory_id");
|
||||
if (expectedMemoryId !== undefined && memoryId !== expectedMemoryId) {
|
||||
invalid("Memory detail identity does not match the requested Memory");
|
||||
}
|
||||
const revision = requiredPositiveInteger(record, "revision");
|
||||
const currentRevision = requiredPositiveInteger(record, "current_revision");
|
||||
if (revision > currentRevision) invalid("revision exceeds current_revision");
|
||||
const bodyTruncated = requiredBoolean(record, "body_truncated");
|
||||
const bodyNextOffset = optionalNonNegativeInteger(record, "body_next_offset");
|
||||
const bodyNextByteOffset = optionalNonNegativeInteger(
|
||||
record,
|
||||
"body_next_byte_offset",
|
||||
);
|
||||
if (
|
||||
bodyTruncated !==
|
||||
(bodyNextOffset !== undefined && bodyNextByteOffset !== undefined)
|
||||
) {
|
||||
invalid("body continuation fields do not match body_truncated");
|
||||
}
|
||||
const sourceCandidateIds = boundedArray(
|
||||
record.source_candidate_ids,
|
||||
MAX_MEMORY_REFS,
|
||||
"source_candidate_ids",
|
||||
).map((item) => identifier(item, "source_candidate_ids item"));
|
||||
assertUnique(sourceCandidateIds, "source candidate ids");
|
||||
const sourceCandidates = boundedArray(
|
||||
record.source_candidates,
|
||||
MAX_MEMORY_REFS,
|
||||
"source_candidates",
|
||||
).map(parseMemoryEvidenceCandidate);
|
||||
if (
|
||||
sourceCandidateIds.length !== sourceCandidates.length ||
|
||||
sourceCandidateIds.some((id, index) => sourceCandidates[index]?.candidate_id !== id)
|
||||
) {
|
||||
invalid("source candidate ids do not match source candidates");
|
||||
}
|
||||
const evidenceHasMore = requiredBoolean(record, "evidence_has_more");
|
||||
const evidenceNextCursor = optionalNullableString(
|
||||
record,
|
||||
"evidence_next_cursor",
|
||||
);
|
||||
assertCursorInvariant(evidenceHasMore, evidenceNextCursor, "Memory evidence");
|
||||
const result: SubjektivMemoryReadResponse = {
|
||||
memory_id: memoryId,
|
||||
revision,
|
||||
current_revision: currentRevision,
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
body_md: requiredString(
|
||||
record,
|
||||
"body_md",
|
||||
MEMORY_API_LIMITS.maxDocumentBytes,
|
||||
),
|
||||
why_useful: requiredString(record, "why_useful"),
|
||||
change_reason: requiredString(record, "change_reason"),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
updated_at: requiredString(record, "updated_at"),
|
||||
body_offset: requiredNonNegativeInteger(record, "body_offset"),
|
||||
body_byte_offset: requiredNonNegativeInteger(record, "body_byte_offset"),
|
||||
body_truncated: bodyTruncated,
|
||||
source_candidate_ids: sourceCandidateIds,
|
||||
source_candidates: sourceCandidates,
|
||||
derived_from: boundedArray(
|
||||
record.derived_from,
|
||||
MAX_MEMORY_REFS,
|
||||
"derived_from",
|
||||
).map(parseMemoryRevisionRef),
|
||||
evidence_has_more: evidenceHasMore,
|
||||
};
|
||||
if ("staleness" in record) {
|
||||
result.staleness = nullableString(record, "staleness");
|
||||
}
|
||||
if (bodyNextOffset !== undefined) result.body_next_offset = bodyNextOffset;
|
||||
if (bodyNextByteOffset !== undefined) {
|
||||
result.body_next_byte_offset = bodyNextByteOffset;
|
||||
}
|
||||
if (evidenceNextCursor !== undefined) {
|
||||
result.evidence_next_cursor = evidenceNextCursor;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemoryEvidenceCandidate(
|
||||
value: unknown,
|
||||
): SubjektivMemoryEvidenceCandidate {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
[
|
||||
"candidate_id",
|
||||
"evidence",
|
||||
"evidence_total",
|
||||
"evidence_truncated",
|
||||
"source_refs",
|
||||
"source_refs_total",
|
||||
"source_refs_truncated",
|
||||
],
|
||||
"Memory evidence candidate",
|
||||
);
|
||||
const evidence = boundedArray(
|
||||
record.evidence,
|
||||
MAX_EVIDENCE_PER_RECORD,
|
||||
"evidence",
|
||||
).map(parseMemoryEvidence);
|
||||
assertUnique(evidence.map((item) => item.id), "evidence ids");
|
||||
const evidenceTotal = requiredNonNegativeInteger(record, "evidence_total");
|
||||
const evidenceTruncated = requiredBoolean(record, "evidence_truncated");
|
||||
assertCollectionTotal(
|
||||
evidence.length,
|
||||
evidenceTotal,
|
||||
evidenceTruncated,
|
||||
"evidence",
|
||||
);
|
||||
const sourceRefs = boundedArray(
|
||||
record.source_refs,
|
||||
MAX_SOURCE_REFS_PER_RECORD,
|
||||
"source_refs",
|
||||
).map(parseMemorySourceEvidenceRef);
|
||||
const sourceRefsTotal = requiredNonNegativeInteger(
|
||||
record,
|
||||
"source_refs_total",
|
||||
);
|
||||
const sourceRefsTruncated = requiredBoolean(
|
||||
record,
|
||||
"source_refs_truncated",
|
||||
);
|
||||
assertCollectionTotal(
|
||||
sourceRefs.length,
|
||||
sourceRefsTotal,
|
||||
sourceRefsTruncated,
|
||||
"source refs",
|
||||
);
|
||||
return {
|
||||
candidate_id: requiredIdentifier(record, "candidate_id"),
|
||||
evidence,
|
||||
evidence_total: evidenceTotal,
|
||||
evidence_truncated: evidenceTruncated,
|
||||
source_refs: sourceRefs,
|
||||
source_refs_total: sourceRefsTotal,
|
||||
source_refs_truncated: sourceRefsTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMemoryEvidence(value: unknown): SubjektivMemoryEvidence {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["id", "kind", "entry_range", "origin", "excerpt", "summary"],
|
||||
"Memory evidence",
|
||||
["entry_range", "origin", "excerpt", "summary"],
|
||||
);
|
||||
const result: SubjektivMemoryEvidence = {
|
||||
id: requiredIdentifier(record, "id"),
|
||||
kind: requiredString(record, "kind"),
|
||||
};
|
||||
if ("entry_range" in record) {
|
||||
result.entry_range = record.entry_range === null
|
||||
? null
|
||||
: parseEntryRange(record.entry_range, "entry_range");
|
||||
}
|
||||
if ("origin" in record) {
|
||||
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");
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemorySourceEvidenceRef(
|
||||
value: unknown,
|
||||
): SubjektivMemorySourceEvidenceRef {
|
||||
const keys = [
|
||||
"session_id",
|
||||
"segment_id",
|
||||
"entry_range",
|
||||
"evidence_id",
|
||||
"origin",
|
||||
"evidence_kind",
|
||||
"label",
|
||||
"summary",
|
||||
] as const;
|
||||
const record = strictRecord(
|
||||
value,
|
||||
keys,
|
||||
"Memory source evidence ref",
|
||||
keys,
|
||||
);
|
||||
const result: SubjektivMemorySourceEvidenceRef = {};
|
||||
for (
|
||||
const key of [
|
||||
"session_id",
|
||||
"segment_id",
|
||||
"evidence_id",
|
||||
"evidence_kind",
|
||||
"label",
|
||||
"summary",
|
||||
] as const
|
||||
) {
|
||||
if (key in record) result[key] = nullableString(record, key);
|
||||
}
|
||||
if ("entry_range" in record) {
|
||||
result.entry_range = record.entry_range === null
|
||||
? null
|
||||
: parseEntryRange(record.entry_range, "entry_range");
|
||||
}
|
||||
if ("origin" in record) {
|
||||
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSubjektivMemoryListRevisionsResponse(
|
||||
value: unknown,
|
||||
expectedMemoryId?: string,
|
||||
): SubjektivMemoryListRevisionsResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["memory_id", "current_revision", "items", "next_cursor", "has_more"],
|
||||
"Memory revisions response",
|
||||
["next_cursor"],
|
||||
);
|
||||
const memoryId = requiredIdentifier(record, "memory_id");
|
||||
if (expectedMemoryId !== undefined && memoryId !== expectedMemoryId) {
|
||||
invalid("Memory revisions identity does not match the requested Memory");
|
||||
}
|
||||
const currentRevision = requiredPositiveInteger(record, "current_revision");
|
||||
const items = boundedArray(record.items, MAX_REVISIONS, "items").map(
|
||||
parseMemoryRevisionItem,
|
||||
);
|
||||
if (items.some((item) => item.revision > currentRevision)) {
|
||||
invalid("revision history contains a future revision");
|
||||
}
|
||||
assertUnique(
|
||||
items.map((item) => String(item.revision)),
|
||||
"Memory revisions",
|
||||
);
|
||||
const hasMore = requiredBoolean(record, "has_more");
|
||||
const nextCursor = optionalNullableString(record, "next_cursor");
|
||||
assertCursorInvariant(hasMore, nextCursor, "Memory revisions");
|
||||
const result: SubjektivMemoryListRevisionsResponse = {
|
||||
memory_id: memoryId,
|
||||
current_revision: currentRevision,
|
||||
items,
|
||||
has_more: hasMore,
|
||||
};
|
||||
if (nextCursor !== undefined) result.next_cursor = nextCursor;
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemoryRevisionItem(value: unknown): SubjektivMemoryRevisionItem {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["revision", "kind", "state", "claim", "change_reason", "updated_at"],
|
||||
"Memory revision item",
|
||||
);
|
||||
return {
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
change_reason: requiredString(record, "change_reason"),
|
||||
updated_at: requiredString(record, "updated_at"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMemoryRevisionRef(value: unknown): SubjektivMemoryRevisionRef {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["memory_id", "revision"],
|
||||
"Memory revision ref",
|
||||
);
|
||||
return {
|
||||
memory_id: requiredIdentifier(record, "memory_id"),
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCandidateKind(value: unknown): MemoryCandidateKind {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!candidateKinds.has(value as MemoryCandidateKind)
|
||||
) {
|
||||
invalid("unknown Memory candidate kind");
|
||||
}
|
||||
return value as MemoryCandidateKind;
|
||||
}
|
||||
|
||||
function parseMemoryState(value: unknown): SubjektivMemoryState {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!memoryStates.has(value as SubjektivMemoryState)
|
||||
) {
|
||||
invalid("unknown Memory state");
|
||||
}
|
||||
return value as SubjektivMemoryState;
|
||||
}
|
||||
|
||||
function assertCollectionTotal(
|
||||
returned: number,
|
||||
total: number,
|
||||
truncated: boolean,
|
||||
label: string,
|
||||
): void {
|
||||
if (total < returned || truncated !== (total > returned)) {
|
||||
invalid(`${label} total does not match its truncated state`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCursorInvariant(
|
||||
hasMore: boolean,
|
||||
cursor: string | null | undefined,
|
||||
label: string,
|
||||
): void {
|
||||
if (hasMore !== (typeof cursor === "string" && cursor.length > 0)) {
|
||||
invalid(`${label} cursor does not match has_more`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnique(values: string[], label: string): void {
|
||||
if (new Set(values).size !== values.length) {
|
||||
invalid(`${label} must be unique`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDiagnostic(value: unknown): Diagnostic {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
@@ -384,6 +954,55 @@ function requiredNonNegativeInteger(
|
||||
return nonNegativeInteger(record[key], key);
|
||||
}
|
||||
|
||||
function requiredPositiveInteger(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): number {
|
||||
return positiveInteger(record[key], key);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
const result = nonNegativeInteger(value, label);
|
||||
if (result === 0) invalid(`${label} must be a positive safe integer`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function optionalNonNegativeInteger(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
if (!(key in record)) return undefined;
|
||||
if (record[key] === null) return undefined;
|
||||
return nonNegativeInteger(record[key], key);
|
||||
}
|
||||
|
||||
function requiredIdentifier(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): string {
|
||||
return identifier(record[key], key);
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
new TextEncoder().encode(value).byteLength >
|
||||
MEMORY_API_LIMITS.maxIdentifierBytes
|
||||
) {
|
||||
invalid(`${label} must be a non-empty bounded identifier`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalNullableString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | null | undefined {
|
||||
if (!(key in record)) return undefined;
|
||||
return nullableString(record, key);
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
invalid(`${label} must be a non-negative safe integer`);
|
||||
@@ -395,10 +1014,10 @@ function parseEntryRange(value: unknown, label: string): [number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
invalid(`${label} must be a two-item entry range`);
|
||||
}
|
||||
return [
|
||||
nonNegativeInteger(value[0], label),
|
||||
nonNegativeInteger(value[1], label),
|
||||
];
|
||||
const start = nonNegativeInteger(value[0], label);
|
||||
const end = nonNegativeInteger(value[1], label);
|
||||
if (start > end) invalid(`${label} must be ordered`);
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
function parseNullableEntryRange(
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
};
|
||||
|
||||
let { currentPath = "/", workspaceId }: Props = $props();
|
||||
let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory") : "/");
|
||||
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory/staging") : "/");
|
||||
let subjectsHref = $derived(
|
||||
workspaceId ? workspaceRoute(workspaceId, "/memory") : "/",
|
||||
);
|
||||
let subjectsActive = $derived(
|
||||
currentPath === subjectsHref || currentPath.startsWith(`${subjectsHref}/`),
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="sidebar-nav-section sidebar-nav-section--category">
|
||||
@@ -16,15 +20,8 @@
|
||||
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath === documentHref}
|
||||
href={documentHref}
|
||||
aria-current={currentPath === documentHref ? "page" : undefined}
|
||||
>Document</a>
|
||||
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath.startsWith(stagingHref)}
|
||||
href={stagingHref}
|
||||
aria-current={currentPath.startsWith(stagingHref) ? "page" : undefined}
|
||||
>Staging</a>
|
||||
class:active={subjectsActive}
|
||||
href={subjectsHref}
|
||||
aria-current={subjectsActive ? "page" : undefined}
|
||||
>Subjects</a>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const designLabBasePath = '/design-lab/workspace-web-ux';
|
||||
export const designLabBasePath = "/design-lab/workspace-web-ux";
|
||||
|
||||
export type WorkspaceNavigationItem = {
|
||||
label: string;
|
||||
@@ -7,31 +7,36 @@ export type WorkspaceNavigationItem = {
|
||||
};
|
||||
|
||||
export const workspaceNavigation: WorkspaceNavigationItem[] = [
|
||||
{ label: 'Tickets', href: `${designLabBasePath}?resource=tickets` },
|
||||
{ label: 'Objectives', href: `${designLabBasePath}?resource=objectives` },
|
||||
{ label: 'Merge Requests', href: `${designLabBasePath}?resource=merge-requests` },
|
||||
{ label: "Tickets", href: `${designLabBasePath}?resource=tickets` },
|
||||
{ label: "Objectives", href: `${designLabBasePath}?resource=objectives` },
|
||||
{
|
||||
label: 'Memory',
|
||||
label: "Merge Requests",
|
||||
href: `${designLabBasePath}?resource=merge-requests`,
|
||||
},
|
||||
{
|
||||
label: "Memory",
|
||||
href: `${designLabBasePath}?resource=memory`,
|
||||
children: [
|
||||
{ label: 'Document', href: `${designLabBasePath}?resource=memory-document` },
|
||||
{ label: 'Staging', href: `${designLabBasePath}?resource=memory-staging` },
|
||||
{
|
||||
label: "Subjects",
|
||||
href: `${designLabBasePath}?resource=memory-subjects`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ label: 'Workers', href: `${designLabBasePath}?resource=workers` },
|
||||
{ label: "Workers", href: `${designLabBasePath}?resource=workers` },
|
||||
];
|
||||
|
||||
export const workspaceWorkers = [
|
||||
{
|
||||
key: 'wrk-language-review',
|
||||
label: 'Language review',
|
||||
state: 'Running',
|
||||
repository: 'yoi',
|
||||
key: "wrk-language-review",
|
||||
label: "Language review",
|
||||
state: "Running",
|
||||
repository: "yoi",
|
||||
},
|
||||
{
|
||||
key: 'wrk-accessibility-check',
|
||||
label: 'Accessibility check with a deliberately long display name',
|
||||
state: 'Idle',
|
||||
repository: 'workspace-web',
|
||||
key: "wrk-accessibility-check",
|
||||
label: "Accessibility check with a deliberately long display name",
|
||||
state: "Idle",
|
||||
repository: "workspace-web",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -1,66 +1,74 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const subjects = $derived(data.subjects.data?.items ?? []);
|
||||
|
||||
function subjectHref(subjectId: string): string {
|
||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory document" />
|
||||
<title>Subjects · Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory subjects" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="memory-document-page" aria-label="Memory document">
|
||||
{#if data.memory.data}
|
||||
<div class="memory-document-meta">
|
||||
<p>
|
||||
<span>Read-only</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Updated <time datetime={data.memory.data.updated_at}>{formatDate(data.memory.data.updated_at)}</time></span>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Document details</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd><time datetime={data.memory.data.created_at}>{formatDate(data.memory.data.created_at)}</time></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{data.memory.data.bytes} bytes</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd><code>{data.memory.data.record_source}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
<section class="memory-page memory-subject-index" aria-labelledby="memory-subjects-heading" data-memory-view="subjects">
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Memory</p>
|
||||
<h1 id="memory-subjects-heading">Subjects</h1>
|
||||
<p>Read-only durable context, organized by explicit subject.</p>
|
||||
</div>
|
||||
{#if data.subjects.data}
|
||||
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if data.memory.data.body_md.trim().length === 0}
|
||||
<div class="memory-document-state" role="status">
|
||||
<strong>Memory document is empty.</strong>
|
||||
<p>Durable Workspace context will appear here when it is available.</p>
|
||||
{#if data.subjects.data}
|
||||
{#if subjects.length === 0}
|
||||
<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>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="memory-document-content" aria-label="Memory document content">
|
||||
<DocumentMarkdown text={data.memory.data.body_md} />
|
||||
</article>
|
||||
<div class="subject-list" aria-label="Memory subjects">
|
||||
{#each subjects as subject (subject.id)}
|
||||
<a class="subject-row" href={subjectHref(subject.id)}>
|
||||
<div class="subject-copy">
|
||||
<span class="memory-state-pill is-{subject.state}">{subject.state}</span>
|
||||
<h2>{subject.role}</h2>
|
||||
<code title={subject.id}>{subject.id}</code>
|
||||
</div>
|
||||
<dl class="subject-meta">
|
||||
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
|
||||
</dl>
|
||||
</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}
|
||||
{/if}
|
||||
{:else if data.memory.error}
|
||||
<div class="memory-document-state memory-document-error">
|
||||
<p role="alert"><strong>Memory document unavailable.</strong> {data.memory.error}</p>
|
||||
{:else if data.subjects.error}
|
||||
<div class="memory-state is-error" role="alert" data-memory-state="error">
|
||||
<strong>Subjects unavailable.</strong>
|
||||
<p>{data.subjects.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-document-state" role="status">
|
||||
<p>Memory document data is unavailable.</p>
|
||||
<div class="memory-state" role="status" data-memory-state="unavailable">
|
||||
<p>Subject data is unavailable.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-document-page {
|
||||
.memory-page {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
@@ -70,96 +78,161 @@
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-document-meta {
|
||||
.memory-page-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
line-height: var(--line-height-compact);
|
||||
gap: var(--space-5);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-document-meta > p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
.memory-page-header h1,
|
||||
.subject-copy h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.memory-document-meta details {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.memory-document-meta summary {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: min(24rem, calc(100vw - (2 * var(--space-4))));
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-3) 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-document-meta dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-document-meta dt {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.memory-document-meta dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-document-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-document-state {
|
||||
padding-block: var(--space-5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-document-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-document-state p {
|
||||
.memory-page-header h1 {
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
.memory-page-header p:not(.memory-eyebrow) {
|
||||
max-width: 46rem;
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--accent);
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-list {
|
||||
display: grid;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.subject-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(22rem, 0.8fr);
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.subject-row:hover,
|
||||
.subject-row:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.subject-copy {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-copy h2 {
|
||||
font-size: var(--font-size-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-copy code {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-state-pill {
|
||||
color: var(--success);
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-state-pill.is-retired {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.subject-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.subject-meta div {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-meta dt {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.subject-meta dd {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-state {
|
||||
padding: var(--space-5) 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-state p,
|
||||
.memory-note {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-document-state > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.memory-document-error,
|
||||
.memory-document-error strong {
|
||||
.memory-state.is-error,
|
||||
.memory-state.is-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.subject-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-document-meta {
|
||||
.memory-page-header {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.memory-document-meta details {
|
||||
width: 100%;
|
||||
.subject-row {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
min-width: 0;
|
||||
.subject-meta {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { MEMORY_API_LIMITS } from "$lib/generated/memory-api";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { parseMemoryDocumentResponse } from "$lib/workspace/memory/api";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivSubjectListResponse,
|
||||
} from "$lib/workspace/memory/api";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
memory: await loadJson(
|
||||
subjects: await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/memory"),
|
||||
`${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?limit=100`,
|
||||
undefined,
|
||||
parseMemoryDocumentResponse,
|
||||
{
|
||||
diagnosticLabel: "Memory API",
|
||||
maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes,
|
||||
},
|
||||
parseSubjektivSubjectListResponse,
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type { SubjektivMemoryState } from '$lib/generated/memory-api';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const surface = $derived(data.surface.data);
|
||||
const snapshot = $derived(surface?.snapshot ?? null);
|
||||
const memories = $derived(data.memories.data?.items ?? []);
|
||||
|
||||
function subjectsHref(): string {
|
||||
return workspaceRoute(data.workspaceId, '/memory');
|
||||
}
|
||||
|
||||
function memoryHref(memoryId: string): string {
|
||||
return workspaceRoute(
|
||||
data.workspaceId,
|
||||
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function stateLabel(state: SubjektivMemoryState): string {
|
||||
return state[0].toUpperCase() + state.slice(1);
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return kind.replaceAll('_', ' ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Subject resident surface and current committed Memories" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="memory-page memory-subject-page" aria-labelledby="subject-heading" data-memory-view="subject" data-memory-state={surface?.availability ?? (data.surface.error ? 'error' : 'unavailable')}>
|
||||
<a class="memory-back-link" href={subjectsHref()}>← All subjects</a>
|
||||
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Memory subject</p>
|
||||
<h1 id="subject-heading">{data.subject.data?.role ?? data.subjectId}</h1>
|
||||
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
|
||||
</div>
|
||||
<span class="read-only-label">Read-only</span>
|
||||
</header>
|
||||
|
||||
{#if data.subject.data}
|
||||
<dl class="subject-facts" aria-label="Subject details">
|
||||
<div><dt>Role</dt><dd>{data.subject.data.role}</dd></div>
|
||||
<div><dt>State</dt><dd><span class="subject-state is-{data.subject.data.state}">{data.subject.data.state}</span></dd></div>
|
||||
<div><dt>Store revision</dt><dd>{data.subject.data.store_revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
|
||||
<div><dt>Current worker</dt><dd>{data.subject.data.current_worker?.display_name ?? 'None'}</dd></div>
|
||||
</dl>
|
||||
{:else if data.subject.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Subject details unavailable.</strong>
|
||||
<p>{data.subject.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
|
||||
{/if}
|
||||
|
||||
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Resident context</p>
|
||||
<h2 id="resident-surface-heading">Resident surface</h2>
|
||||
</div>
|
||||
{#if surface}
|
||||
<span class="availability is-{surface.availability}">{surface.availability}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if surface?.availability === 'ready' && snapshot}
|
||||
<div class="surface-meta">
|
||||
<span>Built from store revision {snapshot.built_from_store_revision}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
{#if snapshot.body_md.trim().length === 0}
|
||||
<div class="memory-state" role="status" data-surface-ready-empty>
|
||||
<strong>Resident surface is ready and empty.</strong>
|
||||
<p>This subject currently has no resident context to display.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="surface-document" aria-label="Resident surface">
|
||||
<DocumentMarkdown text={snapshot.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
<details class="surface-details">
|
||||
<summary>Surface provenance</summary>
|
||||
<dl>
|
||||
<div><dt>Snapshot id</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
||||
<div>
|
||||
<dt>Memory refs</dt>
|
||||
<dd>
|
||||
{#if snapshot.memory_refs.length === 0}
|
||||
None
|
||||
{:else}
|
||||
<ul class="reference-list">
|
||||
{#each snapshot.memory_refs as reference (`${reference.memory_id}:${reference.revision}`)}
|
||||
<li><a href={memoryHref(reference.memory_id)}><code>{reference.memory_id}</code> · revision {reference.revision}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
{:else if surface?.availability === 'ungenerated'}
|
||||
<div class="memory-state" role="status">
|
||||
<strong>Resident surface has not been generated.</strong>
|
||||
<p>Committed Memories remain available below.</p>
|
||||
</div>
|
||||
{:else if surface?.availability === 'stale'}
|
||||
<div class="memory-state is-warning" role="status">
|
||||
<strong>Resident surface is stale.</strong>
|
||||
<p>The latest committed Memories are not represented by a publishable snapshot.</p>
|
||||
</div>
|
||||
{:else if surface?.availability === 'failed'}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Resident surface generation failed.</strong>
|
||||
<p>No failed or partial snapshot is displayed.</p>
|
||||
</div>
|
||||
{:else if data.surface.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Resident surface unavailable.</strong>
|
||||
<p>{data.surface.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Resident surface data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="memories-section" aria-labelledby="current-memories-heading">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Committed records</p>
|
||||
<h2 id="current-memories-heading">Current Memories</h2>
|
||||
</div>
|
||||
{#if data.memories.data}<span class="memory-count">{memories.length}{data.memories.data.has_more ? '+' : ''}</span>{/if}
|
||||
</header>
|
||||
|
||||
{#if data.memories.data}
|
||||
{#if memories.length === 0}
|
||||
<div class="memory-state" role="status" data-memories-empty>
|
||||
<strong>No committed Memories.</strong>
|
||||
<p>This subject has no current Memory revisions.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-list" aria-label="Current committed Memories">
|
||||
{#each memories as memory (memory.id)}
|
||||
<a class="memory-row" href={memoryHref(memory.id)}>
|
||||
<div class="memory-row-heading">
|
||||
<div class="pill-row">
|
||||
<span class="memory-pill is-{memory.state}">{stateLabel(memory.state)}</span>
|
||||
<span class="memory-kind">{kindLabel(memory.kind)}</span>
|
||||
</div>
|
||||
<h3>{memory.claim}</h3>
|
||||
<p>{memory.excerpt || 'No excerpt.'}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Memory id</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
|
||||
<div><dt>Revision</dt><dd>{memory.revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if data.memories.data.has_more}
|
||||
<p class="memory-note">More current Memories are available after this bounded page.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if data.memories.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Current Memories unavailable.</strong>
|
||||
<p>{data.memories.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Current Memory data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-page {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 78rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-back-link {
|
||||
width: fit-content;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.memory-page-header,
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.memory-page-header {
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-page-header h1,
|
||||
.section-heading h2,
|
||||
.memory-row h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-page-header h1 {
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.memory-eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--accent);
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subject-id {
|
||||
display: block;
|
||||
max-width: min(60rem, 100%);
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.read-only-label,
|
||||
.memory-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-facts {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.subject-facts > div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.subject-facts dt {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.subject-facts dd {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-state,
|
||||
.availability,
|
||||
.memory-pill,
|
||||
.memory-kind {
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subject-state,
|
||||
.availability.is-ready,
|
||||
.memory-pill.is-active {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.subject-state.is-retired,
|
||||
.availability.is-ungenerated,
|
||||
.memory-pill.is-retracted {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.availability.is-stale,
|
||||
.memory-pill.is-resolved {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.availability.is-failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.surface-section,
|
||||
.memories-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.surface-meta,
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.surface-document {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.surface-details {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.surface-details summary {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.surface-details dl {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.surface-details dd,
|
||||
.reference-list code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reference-list {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.memory-list {
|
||||
display: grid;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(19rem, 0.8fr);
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.memory-row:hover,
|
||||
.memory-row:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.memory-row-heading {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-row h3 {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.memory-row p,
|
||||
.memory-note {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-kind {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.memory-row dl {
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.memory-row dl > div {
|
||||
grid-template-columns: 6.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.memory-row dd,
|
||||
.memory-row code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-state {
|
||||
padding: var(--space-4) 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-state p {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.memory-state.is-warning,
|
||||
.memory-state.is-warning strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.memory-state.is-error,
|
||||
.memory-state.is-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.subject-facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-page-header,
|
||||
.section-heading {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.subject-facts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.memory-row dl > div,
|
||||
.surface-details dl > div {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
parseSubjektivResidentSurfaceResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "$lib/workspace/memory/api";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const subjectId = params.subjectId;
|
||||
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
|
||||
const [subject, surface, memories] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, subjectPath),
|
||||
undefined,
|
||||
(value) => parseSubjektivSubjectResponse(value, subjectId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, `${subjectPath}/surface`),
|
||||
undefined,
|
||||
(value) => parseSubjektivResidentSurfaceResponse(value, subjectId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
loadJson(
|
||||
fetch,
|
||||
`${workspaceApiPath(params.workspaceId, `${subjectPath}/memories`)}?limit=100`,
|
||||
undefined,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
subjectId,
|
||||
subject,
|
||||
surface,
|
||||
memories,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,626 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type {
|
||||
MemoryEvidenceOrigin,
|
||||
SubjektivMemoryRevisionRef,
|
||||
SubjektivMemorySourceEvidenceRef,
|
||||
} from '$lib/generated/memory-api';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const memory = $derived(data.memory.data);
|
||||
const revisions = $derived(data.revisions.data?.items ?? []);
|
||||
|
||||
function subjectHref(): string {
|
||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`);
|
||||
}
|
||||
|
||||
function memoryPath(memoryId = data.memoryId): string {
|
||||
return workspaceRoute(
|
||||
data.workspaceId,
|
||||
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function revisionHref(revision: number): string {
|
||||
return `${memoryPath()}?revision=${revision}`;
|
||||
}
|
||||
|
||||
function derivedHref(reference: SubjektivMemoryRevisionRef): string {
|
||||
return `${memoryPath(reference.memory_id)}?revision=${reference.revision}`;
|
||||
}
|
||||
|
||||
function bodyContinuationHref(): string | null {
|
||||
if (!memory?.body_truncated || memory.body_next_offset == null || memory.body_next_byte_offset == null) return null;
|
||||
const query = new URLSearchParams({
|
||||
revision: String(memory.revision),
|
||||
offset: String(memory.body_next_offset),
|
||||
byte_offset: String(memory.body_next_byte_offset),
|
||||
});
|
||||
return `${memoryPath()}?${query}`;
|
||||
}
|
||||
|
||||
function evidenceContinuationHref(): string | null {
|
||||
if (!memory?.evidence_has_more || !memory.evidence_next_cursor) return null;
|
||||
const query = new URLSearchParams({
|
||||
revision: String(memory.revision),
|
||||
evidence_cursor: memory.evidence_next_cursor,
|
||||
});
|
||||
return `${memoryPath()}?${query}`;
|
||||
}
|
||||
|
||||
function label(value: string): string {
|
||||
return value.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function rangeLabel(range: [number, number] | null | undefined): string {
|
||||
return range ? `${range[0]}–${range[1]}` : 'None';
|
||||
}
|
||||
|
||||
function originLabel(origin: MemoryEvidenceOrigin | null | undefined): string {
|
||||
if (!origin) return 'Origin unavailable';
|
||||
const identity = [
|
||||
origin.account_id && `account ${origin.account_id}`,
|
||||
origin.workspace_id && `workspace ${origin.workspace_id}`,
|
||||
origin.runtime_id && `runtime ${origin.runtime_id}`,
|
||||
origin.worker_id && `worker ${origin.worker_id}`,
|
||||
origin.flow_selector && `flow ${origin.flow_selector}`,
|
||||
origin.flow_definition_id && `definition ${origin.flow_definition_id}`,
|
||||
origin.flow_definition_revision != null && `definition revision ${origin.flow_definition_revision}`,
|
||||
].filter(Boolean);
|
||||
return `${label(origin.kind)}${identity.length ? ` · ${identity.join(' · ')}` : ''}`;
|
||||
}
|
||||
|
||||
function sourceRefTitle(reference: SubjektivMemorySourceEvidenceRef): string {
|
||||
return reference.label || reference.summary || reference.evidence_id || reference.segment_id || 'Source reference';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{memory?.claim ?? data.memoryId} · Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Committed Memory detail, immutable revisions, and provenance" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="memory-page memory-detail-page" aria-labelledby="memory-detail-heading" data-memory-view="detail" data-memory-state={data.memory.error ? 'error' : memory ? memory.state : 'unavailable'}>
|
||||
<a class="memory-back-link" href={subjectHref()}>← {data.subject.data?.role ?? 'Subject'}</a>
|
||||
|
||||
{#if memory}
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<div class="pill-row">
|
||||
<span class="memory-pill is-{memory.state}">{memory.state}</span>
|
||||
<span class="memory-kind">{label(memory.kind)}</span>
|
||||
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
|
||||
</div>
|
||||
<h1 id="memory-detail-heading">{memory.claim}</h1>
|
||||
<code class="memory-id" title={memory.memory_id}>{memory.memory_id}</code>
|
||||
</div>
|
||||
<span class="read-only-label">Read-only</span>
|
||||
</header>
|
||||
|
||||
<dl class="memory-facts" aria-label="Memory details">
|
||||
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
||||
<div><dt>State</dt><dd>{memory.state}</dd></div>
|
||||
<div><dt>Kind</dt><dd>{label(memory.kind)}</dd></div>
|
||||
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
|
||||
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
|
||||
<header class="section-heading"><h2 id="memory-body-heading">Memory</h2></header>
|
||||
<p class="why-useful"><strong>Why useful:</strong> {memory.why_useful}</p>
|
||||
{#if memory.staleness}<p class="staleness"><strong>Staleness:</strong> {memory.staleness}</p>{/if}
|
||||
<p class="change-reason"><strong>Change reason:</strong> {memory.change_reason}</p>
|
||||
|
||||
{#if memory.body_md.trim().length === 0}
|
||||
<div class="memory-state" role="status"><strong>This Memory body is empty.</strong></div>
|
||||
{:else}
|
||||
<article class="memory-document" aria-label="Committed Memory body">
|
||||
<DocumentMarkdown text={memory.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
{#if bodyContinuationHref()}
|
||||
<div class="continuation-note" role="status">
|
||||
<p>This bounded body segment continues at line {memory.body_next_offset}, byte {memory.body_next_byte_offset}.</p>
|
||||
<a href={bodyContinuationHref() ?? undefined}>View next body segment</a>
|
||||
</div>
|
||||
{/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>
|
||||
<span>{memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'}</span>
|
||||
</header>
|
||||
|
||||
{#if memory.source_candidates.length === 0}
|
||||
<div class="memory-state" role="status"><p>No candidate evidence is included on this page.</p></div>
|
||||
{:else}
|
||||
<div class="candidate-list">
|
||||
{#each memory.source_candidates as candidate, candidateIndex (candidate.candidate_id)}
|
||||
<article class="candidate-record">
|
||||
<header>
|
||||
<h3>Candidate</h3>
|
||||
<code title={candidate.candidate_id}>{candidate.candidate_id}</code>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby={`evidence-${candidateIndex}`}>
|
||||
<h4 id={`evidence-${candidateIndex}`}>Evidence <span>{candidate.evidence.length} of {candidate.evidence_total}</span></h4>
|
||||
{#if candidate.evidence.length === 0}
|
||||
<p class="empty-copy">No evidence anchors on this page.</p>
|
||||
{:else}
|
||||
<ul class="evidence-list">
|
||||
{#each candidate.evidence as evidence (evidence.id)}
|
||||
<li>
|
||||
<div class="evidence-heading"><strong>{evidence.id}</strong><span>{label(evidence.kind)}</span></div>
|
||||
<dl>
|
||||
<div><dt>Entry range</dt><dd>{rangeLabel(evidence.entry_range)}</dd></div>
|
||||
<div><dt>Origin</dt><dd>{originLabel(evidence.origin)}</dd></div>
|
||||
</dl>
|
||||
{#if evidence.summary}<p>{evidence.summary}</p>{/if}
|
||||
{#if evidence.excerpt}<blockquote>{evidence.excerpt}</blockquote>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if candidate.evidence_truncated}<p class="bounded-note">Additional evidence anchors are available.</p>{/if}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby={`sources-${candidateIndex}`}>
|
||||
<h4 id={`sources-${candidateIndex}`}>Source refs <span>{candidate.source_refs.length} of {candidate.source_refs_total}</span></h4>
|
||||
{#if candidate.source_refs.length === 0}
|
||||
<p class="empty-copy">No source refs on this page.</p>
|
||||
{:else}
|
||||
<ul class="source-list">
|
||||
{#each candidate.source_refs as reference, index (`${candidate.candidate_id}:source:${index}`)}
|
||||
<li>
|
||||
<strong>{sourceRefTitle(reference)}</strong>
|
||||
<dl>
|
||||
<div><dt>Session</dt><dd><code>{reference.session_id ?? 'None'}</code></dd></div>
|
||||
<div><dt>Segment</dt><dd><code>{reference.segment_id ?? 'None'}</code></dd></div>
|
||||
<div><dt>Entry range</dt><dd>{rangeLabel(reference.entry_range)}</dd></div>
|
||||
<div><dt>Evidence id</dt><dd><code>{reference.evidence_id ?? 'None'}</code></dd></div>
|
||||
<div><dt>Evidence kind</dt><dd>{reference.evidence_kind ? label(reference.evidence_kind) : 'None'}</dd></div>
|
||||
<div><dt>Origin</dt><dd>{originLabel(reference.origin)}</dd></div>
|
||||
</dl>
|
||||
{#if reference.summary}<p>{reference.summary}</p>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if candidate.source_refs_truncated}<p class="bounded-note">Additional source refs are available.</p>{/if}
|
||||
</section>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if evidenceContinuationHref()}
|
||||
<div class="continuation-note" role="status">
|
||||
<p>Candidate evidence continues on another bounded page.</p>
|
||||
<a href={evidenceContinuationHref() ?? undefined}>View next evidence page</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="detail-section derived-section" aria-labelledby="derived-heading">
|
||||
<header class="section-heading"><h2 id="derived-heading">Derived from</h2><span>{memory.derived_from.length}</span></header>
|
||||
{#if memory.derived_from.length === 0}
|
||||
<p class="empty-copy">No derivation references.</p>
|
||||
{:else}
|
||||
<ul class="derived-list">
|
||||
{#each memory.derived_from as reference (`${reference.memory_id}:${reference.revision}`)}
|
||||
<li><a href={derivedHref(reference)}><code>{reference.memory_id}</code><span>Revision {reference.revision}</span></a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="detail-section revision-section" aria-labelledby="revision-heading">
|
||||
<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>
|
||||
{:else}
|
||||
<ol class="revision-list">
|
||||
{#each revisions as revision (revision.revision)}
|
||||
<li class:current={revision.revision === memory.revision}>
|
||||
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
|
||||
<div><strong>Revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{revision.state}</span><span class="memory-kind">{label(revision.kind)}</span></div>
|
||||
<p>{revision.claim}</p>
|
||||
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if data.revisions.data.has_more}<p class="bounded-note">More immutable revisions are available after this bounded page.</p>{/if}
|
||||
{/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>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Revision history data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if data.memory.error}
|
||||
<header class="memory-page-header">
|
||||
<div><p class="memory-eyebrow">Committed Memory</p><h1 id="memory-detail-heading">{data.memoryId}</h1></div>
|
||||
<span class="read-only-label">Read-only</span>
|
||||
</header>
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Memory unavailable.</strong>
|
||||
<p>{data.memory.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<header class="memory-page-header"><h1 id="memory-detail-heading">{data.memoryId}</h1></header>
|
||||
<div class="memory-state" role="status"><p>Memory data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-page {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 78rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-back-link {
|
||||
width: fit-content;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.memory-page-header,
|
||||
.section-heading,
|
||||
.candidate-record > header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-page-header {
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-page-header > div,
|
||||
.section-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-page-header h1,
|
||||
.section-heading h2,
|
||||
.candidate-record h3,
|
||||
.candidate-record h4 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-page-header h1 {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.section-heading > span,
|
||||
.candidate-record h4 span,
|
||||
.read-only-label {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.pill-row,
|
||||
.revision-list a > div,
|
||||
.evidence-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.memory-pill,
|
||||
.memory-kind,
|
||||
.historical-pill,
|
||||
.memory-eyebrow {
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-pill.is-active {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.memory-pill.is-resolved,
|
||||
.historical-pill {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.memory-pill.is-retracted {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.memory-kind,
|
||||
.memory-eyebrow {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.memory-eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.memory-id {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-facts {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-facts > div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.memory-facts dt {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.memory-facts dd {
|
||||
margin-top: var(--space-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.why-useful,
|
||||
.staleness,
|
||||
.change-reason,
|
||||
.empty-copy,
|
||||
.bounded-note {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.why-useful strong,
|
||||
.change-reason strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.staleness,
|
||||
.staleness strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.memory-document {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.continuation-note {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.continuation-note p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.candidate-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.candidate-record {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.candidate-record > header {
|
||||
align-items: baseline;
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.candidate-record > header code {
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.candidate-record section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.evidence-list,
|
||||
.source-list,
|
||||
.derived-list,
|
||||
.revision-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.evidence-list li,
|
||||
.source-list li {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.evidence-heading span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.evidence-list dl,
|
||||
.source-list dl {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.evidence-list dd,
|
||||
.source-list dd,
|
||||
.source-list code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.evidence-list p,
|
||||
.source-list p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.evidence-list blockquote {
|
||||
margin: var(--space-2) 0 0;
|
||||
padding-left: var(--space-3);
|
||||
border-left: 3px solid var(--line-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.derived-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.derived-list a {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.derived-list a:hover,
|
||||
.derived-list a:focus-visible,
|
||||
.revision-list a:hover,
|
||||
.revision-list a:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.derived-list code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.derived-list span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.revision-list {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.revision-list li {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.revision-list li.current {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.revision-list a {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.revision-list p,
|
||||
.revision-list small {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.revision-list small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-state {
|
||||
padding: var(--space-4) 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-state p {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.memory-state.is-error,
|
||||
.memory-state.is-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.memory-facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-page-header,
|
||||
.section-heading,
|
||||
.candidate-record > header,
|
||||
.continuation-note {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.memory-facts,
|
||||
.derived-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.memory-facts > div,
|
||||
.evidence-list dl > div,
|
||||
.source-list dl > div {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.candidate-record {
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivMemoryListRevisionsResponse,
|
||||
parseSubjektivMemoryReadResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "$lib/workspace/memory/api";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/;
|
||||
|
||||
function safeIntegerQuery(
|
||||
source: URLSearchParams,
|
||||
key: string,
|
||||
positive = false,
|
||||
): string | null {
|
||||
const value = source.get(key);
|
||||
if (value === null || !SAFE_INTEGER_TEXT.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || (positive ? parsed < 1 : parsed < 0)) {
|
||||
return null;
|
||||
}
|
||||
return String(parsed);
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
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 [
|
||||
["revision", true],
|
||||
["offset", false],
|
||||
["byte_offset", false],
|
||||
] as const
|
||||
) {
|
||||
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 [subject, memory, revisions] = 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}/revisions`)}?limit=100`,
|
||||
undefined,
|
||||
(value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
subjectId,
|
||||
memoryId,
|
||||
subject,
|
||||
memory,
|
||||
revisions,
|
||||
};
|
||||
};
|
||||
@@ -2,75 +2,250 @@
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import MemoryPage from "./+page.svelte";
|
||||
import type { PageProps } from "./$types";
|
||||
|
||||
function pageProps(memory: {
|
||||
data: {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}): PageProps {
|
||||
return {
|
||||
data: { workspaceId: "workspace-1", memory },
|
||||
params: { workspaceId: "workspace-1" },
|
||||
} as unknown as PageProps;
|
||||
}
|
||||
import SubjectIndexPage from "./+page.svelte";
|
||||
import SubjectPage from "./[subjectId]/+page.svelte";
|
||||
import MemoryDetailPage from "./[subjectId]/[memoryId]/+page.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => {
|
||||
const { container } = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
data: {
|
||||
body_md: "# Durable context\n\nRead the **current** document.",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 48,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
const result = <T>(data: T | null, error: string | null = null) => ({
|
||||
data,
|
||||
error,
|
||||
});
|
||||
|
||||
function subject() {
|
||||
return {
|
||||
id: "subject-1",
|
||||
role: "Release coordinator",
|
||||
state: "active" as const,
|
||||
store_revision: 9,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
};
|
||||
}
|
||||
|
||||
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 }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Subjects", level: 1 })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("subject-1")).not.toBeNull();
|
||||
expect(screen.queryByText("Staging")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders a ready resident surface and committed Memory lifecycle states", async () => {
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
subject: result(subject()),
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ready" as const,
|
||||
snapshot: {
|
||||
snapshot_id: "snapshot-1",
|
||||
body_md: "# Resident context\n\nUse the **current** committed record.",
|
||||
memory_refs: [{ memory_id: "memory-1", revision: 3 }],
|
||||
built_from_store_revision: 9,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
}),
|
||||
memories: result({
|
||||
items: [
|
||||
{
|
||||
id: "memory-1",
|
||||
revision: 3,
|
||||
kind: "decision" as const,
|
||||
state: "active" as const,
|
||||
claim: "Current decision",
|
||||
excerpt: "Current excerpt",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
{
|
||||
id: "memory-2",
|
||||
revision: 2,
|
||||
kind: "lesson" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Resolved lesson",
|
||||
excerpt: "Resolved excerpt",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
{
|
||||
id: "memory-3",
|
||||
revision: 1,
|
||||
kind: "constraint" as const,
|
||||
state: "retracted" as const,
|
||||
claim: "Retracted constraint",
|
||||
excerpt: "Retracted excerpt",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
|
||||
.not.toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Read-only")).not.toBeNull();
|
||||
expect(screen.getByText("Document details")).not.toBeNull();
|
||||
expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false);
|
||||
expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull();
|
||||
expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull();
|
||||
expect(screen.getByRole("article", { name: "Resident surface" })).not
|
||||
.toBeNull();
|
||||
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();
|
||||
});
|
||||
|
||||
test("distinguishes an empty document from request failure and unavailable data", async () => {
|
||||
const view = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||
const base = {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
subject: result(subject()),
|
||||
memories: result({ items: [], has_more: false }),
|
||||
};
|
||||
const view = render(SubjectPage, {
|
||||
data: {
|
||||
...base,
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ready" as const,
|
||||
snapshot: {
|
||||
snapshot_id: "snapshot-empty",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 9,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
expect(screen.getByText("Resident surface is ready and empty.")).not
|
||||
.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
data: {
|
||||
body_md: " \n",
|
||||
...base,
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "stale" as const,
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByText("Resident surface is stale.")).not.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
data: {
|
||||
...base,
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "failed" as const,
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain("generation failed");
|
||||
|
||||
await view.rerender({ data: { ...base, surface: result(null) } } as never);
|
||||
expect(screen.getByText("Resident surface data is unavailable.")).not
|
||||
.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
data: { ...base, surface: result(null, "Memory API request failed") },
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Resident surface unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders committed Memory detail, provenance, derivation, and immutable revisions", async () => {
|
||||
render(MemoryDetailPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
memoryId: "memory-1",
|
||||
subject: result(subject()),
|
||||
memory: result({
|
||||
memory_id: "memory-1",
|
||||
revision: 2,
|
||||
current_revision: 3,
|
||||
kind: "decision" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Keep provenance typed",
|
||||
body_md: "# Committed detail\n\nRead-only body.",
|
||||
why_useful: "Prevents origin loss.",
|
||||
change_reason: "Resolved after implementation.",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 3,
|
||||
record_source: "fixture",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("status").textContent).toContain("Memory document is empty.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
body_offset: 0,
|
||||
body_byte_offset: 0,
|
||||
body_truncated: false,
|
||||
source_candidate_ids: ["candidate-1"],
|
||||
source_candidates: [{
|
||||
candidate_id: "candidate-1",
|
||||
evidence: [{
|
||||
id: "evidence-1",
|
||||
kind: "message",
|
||||
summary: "Explicit decision.",
|
||||
}],
|
||||
evidence_total: 1,
|
||||
evidence_truncated: false,
|
||||
source_refs: [{
|
||||
session_id: "session-1",
|
||||
segment_id: "segment-1",
|
||||
evidence_id: "evidence-1",
|
||||
label: "Decision discussion",
|
||||
}],
|
||||
source_refs_total: 1,
|
||||
source_refs_truncated: false,
|
||||
}],
|
||||
derived_from: [{ memory_id: "memory-parent", revision: 4 }],
|
||||
evidence_has_more: false,
|
||||
}),
|
||||
revisions: result({
|
||||
memory_id: "memory-1",
|
||||
current_revision: 3,
|
||||
items: [{
|
||||
revision: 3,
|
||||
kind: "decision" as const,
|
||||
state: "active" as const,
|
||||
claim: "Current claim",
|
||||
change_reason: "Clarified",
|
||||
updated_at: "2026-01-03T00:00:00Z",
|
||||
}, {
|
||||
revision: 2,
|
||||
kind: "decision" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Earlier claim",
|
||||
change_reason: "Resolved",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}],
|
||||
has_more: false,
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: "Memory API request failed" }));
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable.");
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory API request failed");
|
||||
expect(screen.queryByText("Memory document is empty.")).toBeNull();
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: null }));
|
||||
expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Committed detail", level: 1 }))
|
||||
.not.toBeNull();
|
||||
});
|
||||
expect(screen.getByText("candidate-1")).not.toBeNull();
|
||||
expect(screen.getByText("Decision discussion")).not.toBeNull();
|
||||
expect(screen.getByText("memory-parent")).not.toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "Revision history" })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("Historical revision")).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -3,12 +3,29 @@ declare const Deno: {
|
||||
};
|
||||
|
||||
import {
|
||||
parseMemoryDocumentResponse,
|
||||
parseMemoryStagingListResponse,
|
||||
parseSubjektivMemoryListRevisionsResponse,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
parseSubjektivMemoryReadResponse,
|
||||
parseSubjektivResidentSurfaceResponse,
|
||||
parseSubjektivSubjectListResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "../src/lib/workspace/memory/api.ts";
|
||||
|
||||
function stable(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, stable(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
if (JSON.stringify(stable(actual)) !== JSON.stringify(stable(expected))) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
@@ -27,8 +44,386 @@ function assertThrows(fn: () => void, expectedMessage: string): void {
|
||||
throw new Error(`expected function to throw ${expectedMessage}`);
|
||||
}
|
||||
|
||||
function fixture(origin: Record<string, unknown>) {
|
||||
function subject(id = "subject-1") {
|
||||
return {
|
||||
id,
|
||||
role: "Release coordinator",
|
||||
state: "active",
|
||||
store_revision: 12,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function queryItem(id = "memory-1") {
|
||||
return {
|
||||
id,
|
||||
revision: 3,
|
||||
kind: "decision",
|
||||
state: "active",
|
||||
claim: "Keep provenance typed.",
|
||||
excerpt: "Preserve source and derivation references.",
|
||||
updated_at: "2026-09-02T03:04:05Z",
|
||||
};
|
||||
}
|
||||
|
||||
function detailFixture() {
|
||||
const origin = {
|
||||
kind: "flow_instruction",
|
||||
workspace_id: "workspace-1",
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
flow_selector: "builtin:coder-review",
|
||||
flow_definition_id: "flow-1",
|
||||
flow_definition_revision: 7,
|
||||
};
|
||||
return {
|
||||
memory_id: "memory-1",
|
||||
revision: 2,
|
||||
current_revision: 3,
|
||||
kind: "decision",
|
||||
state: "resolved",
|
||||
claim: "Keep provenance typed.",
|
||||
body_md: "# Committed Memory\n\nBounded body segment.",
|
||||
why_useful: "Prevents origin loss.",
|
||||
staleness: null,
|
||||
change_reason: "Resolved after implementation.",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-02T03:04:05Z",
|
||||
body_offset: 0,
|
||||
body_byte_offset: 0,
|
||||
body_next_offset: 2,
|
||||
body_next_byte_offset: 0,
|
||||
body_truncated: true,
|
||||
source_candidate_ids: ["candidate-1"],
|
||||
source_candidates: [{
|
||||
candidate_id: "candidate-1",
|
||||
evidence: [{
|
||||
id: "evidence-1",
|
||||
kind: "message",
|
||||
entry_range: [10, 12],
|
||||
origin,
|
||||
excerpt: "Use exact DTOs.",
|
||||
summary: "The decision was explicit.",
|
||||
}],
|
||||
evidence_total: 2,
|
||||
evidence_truncated: true,
|
||||
source_refs: [{
|
||||
session_id: "session-1",
|
||||
segment_id: "segment-1",
|
||||
entry_range: [10, 12],
|
||||
evidence_id: "evidence-1",
|
||||
origin,
|
||||
evidence_kind: "message",
|
||||
label: "Decision discussion",
|
||||
summary: "Bounded host summary.",
|
||||
}],
|
||||
source_refs_total: 2,
|
||||
source_refs_truncated: true,
|
||||
}],
|
||||
derived_from: [{ memory_id: "memory-parent", revision: 4 }],
|
||||
evidence_next_cursor: "evidence-page-2",
|
||||
evidence_has_more: true,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bounds", () => {
|
||||
assertEquals(
|
||||
parseSubjektivSubjectResponse(subject(), "subject-1"),
|
||||
subject(),
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse(subject(), "another-subject"),
|
||||
"identity does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse({ ...subject(), state: "paused" }),
|
||||
"unknown Subject state",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectResponse({
|
||||
...subject(),
|
||||
store_revision: Number.MAX_SAFE_INTEGER + 1,
|
||||
}),
|
||||
"safe integer",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse({ ...subject(), id: "x".repeat(513) }),
|
||||
"bounded identifier",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse({ ...subject(), future: true }),
|
||||
"unknown field",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectListResponse({
|
||||
limit: 100,
|
||||
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
|
||||
has_more: false,
|
||||
}),
|
||||
"bounded array",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||
const response = { limit: 100, items: [subject()], has_more: false };
|
||||
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectListResponse({
|
||||
limit: 100,
|
||||
items: [subject("duplicate"), subject("duplicate")],
|
||||
has_more: false,
|
||||
}),
|
||||
"Subject ids must be unique",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => {
|
||||
const readyEmpty = {
|
||||
subject_id: "subject-1",
|
||||
availability: "ready",
|
||||
snapshot: {
|
||||
snapshot_id: "snapshot-empty",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 0,
|
||||
created_at: "2026-09-02T00:00:00Z",
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
parseSubjektivResidentSurfaceResponse(readyEmpty, "subject-1"),
|
||||
readyEmpty,
|
||||
);
|
||||
assertEquals(
|
||||
parseSubjektivResidentSurfaceResponse({
|
||||
subject_id: "subject-1",
|
||||
availability: "ungenerated",
|
||||
}),
|
||||
{ subject_id: "subject-1", availability: "ungenerated" },
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivResidentSurfaceResponse({
|
||||
subject_id: "subject-1",
|
||||
availability: "ready",
|
||||
}),
|
||||
"requires a snapshot",
|
||||
);
|
||||
for (const availability of ["ungenerated", "stale", "failed"]) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivResidentSurfaceResponse({
|
||||
...readyEmpty,
|
||||
availability,
|
||||
}),
|
||||
"must not include a snapshot",
|
||||
);
|
||||
}
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivResidentSurfaceResponse({
|
||||
subject_id: "subject-1",
|
||||
availability: "refreshing",
|
||||
}),
|
||||
"unknown resident surface availability",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivResidentSurfaceResponse(readyEmpty, "subject-2"),
|
||||
"identity does not match",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Current Memory list parser rejects unknown variants, unsafe integers, cursors, and oversized pages", () => {
|
||||
const response = {
|
||||
items: [queryItem()],
|
||||
has_more: true,
|
||||
next_cursor: "memory-page-2",
|
||||
};
|
||||
assertEquals(parseSubjektivMemoryQueryResponse(response), response);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
...response,
|
||||
items: [{ ...queryItem(), state: "pending" }],
|
||||
}),
|
||||
"unknown Memory state",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
...response,
|
||||
items: [{ ...queryItem(), kind: "future_kind" }],
|
||||
}),
|
||||
"unknown Memory candidate kind",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
...response,
|
||||
items: [{ ...queryItem(), revision: 0 }],
|
||||
}),
|
||||
"positive safe integer",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
items: [queryItem()],
|
||||
has_more: true,
|
||||
}),
|
||||
"cursor does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
|
||||
has_more: false,
|
||||
}),
|
||||
"bounded array",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Memory detail parser preserves body/evidence continuation, sources, origins, and derivation", () => {
|
||||
const fixture = detailFixture();
|
||||
const parsed = parseSubjektivMemoryReadResponse(fixture, "memory-1");
|
||||
assertEquals(parsed, fixture);
|
||||
assertEquals(parsed.body_next_offset, 2);
|
||||
assertEquals(parsed.body_next_byte_offset, 0);
|
||||
assertEquals(parsed.evidence_next_cursor, "evidence-page-2");
|
||||
assertEquals(
|
||||
parsed.source_candidates[0].evidence[0].origin,
|
||||
fixture.source_candidates[0].evidence[0].origin,
|
||||
);
|
||||
assertEquals(
|
||||
parsed.source_candidates[0].source_refs,
|
||||
fixture.source_candidates[0].source_refs,
|
||||
);
|
||||
assertEquals(parsed.derived_from, [{
|
||||
memory_id: "memory-parent",
|
||||
revision: 4,
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("Memory detail parser fails closed on identity, variants, bounds, and continuation mismatch", () => {
|
||||
const fixture = detailFixture();
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryReadResponse(fixture, "memory-2"),
|
||||
"identity does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
current_revision: Number.MAX_SAFE_INTEGER + 1,
|
||||
}),
|
||||
"safe integer",
|
||||
);
|
||||
const missingBodyContinuation = structuredClone(fixture) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete missingBodyContinuation.body_next_byte_offset;
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryReadResponse(missingBodyContinuation),
|
||||
"body continuation fields",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
source_candidate_ids: ["different-candidate"],
|
||||
}),
|
||||
"source candidate ids do not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
source_candidates: [{
|
||||
...fixture.source_candidates[0],
|
||||
evidence: [{
|
||||
...fixture.source_candidates[0].evidence[0],
|
||||
entry_range: [12, 10],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
"must be ordered",
|
||||
);
|
||||
const unknownOrigin = structuredClone(fixture);
|
||||
unknownOrigin.source_candidates[0].evidence[0].origin.kind = "future_origin";
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryReadResponse(unknownOrigin),
|
||||
"unknown Memory evidence origin kind",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
evidence_has_more: false,
|
||||
}),
|
||||
"cursor does not match",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Memory revision parser preserves immutable states and checks response identity", () => {
|
||||
const response = {
|
||||
memory_id: "memory-1",
|
||||
current_revision: 3,
|
||||
items: [{
|
||||
revision: 3,
|
||||
kind: "decision",
|
||||
state: "active",
|
||||
claim: "Current claim.",
|
||||
change_reason: "Clarified wording.",
|
||||
updated_at: "2026-09-03T00:00:00Z",
|
||||
}, {
|
||||
revision: 2,
|
||||
kind: "decision",
|
||||
state: "resolved",
|
||||
claim: "Earlier claim.",
|
||||
change_reason: "Resolved.",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
}, {
|
||||
revision: 1,
|
||||
kind: "decision",
|
||||
state: "retracted",
|
||||
claim: "Original claim.",
|
||||
change_reason: "Retracted.",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
}],
|
||||
has_more: false,
|
||||
};
|
||||
assertEquals(
|
||||
parseSubjektivMemoryListRevisionsResponse(response, "memory-1"),
|
||||
response,
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryListRevisionsResponse(response, "memory-2"),
|
||||
"identity does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryListRevisionsResponse({
|
||||
...response,
|
||||
items: [{ ...response.items[0], state: "archived" }],
|
||||
}),
|
||||
"unknown Memory state",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryListRevisionsResponse({
|
||||
...response,
|
||||
items: [{ ...response.items[0], revision: 4 }],
|
||||
}),
|
||||
"future revision",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Legacy staging parser remains strict for deprecated compatibility", () => {
|
||||
const origin = { kind: "worker_input", worker_id: "worker-1" };
|
||||
const response = {
|
||||
limit: 100,
|
||||
returned_count: 1,
|
||||
total_valid_count: 1,
|
||||
@@ -70,97 +465,19 @@ function fixture(origin: Record<string, unknown>) {
|
||||
}],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Memory document response requires the generated DTO fields", () => {
|
||||
assertEquals(
|
||||
parseMemoryDocumentResponse({
|
||||
body_md: "# Memory\n",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
bytes: 9,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
}).bytes,
|
||||
9,
|
||||
);
|
||||
assertThrows(
|
||||
() => parseMemoryDocumentResponse({ body_md: "# Memory\n" }),
|
||||
"missing a required field",
|
||||
);
|
||||
});
|
||||
|
||||
for (
|
||||
const [kind, fields] of [
|
||||
["human_input", { account_id: "account-1" }],
|
||||
[
|
||||
"worker_input",
|
||||
{
|
||||
workspace_id: "workspace-1",
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
},
|
||||
],
|
||||
["model_output", { runtime_id: "runtime-1", worker_id: "worker-1" }],
|
||||
["tool_output", { runtime_id: "runtime-1", worker_id: "worker-1" }],
|
||||
["legacy_unknown", {}],
|
||||
] as const
|
||||
) {
|
||||
Deno.test(`Memory staging parser preserves ${kind} origin`, () => {
|
||||
const parsed = parseMemoryStagingListResponse(fixture({ kind, ...fields }));
|
||||
assertEquals(parsed.items[0].record.evidence[0].origin, {
|
||||
kind,
|
||||
...fields,
|
||||
});
|
||||
assertEquals(parsed.items[0].record.source_refs[0].origin, {
|
||||
kind,
|
||||
...fields,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Deno.test("Memory staging parser preserves Flow origin fields", () => {
|
||||
const origin = {
|
||||
kind: "flow_instruction" as const,
|
||||
workspace_id: "workspace-1",
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
flow_selector: "builtin:coder-review",
|
||||
flow_definition_id: "flow-1",
|
||||
flow_definition_revision: 7,
|
||||
};
|
||||
const parsed = parseMemoryStagingListResponse(fixture(origin));
|
||||
assertEquals(parsed.items[0].record.source_refs[0].origin, origin);
|
||||
});
|
||||
|
||||
Deno.test("Memory staging parser rejects unknown or newer origin shapes", () => {
|
||||
assertThrows(
|
||||
() => parseMemoryStagingListResponse(fixture({ kind: "future_origin" })),
|
||||
"unknown Memory evidence origin kind",
|
||||
);
|
||||
assertEquals(parseMemoryStagingListResponse(response), response);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseMemoryStagingListResponse(
|
||||
fixture({ kind: "human_input", future_field: "must not be accepted" }),
|
||||
),
|
||||
"unknown field",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Memory staging parser rejects malformed records and unbounded origins", () => {
|
||||
const malformed = fixture({ kind: "legacy_unknown" });
|
||||
malformed.items[0].record.source_refs[0].entry_range = [1] as unknown as [
|
||||
number,
|
||||
number,
|
||||
];
|
||||
assertThrows(
|
||||
() => parseMemoryStagingListResponse(malformed),
|
||||
"two-item entry range",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseMemoryStagingListResponse(
|
||||
fixture({ kind: "worker_input", worker_id: "x".repeat(513) }),
|
||||
),
|
||||
"exceeds the Memory origin limit",
|
||||
parseMemoryStagingListResponse({
|
||||
...response,
|
||||
items: [{
|
||||
...response.items[0],
|
||||
record: {
|
||||
...response.items[0].record,
|
||||
source: { segment_id: "segment-1", range: [20, 10] },
|
||||
},
|
||||
}],
|
||||
}),
|
||||
"must be ordered",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user