chore: integrate Subject Memory information improvements
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
# Subject / Memory information audit (T-692)
|
||||
|
||||
## Decision and scope
|
||||
|
||||
The previous Subjects index exposed `Store revision` alongside identity, state and
|
||||
update time; Subject detail led with technical metadata and raw surface states.
|
||||
These numbers require implementation knowledge before they help a user choose a
|
||||
Subject or understand what is available. The revised hierarchy starts with
|
||||
identity, connection, committed records and generated-context availability.
|
||||
|
||||
This is a presentation change, not a change to storage, freshness comparisons,
|
||||
permissions, generation, or Worker history. T-688's ordinary Worker launch and
|
||||
T-689's independent Subject creation remain the actual operation paths. T-693
|
||||
owns editable user-managed behavior: that content must remain distinct from this
|
||||
page's context generated from committed Memories. A successful read or a current
|
||||
surface does not attest what is already in any running Worker's history.
|
||||
|
||||
## Field classification and before/after priorities
|
||||
|
||||
| Information | Classification and reason | Presentation decision |
|
||||
| --- | --- | --- |
|
||||
| Subject role and ID | Normal identification. Similar or identical roles need a stable identifier for selection and comparison. | Keep role as heading and the complete wrapping ID visible in index and detail. |
|
||||
| Subject active/retired state | Normal operation decision: whether a new Worker connection can use the Subject. | Replace raw `active` with `Available`; keep `Retired`. Detail explains connection eligibility, not an assertion that a Worker is running. |
|
||||
| Current Worker | Normal connection decision, derived from the existing Backend singleton projection. | Promote `Connected` / `Not connected` and display name. Do not infer ownership from store revision or claim all Workers received current Memory. Worker start remains the ordinary launch form. |
|
||||
| Committed-Memory presence | Normal reading decision, independent from surface generation or candidates. | Show `Available` / `None` only on the successful first bounded page. On later pages say `N on this page`; errors say `Unavailable`. Never infer counts from revision or scan all pages. |
|
||||
| List counts | Normal navigation context, not a store-wide statistic. | Label `N shown` and `more available`, preserving existing bounded cursor pagination. |
|
||||
| Resident availability | Normal context decision. A missing surface is not missing committed Memory. | `Ready to use`, `Ready, no context`, `Not generated`, `Needs refresh`, `Generation failed`, and request `Unavailable` are distinct. These describe the current generated store artifact, not content truth or Worker-history propagation. |
|
||||
| Resident body and generated time | Normal content/read freshness context. | Keep the shared Markdown renderer, generated date and exact snapshot reference count. Ready-empty explains that a valid snapshot has no body; stale/failed do not display a partial or old snapshot. |
|
||||
| Subject store revision | Diagnostic only: internal committed-Memory change number, not record count, schema version or quality score. | Remove it from the index's normal columns. Retain the exact value under detail's `Technical details`, with its meaning explained. Backend comparisons are untouched. |
|
||||
| Subject update time | Secondary metadata; not a context-injection or generation completion timestamp. | Label `Last changed`; on detail put it with Subject diagnostics rather than competing with the availability summary. |
|
||||
| Snapshot ID and built-from revision | Diagnostic correlation, distinct from per-Memory revisions. | Retain under `Surface sources and diagnostics`, labelled `Generated from Subject revision` and explained. |
|
||||
| Surface Memory references | Detailed audit/reachability. | Preserve reference IDs, immutable revision values and Memory links in the disclosure. |
|
||||
| Memory claim, kind, lifecycle state, excerpt and body | Normal content decision. Active, resolved and retracted are not interchangeable. | Retain list scanning and body reading. Keep the Markdown renderer and current/historical distinction. |
|
||||
| Individual Memory revision and history | Detailed content comparison, also useful for identifying a selected version. | Explicit `Memory revision` labels, separate from Subject counters. Preserve immutable history, selected revision links and revision pagination. |
|
||||
| Candidate provenance, evidence, origin, source refs and derivations | Detailed audit, required to understand why a record exists. | Retain IDs, ranges, summaries, excerpts, origins and derived-revision links. Counts say `shown` except where the API provides exact per-candidate totals. Preserve body and evidence continuation links. |
|
||||
| Raw field names / duplicated general revision terminology | Unnecessary in the normal hierarchy; multiple unrelated counters are misleading. | Replace bare revision labels and raw surface enums with purpose-specific labels. Do not delete API fields, stored information, diagnostics or their detailed access. |
|
||||
|
||||
No diagnostic datum was removed from Subject detail, surface detail or Memory
|
||||
provenance/history. The index no longer shows a store revision, but the exact
|
||||
value remains one click away in Subject detail. No invented regeneration,
|
||||
editing or recovery action was added; `Needs refresh` does not mean an update job
|
||||
is running. Creation still creates only a Subject, and selection still uses the
|
||||
existing ordinary launch contract.
|
||||
|
||||
## Candidate-only verification
|
||||
|
||||
Candidates are not committed records and are not an input to this page. The
|
||||
Subject loader performs just three bounded reads: Subject, resident surface and
|
||||
`/memories?limit=100[&cursor=...]`. It does not read or count staging records.
|
||||
|
||||
The existing real-store regression
|
||||
`subjektiv::tests::candidate_decisions_reject_conflicts_and_roll_back_partial_writes`
|
||||
stages a candidate and attempts an invalid apply. It asserts the candidate has no
|
||||
resolution and the committed Memory list remains empty. The component regression
|
||||
`candidate-only Subjects show no committed Memories, not an inferred candidate count`
|
||||
uses that read projection (store revision 0, ungenerated surface, empty successful
|
||||
committed list) and checks `None`, `No committed Memories yet.`, `Not connected`
|
||||
and `Not generated`, with no resident body. Thus a staged candidate does not turn
|
||||
into a committed-Memory count or a claim that context has been generated.
|
||||
|
||||
The store test proves the persistence boundary; the component test proves the
|
||||
rendering of its public projection. These are separate boundary tests, **not** a
|
||||
live browser-to-Backend candidate-creation E2E. The existing `ungenerated` capture
|
||||
represents the same visible projection, but does not itself stage a candidate.
|
||||
|
||||
## Visual review and validation evidence
|
||||
|
||||
The restored implementation is in `fbb09e65` and `4cf6c130`; this report/test
|
||||
supplement does not change rendered markup, styling, copy or data contracts.
|
||||
|
||||
- Scenarios: `tools/web-ux/scenarios/memory-subjects-{light,dark}.json`.
|
||||
- Before: `target/web-ux/t-692-before-{light,dark}` at `cc979874`.
|
||||
- After: `target/web-ux/t-692-final2-{light,dark}` at clean `4cf6c130`.
|
||||
- Comparisons: `target/web-ux/t-692-final2-{light,dark}-comparison`.
|
||||
- Browser interactions: `target/web-ux/t-692-browser-interactions`.
|
||||
- Persona: synthetic Workspace fixture; permitted creation and intentional
|
||||
permission-rejection responses. No real account or private store data used.
|
||||
- Viewports: 1440, 768, 390 and 320px; light and dark themes.
|
||||
- States: connected/unconnected, active/retired, successful empty lists, ready
|
||||
long Markdown, ready-empty, ungenerated, stale, failed, unavailable HTTP503,
|
||||
create form and create permission error; expanded technical disclosures.
|
||||
- The takeover implementer personally inspected individual before/after images
|
||||
for index at 768px, representative detail at desktop/mobile and Memory detail,
|
||||
then ready-empty mobile, ungenerated narrow, stale desktop, failed mobile,
|
||||
endpoint error at 320px, mobile surface sources, compact Subject diagnostics
|
||||
and mobile permission error. Oversized contact sheets were rejected by the
|
||||
image provider; individual viewport PNGs were inspected instead.
|
||||
- Findings/result: primary state groups replace counters without losing IDs or
|
||||
document access; long IDs wrap, status is textual rather than color-only,
|
||||
mobile groups stack, diagnostics remain readable, and error state regions stay
|
||||
separate. No new visual defect identified. **Pass for the audited scope.**
|
||||
- Final bundles contain 64 captures each. `completed-with-errors` is expected:
|
||||
intentional creation HTTP403, endpoint HTTP503/associated aborted requests,
|
||||
and the deliberately failed surface's alert are retained as evidence. Healthy
|
||||
capture states have empty error arrays; this is not a blanket claim that all
|
||||
captures were error-free. Review-context diagnostics are empty.
|
||||
- The focused production-shell browser test proves main-owned vertical scrolling
|
||||
with wheel/keyboard/touch, 320px/mobile shell behavior, no page-wide overflow,
|
||||
local table/code scroll, Markdown safety, creation/launch separation, bounded
|
||||
pagination and Memory/source/revision reachability.
|
||||
|
||||
Independent review at source `4cf6c130` ran root `cargo check`,
|
||||
`cargo fmt --all -- --check`, diff hygiene, Web check (0 errors/warnings), 12
|
||||
Memory API tests, 452 full Web unit tests, 134 component tests (8 Memory tests),
|
||||
production build, the focused production-shell browser test (1), and Web-UX unit
|
||||
tests (16), all passing.
|
||||
|
||||
The takeover implementer then validated this report/test supplement with:
|
||||
|
||||
- `cargo test -p yoi-workspace-server --lib candidate_decisions_reject_conflicts_and_roll_back_partial_writes`: 1 passed.
|
||||
- Root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`: passed.
|
||||
- Targeted Memory component tests: 9 passed, including the added candidate-only projection test.
|
||||
- Web `deno task check`: 0 errors/warnings; `deno task test`: 452 passed;
|
||||
`deno task test:component`: 135 passed.
|
||||
- Scoped `deno fmt --check` on the changed TypeScript test: passed.
|
||||
- JSON audit of both final2 bundles: 64 captures each, exact clean source
|
||||
`4cf6c130`, and no errors outside the deliberate permission/error/failed routes.
|
||||
|
||||
The supplement changes only a document and a test, so it does not require new
|
||||
rendered-UI captures. Rereview records and the final approved Ticket handoff link
|
||||
this report to the new exact source.
|
||||
|
||||
Known non-blocking validation diagnostics: component runs emitted incidental
|
||||
localhost:3000 connection-refused messages while passing; the production build
|
||||
warned about a large chunk. A broader Web-UX typecheck fails in unchanged
|
||||
`tools/web-ux/src/processes.ts:44` on a `TextDecoderStream` generic incompatibility;
|
||||
this report does not claim that broad check passed.
|
||||
|
||||
No live-Backend browser interactions, real Worker model-request propagation,
|
||||
regeneration, or server/runtime process E2E were performed. No Rust production
|
||||
crate changed in T-692; the supplemental store test exercises an existing
|
||||
contract without changing that contract. Backend counters, comparison logic,
|
||||
storage schema and feature behavior remain intact.
|
||||
@@ -66,7 +66,13 @@ ${"detail-wide-code-".repeat(18)}
|
||||
<script>alert("not rendered")</script>`;
|
||||
|
||||
const subjects = [
|
||||
subject(representativeSubjectId, "Release coordination", "active", 42),
|
||||
subject(
|
||||
representativeSubjectId,
|
||||
"Release coordination",
|
||||
"active",
|
||||
42,
|
||||
connectedWorker("Release coordination Worker with a deliberately long display name"),
|
||||
),
|
||||
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
||||
subject(staleSubjectId, "Stale surface subject", "active", 19),
|
||||
subject(failedSubjectId, "Failed surface subject", "active", 8),
|
||||
@@ -75,7 +81,37 @@ const subjects = [
|
||||
];
|
||||
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1);
|
||||
|
||||
function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) {
|
||||
function connectedWorker(displayName: string) {
|
||||
return {
|
||||
runtime_id: "embedded-worker-runtime",
|
||||
worker_id: "release-coordination-worker",
|
||||
host_id: "fixture-host",
|
||||
display_name: displayName,
|
||||
label: displayName,
|
||||
profile: "builtin:companion",
|
||||
singleton_key: `subjektiv:${representativeSubjectId}`,
|
||||
tags: [],
|
||||
workspace: {
|
||||
visibility: "workspace",
|
||||
identity: workspaceId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
state: "idle",
|
||||
last_seen_at: "2026-01-02T03:04:05Z",
|
||||
pinned: false,
|
||||
retention_state: "retained",
|
||||
implementation: { kind: "embedded", display_hint: "Fixture Worker" },
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function subject(
|
||||
id: string,
|
||||
role: string,
|
||||
state: "active" | "retired",
|
||||
storeRevision: number,
|
||||
currentWorker?: ReturnType<typeof connectedWorker>,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
@@ -83,6 +119,7 @@ function subject(id: string, role: string, state: "active" | "retired", storeRev
|
||||
store_revision: storeRevision,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
...(currentWorker ? { current_worker: currentWorker } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,7 +162,7 @@ function memoriesFor(subjectId: string) {
|
||||
1,
|
||||
"working_assumption",
|
||||
"retracted",
|
||||
"T-672 UNIQUE END MARKER",
|
||||
"Memory list end marker for scroll validation",
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -235,7 +272,7 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) {
|
||||
evidence_id: "evidence-message-0001",
|
||||
origin: { kind: "human_input", account_id: "account-fixture" },
|
||||
evidence_kind: "message",
|
||||
label: "T-672 product direction",
|
||||
label: "Memory information design source",
|
||||
summary: "Bounded source reference retained with the committed revision.",
|
||||
}],
|
||||
source_refs_total: 1,
|
||||
|
||||
@@ -16,7 +16,7 @@ const memoryId =
|
||||
const subjectPageCursor = "fixture-subject-page-2";
|
||||
const createInteractionArtifacts = join(
|
||||
repositoryRoot,
|
||||
"target/web-ux/t-689-browser-interactions",
|
||||
"target/web-ux/t-692-browser-interactions",
|
||||
);
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
@@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
|
||||
const document = (globalThis as any).document;
|
||||
const main = document.querySelector("main");
|
||||
const marker = [...document.querySelectorAll("h3")].find((heading) =>
|
||||
heading.textContent === "T-672 UNIQUE END MARKER"
|
||||
heading.textContent === "Memory list end marker for scroll validation"
|
||||
);
|
||||
if (!main || !marker) return false;
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
@@ -262,7 +262,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
|
||||
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("article", { name: "Resident surface" }).locator("img, script")
|
||||
await page.getByRole("article", { name: "Resident context" }).locator("img, script")
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
@@ -282,7 +282,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
await page.locator("main").hover();
|
||||
await page.mouse.wheel(0, 100_000);
|
||||
} else if (scenario.label === "keyboard") {
|
||||
await page.getByText("Surface provenance", { exact: true }).focus();
|
||||
await page.getByText("Surface sources and diagnostics", { exact: true }).focus();
|
||||
await page.keyboard.press("End");
|
||||
} else {
|
||||
await swipeMainToEnd(page);
|
||||
@@ -313,17 +313,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
level: 1,
|
||||
}).waitFor();
|
||||
assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1);
|
||||
assertEquals(await detailPage.getByText("T-672 product direction").count(), 1);
|
||||
assertEquals(await detailPage.getByText("Memory information design source").count(), 1);
|
||||
assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1);
|
||||
assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0);
|
||||
assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0);
|
||||
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
||||
"link",
|
||||
{ name: "Next page →" },
|
||||
).click();
|
||||
await detailPage.getByText("Revision 1", { exact: true }).waitFor();
|
||||
await detailPage.getByText("Memory revision 1", { exact: true }).waitFor();
|
||||
assert(new URL(detailPage.url()).searchParams.has("revision_cursor"));
|
||||
assertEquals(
|
||||
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
||||
@@ -352,6 +352,15 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
const paginationPage = await paginationContext.newPage();
|
||||
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor();
|
||||
assertEquals(
|
||||
await paginationPage.getByText(
|
||||
"Release coordination Worker with a deliberately long display name",
|
||||
{ exact: true },
|
||||
).count(),
|
||||
1,
|
||||
);
|
||||
assertEquals(await paginationPage.getByText("Store revision", { exact: true }).count(), 0);
|
||||
assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0);
|
||||
assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
|
||||
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
|
||||
"link",
|
||||
@@ -451,10 +460,12 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
||||
1,
|
||||
);
|
||||
const currentWorker = createPage.locator(".subject-facts div").filter({
|
||||
hasText: "Current worker",
|
||||
});
|
||||
assert((await currentWorker.textContent())?.includes("None"));
|
||||
const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator(
|
||||
"..",
|
||||
);
|
||||
assert((await currentWorker.textContent())?.includes("Not connected"));
|
||||
await createPage.getByText("Resident context has not been generated.").waitFor();
|
||||
await createPage.getByText("No committed Memories yet.").waitFor();
|
||||
assertEquals(mutationPaths, [
|
||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||
@@ -472,21 +483,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
const stateCases = [
|
||||
{
|
||||
id: "empty-subject",
|
||||
text: "Resident surface is ready and empty.",
|
||||
text: "Resident context is current but empty.",
|
||||
role: "status" as const,
|
||||
},
|
||||
{
|
||||
id: "stale-subject",
|
||||
text: "Resident context needs to be refreshed.",
|
||||
role: "status" as const,
|
||||
},
|
||||
{ id: "stale-subject", text: "Resident surface is stale.", role: "status" as const },
|
||||
{
|
||||
id: "failed-subject",
|
||||
text: "Resident surface generation failed.",
|
||||
text: "Resident context generation failed.",
|
||||
role: "alert" as const,
|
||||
},
|
||||
{
|
||||
id: "ungenerated-subject",
|
||||
text: "Resident surface has not been generated.",
|
||||
text: "Resident context has not been generated.",
|
||||
role: "status" as const,
|
||||
},
|
||||
{ id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const },
|
||||
{
|
||||
id: "error-subject",
|
||||
text: "Resident context status unavailable.",
|
||||
role: "alert" as const,
|
||||
},
|
||||
];
|
||||
for (const stateCase of stateCases) {
|
||||
const page = await stateContext.newPage();
|
||||
@@ -494,8 +513,14 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text });
|
||||
await state.waitFor();
|
||||
assert((await state.textContent())?.includes(stateCase.text));
|
||||
if (stateCase.id === "stale-subject") {
|
||||
assertEquals(await page.getByText("A resolved stale-surface lesson").count(), 1);
|
||||
}
|
||||
if (stateCase.id === "failed-subject") {
|
||||
assertEquals(await page.getByText("A retracted failed-surface constraint").count(), 1);
|
||||
}
|
||||
if (stateCase.id !== "empty-subject") {
|
||||
assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0);
|
||||
assertEquals(await page.getByRole("article", { name: "Resident context" }).count(), 0);
|
||||
}
|
||||
await assertNoPageWideOverflow(page);
|
||||
await page.close();
|
||||
|
||||
@@ -122,9 +122,33 @@
|
||||
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Ready subject" },
|
||||
{
|
||||
"id": "subject-diagnostics",
|
||||
"label": "Subject diagnostics",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "details.subject-technical-details summary" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details.subject-technical-details[open]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "surface-diagnostics",
|
||||
"label": "Surface sources and diagnostics",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "details.surface-details summary" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details.surface-details[open]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
@@ -150,6 +174,18 @@
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
||||
},
|
||||
{
|
||||
"id": "ungenerated",
|
||||
"label": "Ungenerated subject context",
|
||||
"path": "/w/memory-review/memory/ungenerated-subject",
|
||||
"goal": "Distinguish a Subject with no generated resident context from lost committed Memory.",
|
||||
"dataState": "Ungenerated resident context and a successful empty committed Memory list.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ungenerated']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Not generated" }]
|
||||
},
|
||||
{
|
||||
"id": "stale",
|
||||
"label": "Stale subject surface",
|
||||
|
||||
@@ -122,9 +122,33 @@
|
||||
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Ready subject" },
|
||||
{
|
||||
"id": "subject-diagnostics",
|
||||
"label": "Subject diagnostics",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "details.subject-technical-details summary" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details.subject-technical-details[open]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "surface-diagnostics",
|
||||
"label": "Surface sources and diagnostics",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "details.surface-details summary" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details.surface-details[open]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
@@ -150,6 +174,18 @@
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
||||
},
|
||||
{
|
||||
"id": "ungenerated",
|
||||
"label": "Ungenerated subject context",
|
||||
"path": "/w/memory-review/memory/ungenerated-subject",
|
||||
"goal": "Distinguish a Subject with no generated resident context from lost committed Memory.",
|
||||
"dataState": "Ungenerated resident context and a successful empty committed Memory list.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ungenerated']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Not generated" }]
|
||||
},
|
||||
{
|
||||
"id": "stale",
|
||||
"label": "Stale subject surface",
|
||||
|
||||
@@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
subjectIndexLoad.includes('"/subjektiv/subjects"') &&
|
||||
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
|
||||
subjectIndexPage.includes(">Subjects</h1>") &&
|
||||
subjectIndexPage.includes("subject.store_revision") &&
|
||||
subjectIndexPage.includes("Worker connection") &&
|
||||
subjectIndexPage.includes("subject.current_worker") &&
|
||||
!subjectIndexPage.includes("subject.store_revision") &&
|
||||
subjectIndexPage.includes('data-memory-view="subjects"'),
|
||||
"Memory product entry should list explicit typed subjects",
|
||||
);
|
||||
@@ -510,10 +512,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
subjectPageLoad.includes("`${subjectPath}/surface`") &&
|
||||
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
||||
subjectPage.includes("DocumentMarkdown from") &&
|
||||
subjectPage.includes("Resident surface") &&
|
||||
subjectPage.includes("Resident context") &&
|
||||
subjectPage.includes("Subject store revision") &&
|
||||
subjectPage.includes("Current Memories") &&
|
||||
subjectPage.includes("surface?.availability === 'ready'") &&
|
||||
subjectPage.includes("surface?.availability === 'ungenerated'") &&
|
||||
subjectPage.includes("surface?.availability === 'stale'") &&
|
||||
subjectPage.includes("surface?.availability === 'failed'") &&
|
||||
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",
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
||||
}
|
||||
|
||||
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
|
||||
return state === 'active' ? 'Available' : 'Retired';
|
||||
}
|
||||
|
||||
async function openCreateForm(): Promise<void> {
|
||||
createExpanded = true;
|
||||
createError = null;
|
||||
@@ -109,7 +113,7 @@
|
||||
</div>
|
||||
<div class="memory-header-actions">
|
||||
{#if data.subjects.data}
|
||||
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
|
||||
<span class="memory-count">{subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''}</span>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={createButton}
|
||||
@@ -184,14 +188,19 @@
|
||||
{#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>
|
||||
<span class="memory-state-pill is-{subject.state}">{subjectStateLabel(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>
|
||||
<div>
|
||||
<dt>Worker connection</dt>
|
||||
<dd>
|
||||
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
|
||||
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -426,8 +435,8 @@
|
||||
|
||||
.subject-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@@ -441,12 +450,19 @@
|
||||
}
|
||||
|
||||
.subject-meta dd {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-meta dd strong {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.memory-state {
|
||||
padding: var(--space-5) 0;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
import type { SubjektivMemoryState } from '#lib/generated/memory-api.ts';
|
||||
import type {
|
||||
SubjektivMemoryState,
|
||||
SubjektivResidentSurfaceAvailability,
|
||||
} from '#lib/generated/memory-api.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -36,6 +39,24 @@
|
||||
function kindLabel(kind: string): string {
|
||||
return kind.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function surfaceLabel(availability: SubjektivResidentSurfaceAvailability): string {
|
||||
if (availability === 'ready') return snapshot?.body_md.trim() ? 'Ready to use' : 'Ready, no context';
|
||||
if (availability === 'ungenerated') return 'Not generated';
|
||||
if (availability === 'stale') return 'Needs refresh';
|
||||
return 'Generation failed';
|
||||
}
|
||||
|
||||
function surfaceSummary(availability: SubjektivResidentSurfaceAvailability): string {
|
||||
if (availability !== 'ready') return 'No current generated context is available to use.';
|
||||
return snapshot?.body_md.trim()
|
||||
? 'Current generated context is available.'
|
||||
: 'A current empty surface exists; there is no resident context to show.';
|
||||
}
|
||||
|
||||
function subjectStateLabel(state: 'active' | 'retired'): string {
|
||||
return state === 'active' ? 'Available' : 'Retired';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -56,13 +77,50 @@
|
||||
</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>
|
||||
<section class="subject-overview" aria-label="Subject status">
|
||||
<div>
|
||||
<span>Subject status</span>
|
||||
<strong class="subject-state is-{data.subject.data.state}">{subjectStateLabel(data.subject.data.state)}</strong>
|
||||
<p>{data.subject.data.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Worker connection</span>
|
||||
<strong>{data.subject.data.current_worker ? 'Connected' : 'Not connected'}</strong>
|
||||
<p>{data.subject.data.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Committed Memories</span>
|
||||
{#if data.memories.data}
|
||||
<strong>{data.cursor ? `${memories.length} on this page` : memories.length === 0 ? 'None' : 'Available'}</strong>
|
||||
<p>{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</p>
|
||||
{:else}
|
||||
<strong>Unavailable</strong>
|
||||
<p>The current Memory list could not be read.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span>Resident context</span>
|
||||
{#if surface}
|
||||
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
|
||||
<p>{surfaceSummary(surface.availability)}</p>
|
||||
{:else}
|
||||
<strong>Unavailable</strong>
|
||||
<p>The resident context status could not be read.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="subject-technical-details">
|
||||
<summary>Technical details</summary>
|
||||
<dl>
|
||||
<div><dt>Subject ID</dt><dd><code>{data.subject.data.id}</code></dd></div>
|
||||
<div>
|
||||
<dt>Subject store revision</dt>
|
||||
<dd>{data.subject.data.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
|
||||
</div>
|
||||
<div><dt>Last changed</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</details>
|
||||
{:else if data.subject.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Subject details unavailable.</strong>
|
||||
@@ -75,38 +133,40 @@
|
||||
<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>
|
||||
<p class="memory-eyebrow">Generated from committed Memory</p>
|
||||
<h2 id="resident-surface-heading">Resident context</h2>
|
||||
</div>
|
||||
{#if surface}
|
||||
<span class="availability is-{surface.availability}">{surface.availability}</span>
|
||||
<span class="availability is-{surface.availability}">{surfaceLabel(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>Generated <time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time></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>
|
||||
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</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>
|
||||
<strong>Resident context is current but empty.</strong>
|
||||
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="surface-document" aria-label="Resident surface">
|
||||
<article class="surface-document" aria-label="Resident context">
|
||||
<DocumentMarkdown text={snapshot.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
<details class="surface-details">
|
||||
<summary>Surface provenance</summary>
|
||||
<summary>Surface sources and diagnostics</summary>
|
||||
<dl>
|
||||
<div><dt>Snapshot id</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
||||
<div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
||||
<div>
|
||||
<dt>Memory refs</dt>
|
||||
<dt>Generated from Subject revision</dt>
|
||||
<dd>{snapshot.built_from_store_revision}<small>The Subject change number used for this surface, distinct from each Memory’s revision.</small></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Referenced Memory revisions</dt>
|
||||
<dd>
|
||||
{#if snapshot.memory_refs.length === 0}
|
||||
None
|
||||
@@ -123,26 +183,26 @@
|
||||
</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>
|
||||
<strong>Resident context has not been generated.</strong>
|
||||
<p>Committed Memories, if any, 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>
|
||||
<strong>Resident context needs to be refreshed.</strong>
|
||||
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</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>
|
||||
<strong>Resident context generation failed.</strong>
|
||||
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
|
||||
</div>
|
||||
{:else if data.surface.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Resident surface unavailable.</strong>
|
||||
<strong>Resident context status unavailable.</strong>
|
||||
<p>{data.surface.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Resident surface data is unavailable.</p></div>
|
||||
<div class="memory-state" role="status"><p>Resident context status is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -152,14 +212,14 @@
|
||||
<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}
|
||||
{#if data.memories.data}<span class="memory-count">{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</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>
|
||||
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
|
||||
<p>{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}</p>
|
||||
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -175,9 +235,9 @@
|
||||
<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>
|
||||
<div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
|
||||
<div><dt>Memory revision</dt><dd>{memory.revision}</dd></div>
|
||||
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -270,25 +330,79 @@
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-facts {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
.subject-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-soft);
|
||||
}
|
||||
|
||||
.subject-facts > div {
|
||||
display: block;
|
||||
.subject-overview > div {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-facts dt {
|
||||
white-space: normal;
|
||||
.subject-overview span,
|
||||
.subject-overview p,
|
||||
.subject-technical-details summary,
|
||||
.subject-technical-details small,
|
||||
.surface-details small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-facts dd {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text);
|
||||
.subject-overview strong {
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-overview p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-technical-details {
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.subject-technical-details summary {
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subject-technical-details dl,
|
||||
.surface-details dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.subject-technical-details dl > div,
|
||||
.surface-details dl > div {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-technical-details dd,
|
||||
.surface-details dd {
|
||||
margin-top: var(--space-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-technical-details small,
|
||||
.surface-details small {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.subject-state,
|
||||
.availability,
|
||||
.memory-pill,
|
||||
@@ -455,7 +569,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.subject-facts {
|
||||
.subject-overview {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -472,10 +586,16 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.subject-facts {
|
||||
.subject-overview,
|
||||
.subject-technical-details dl,
|
||||
.surface-details dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.subject-overview {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
+20
-9
@@ -63,6 +63,11 @@
|
||||
return value.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function titleLabel(value: string): string {
|
||||
const normalized = label(value);
|
||||
return normalized[0].toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
|
||||
function rangeLabel(range: [number, number] | null | undefined): string {
|
||||
return range ? `${range[0]}–${range[1]}` : 'None';
|
||||
}
|
||||
@@ -98,8 +103,8 @@
|
||||
<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>
|
||||
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
|
||||
<span class="memory-kind">{titleLabel(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>
|
||||
@@ -109,11 +114,11 @@
|
||||
</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>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
||||
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
|
||||
<div><dt>Type</dt><dd>{titleLabel(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>
|
||||
<div><dt>Last changed</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">
|
||||
@@ -140,7 +145,7 @@
|
||||
<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>
|
||||
<span>{memory.source_candidates.length} shown</span>
|
||||
</header>
|
||||
|
||||
{#if memory.source_candidates.length === 0}
|
||||
@@ -226,7 +231,11 @@
|
||||
</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>
|
||||
<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} shown{data.revisions.data.has_more ? ' · more available' : ''}</span>{/if}
|
||||
</header>
|
||||
<p class="section-intro">These are versions of this Memory record, separate from the Subject’s internal update number.</p>
|
||||
{#if data.revisions.data}
|
||||
{#if revisions.length === 0}
|
||||
<div>
|
||||
@@ -238,7 +247,7 @@
|
||||
{#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>
|
||||
<div><strong>Memory revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{titleLabel(revision.state)}</span><span class="memory-kind">{titleLabel(revision.kind)}</span></div>
|
||||
<p>{revision.claim}</p>
|
||||
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
|
||||
</a>
|
||||
@@ -386,6 +395,7 @@
|
||||
}
|
||||
|
||||
.memory-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@@ -414,6 +424,7 @@
|
||||
.why-useful,
|
||||
.staleness,
|
||||
.change-reason,
|
||||
.section-intro,
|
||||
.empty-copy,
|
||||
.bounded-note {
|
||||
margin: 0;
|
||||
|
||||
@@ -73,6 +73,9 @@ test("renders the subject index as the Memory product entry", () => {
|
||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("subject-1")).not.toBeNull();
|
||||
expect(screen.getByText("Not connected")).not.toBeNull();
|
||||
expect(screen.queryByText("Store revision", { exact: true })).toBeNull();
|
||||
expect(screen.getByText("1 shown · more available")).not.toBeNull();
|
||||
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
|
||||
.toBe("/w/workspace-1/memory");
|
||||
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||
@@ -214,7 +217,10 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
cursor: "memory-current",
|
||||
subject: result(subject()),
|
||||
subject: result({
|
||||
...subject(),
|
||||
current_worker: { display_name: "Release Worker" },
|
||||
}),
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ready" as const,
|
||||
@@ -267,9 +273,15 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
|
||||
.not.toBeNull();
|
||||
});
|
||||
expect(screen.getByRole("article", { name: "Resident surface" })).not
|
||||
expect(screen.getByRole("article", { name: "Resident context" })).not
|
||||
.toBeNull();
|
||||
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
|
||||
expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("Subject store revision", { exact: true })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText(/not a Memory count/)).not.toBeNull();
|
||||
expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
|
||||
@@ -280,6 +292,34 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
.toContain("cursor=memory-next");
|
||||
});
|
||||
|
||||
test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => {
|
||||
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
|
||||
// stages a candidate without committing a Memory. Its public read projection is
|
||||
// the same empty committed list as a new Subject; candidates are not a page input.
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
cursor: null,
|
||||
subject: result({ ...subject(), store_revision: 0 }),
|
||||
surface: result({ subject_id: "subject-1", availability: "ungenerated" }),
|
||||
memories: result({ limit: 100, items: [], has_more: false }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(screen.getByText("None", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
|
||||
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
|
||||
expect(screen.queryByRole("article", { name: "Resident context" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/candidate count|all Memories lost|automatically updating/i,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||
const base = {
|
||||
workspaceId: "workspace-1",
|
||||
@@ -303,8 +343,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
expect(screen.getByText("Resident surface is ready and empty.")).not
|
||||
expect(screen.getByText("Resident context is current but empty.")).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText(/current empty surface exists/)).not.toBeNull();
|
||||
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
data: {
|
||||
...base,
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ungenerated" as const,
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByText("Resident context has not been generated.")).not
|
||||
.toBeNull();
|
||||
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
@@ -317,7 +374,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByText("Resident surface is stale.")).not.toBeNull();
|
||||
expect(screen.getByText("Resident context needs to be refreshed.")).not
|
||||
.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
@@ -333,7 +391,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
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
|
||||
expect(screen.getByText("Resident context status is unavailable.")).not
|
||||
.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
@@ -342,7 +400,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Resident surface unavailable",
|
||||
"Resident context status unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -61,9 +61,7 @@ async function assertSubjectCreateRejects(
|
||||
assertEquals(error.status, expectedStatus);
|
||||
if (!error.message.includes(expectedMessage)) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(error.message)} to include ${
|
||||
JSON.stringify(expectedMessage)
|
||||
}`,
|
||||
`expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -82,6 +80,30 @@ function subject(id = "subject-1") {
|
||||
};
|
||||
}
|
||||
|
||||
function currentWorker() {
|
||||
return {
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
host_id: "host-1",
|
||||
display_name: "Release Worker",
|
||||
label: "Release Worker",
|
||||
profile: "builtin:companion",
|
||||
singleton_key: "subjektiv:subject-1",
|
||||
tags: [],
|
||||
workspace: {
|
||||
visibility: "workspace",
|
||||
identity: "workspace-1",
|
||||
workspace_id: "workspace-1",
|
||||
},
|
||||
state: "idle",
|
||||
last_seen_at: "2026-09-02T00:00:00Z",
|
||||
pinned: false,
|
||||
retention_state: "retained",
|
||||
implementation: { kind: "embedded", display_hint: "Embedded" },
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function queryItem(id = "memory-1") {
|
||||
return {
|
||||
id,
|
||||
@@ -187,14 +209,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
|
||||
() =>
|
||||
parseSubjektivSubjectListResponse({
|
||||
limit: 100,
|
||||
items: Array.from({ length: 101 }, (_, index) =>
|
||||
subject(`subject-${index}`)),
|
||||
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
|
||||
has_more: false,
|
||||
}),
|
||||
"bounded array",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject parser preserves live Worker connection state", () => {
|
||||
assertEquals(
|
||||
parseSubjektivSubjectResponse({
|
||||
...subject(),
|
||||
current_worker: currentWorker(),
|
||||
}),
|
||||
{ ...subject(), current_worker: currentWorker() },
|
||||
);
|
||||
assertEquals(
|
||||
parseSubjektivSubjectResponse({ ...subject(), current_worker: null }),
|
||||
{ ...subject(), current_worker: null },
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectResponse({
|
||||
...subject(),
|
||||
current_worker: { display_name: "Incomplete Worker" },
|
||||
}),
|
||||
"current worker.runtime_id",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||
const response = { limit: 100, items: [subject()], has_more: false };
|
||||
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
||||
@@ -328,8 +371,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
||||
throw new TypeError("connection reset");
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
||||
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
||||
"unknown_outcome",
|
||||
"may have been created",
|
||||
);
|
||||
@@ -433,8 +475,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
items: Array.from({ length: 101 }, (_, index) =>
|
||||
queryItem(`memory-${index}`)),
|
||||
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
|
||||
has_more: false,
|
||||
}),
|
||||
"bounded array",
|
||||
|
||||
Reference in New Issue
Block a user