chore: merge develop into hare/develop
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:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a)
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -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>, next_cursor?: string | null, 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, };
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -200,21 +200,42 @@ export type SubmissionDisposition = "started" | "queued";
|
||||
|
||||
export type SessionSnapshot = { pending_submissions: PendingSubmissionsSnapshot, entries: Array<SessionSnapshotEntry>, };
|
||||
|
||||
export type ReasoningConfig = { "kind": "effort", effort: string, } | { "kind": "budget_tokens", budget_tokens: number, };
|
||||
|
||||
export type ContextTokenSource = "measured" | "estimated";
|
||||
|
||||
export type ContextUsage = { tokens: number, source: ContextTokenSource, };
|
||||
|
||||
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string,
|
||||
/**
|
||||
* Effective reasoning control. Absent means unspecified or unavailable.
|
||||
*/
|
||||
reasoning?: ReasoningConfig | null, scope_summary: string, tools: Array<string>,
|
||||
/**
|
||||
* Resolved effective model context window in tokens. Legacy producers use
|
||||
* zero when this is unavailable; clients must treat that as unknown.
|
||||
*/
|
||||
context_window: number,
|
||||
/**
|
||||
* Legacy numeric mirror retained for mixed-version clients.
|
||||
*/
|
||||
context_tokens: number,
|
||||
/**
|
||||
* Typed current-context value. Older snapshots omit it; clients may fall
|
||||
* back to `context_tokens` only when `context_window` is known.
|
||||
*/
|
||||
context_usage?: ContextUsage | null, };
|
||||
|
||||
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
|
||||
|
||||
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
|
||||
|
||||
export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array<InternalWorkerSnapshot>, };
|
||||
|
||||
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array<string>,
|
||||
export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot,
|
||||
/**
|
||||
* Model context window in tokens. Always filled by the Worker greeting.
|
||||
* Public execution metadata for this child. Older snapshots and service
|
||||
* workers may omit it; clients must never substitute the parent's values.
|
||||
*/
|
||||
context_window: number,
|
||||
/**
|
||||
* Estimated current session context tokens at connect time.
|
||||
*/
|
||||
context_tokens: number, };
|
||||
greeting?: Greeting | null, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array<InternalWorkerSnapshot>, };
|
||||
|
||||
export type Alert = { level: AlertLevel, source: AlertSource, message: string,
|
||||
/**
|
||||
@@ -304,11 +325,7 @@ resource_key?: string | null,
|
||||
*/
|
||||
availability: SubscriptionWorkerAvailability,
|
||||
/**
|
||||
* Producer-owned monotonic revision for this Worker subject.
|
||||
*/
|
||||
subject_revision: number,
|
||||
/**
|
||||
* Latest revisioned foreground state observed from the Worker. This remains
|
||||
* Latest foreground state observed from the Worker. This remains
|
||||
* absent until an authoritative Worker snapshot/event has been applied.
|
||||
*/
|
||||
worker_state?: WorkerStateSnapshot | null,
|
||||
@@ -336,9 +353,9 @@ export type SubscriptionRequest = { "method": "subscribe_events", "params": { re
|
||||
|
||||
export type SubscriptionWorkerProtocolMethod = { subscription_id: SubscriptionId, method: Method, };
|
||||
|
||||
export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot_revision: number, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } };
|
||||
export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } };
|
||||
|
||||
export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, subject_revision: number, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } };
|
||||
export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } };
|
||||
|
||||
export type SubscriptionFramePayload = { "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod };
|
||||
|
||||
@@ -364,7 +381,7 @@ summary: string,
|
||||
* Full tool output. Absent when the tool chose to return
|
||||
* summary-only, or when the result was pruned.
|
||||
*/
|
||||
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting,
|
||||
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "context_usage", "data": { usage?: ContextUsage | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting,
|
||||
/**
|
||||
* Full revisioned live execution state. `Stopped` remains Runtime
|
||||
* catalog authority and is deliberately not represented here.
|
||||
|
||||
@@ -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:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a)
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a)
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a)
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a)
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a)
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Event } from "#lib/generated/protocol.ts";
|
||||
import { cleanup, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import ConsoleLineItem from "./ConsoleLineItem.svelte";
|
||||
import { createConsoleProjector } from "./model.ts";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function reconnectSnapshot(text: string): Event {
|
||||
return {
|
||||
event: "snapshot",
|
||||
data: {
|
||||
session: {
|
||||
entries: [],
|
||||
pending_submissions: {
|
||||
revision: 0,
|
||||
head_id: null,
|
||||
notification_count: 0,
|
||||
submissions: [],
|
||||
},
|
||||
},
|
||||
greeting: {
|
||||
worker_name: "Worker",
|
||||
cwd: "/repo",
|
||||
provider: "provider",
|
||||
model: "model",
|
||||
scope_summary: "bounded",
|
||||
tools: [],
|
||||
context_window: 100,
|
||||
context_tokens: 20,
|
||||
},
|
||||
state: {
|
||||
last_command_id: 0,
|
||||
state: { kind: "busy", state: { kind: "run", state: "running" } },
|
||||
},
|
||||
in_flight: {
|
||||
blocks: [{ kind: "text", text, finished: false }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("reconnected assistant output keeps the normal streaming Markdown path", async () => {
|
||||
const projector = createConsoleProjector();
|
||||
let projection = projector.append([{
|
||||
eventId: "snapshot",
|
||||
event: reconnectSnapshot("**hel"),
|
||||
}]);
|
||||
const { container, rerender } = render(ConsoleLineItem, {
|
||||
item: projection.lines[0],
|
||||
});
|
||||
|
||||
expect(container.querySelector("li")?.classList.contains("assistant")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(container.querySelector(".message-heading")).toBeNull();
|
||||
expect(container.querySelector(".console-plain-text")).toBeNull();
|
||||
expect(container.textContent).not.toContain("in-flight");
|
||||
|
||||
projection = projector.append([{
|
||||
eventId: "inline-suffix",
|
||||
event: {
|
||||
event: "text_delta",
|
||||
data: { text: "lo** and [safe](https://exam" },
|
||||
},
|
||||
}]);
|
||||
await rerender({ item: projection.lines[0] });
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("strong")?.textContent).toBe("hello");
|
||||
expect(container.querySelector('a[href="https://example.com"]')).toBeNull();
|
||||
expect(container.textContent).toContain("[safe](https://exam");
|
||||
});
|
||||
|
||||
projection = projector.append([{
|
||||
eventId: "fence-prefix",
|
||||
event: {
|
||||
event: "text_delta",
|
||||
data: { text: "ple.com)\n\n```ts\nconst answer = 42;" },
|
||||
},
|
||||
}]);
|
||||
await rerender({ item: projection.lines[0] });
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('a[href="https://example.com"]')).not
|
||||
.toBeNull();
|
||||
expect(container.querySelector("pre code")?.textContent).toContain(
|
||||
"const answer = 42;",
|
||||
);
|
||||
});
|
||||
|
||||
projection = projector.append([{
|
||||
eventId: "fence-suffix",
|
||||
event: { event: "text_delta", data: { text: "\n```" } },
|
||||
}]);
|
||||
await rerender({ item: projection.lines[0] });
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("strong")?.textContent).toBe("hello");
|
||||
expect(container.querySelector('a[href="https://example.com"]')).not
|
||||
.toBeNull();
|
||||
expect(container.querySelector("pre code")?.textContent).toContain(
|
||||
"const answer = 42;",
|
||||
);
|
||||
});
|
||||
|
||||
expect(projection.lines).toHaveLength(1);
|
||||
expect(projection.lines[0]).toMatchObject({
|
||||
kind: "assistant",
|
||||
streaming: true,
|
||||
body:
|
||||
"**hello** and [safe](https://example.com)\n\n```ts\nconst answer = 42;\n```",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import WorkerContextStatus from "./WorkerContextStatus.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("renders resolved model, reasoning, and measured context accessibly", () => {
|
||||
const view = render(WorkerContextStatus, {
|
||||
metadata: {
|
||||
model: "gpt-6-astra",
|
||||
reasoning: { kind: "effort", effort: "high" },
|
||||
contextWindow: 272_000,
|
||||
contextTokens: 142_000,
|
||||
contextSource: "measured",
|
||||
},
|
||||
});
|
||||
|
||||
const status = view.getByLabelText("Worker model and context");
|
||||
expect(status.textContent).toContain("gpt-6-astra · high");
|
||||
expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)");
|
||||
});
|
||||
|
||||
test("renders honest unavailable state instead of a fabricated zero percent", async () => {
|
||||
const view = render(WorkerContextStatus, { metadata: null });
|
||||
expect(view.getByText("Model unavailable")).not.toBeNull();
|
||||
expect(view.getByText("Context unavailable")).not.toBeNull();
|
||||
expect(view.container.textContent).not.toContain("0%");
|
||||
|
||||
await view.rerender({
|
||||
metadata: {
|
||||
model: "claude",
|
||||
reasoning: { kind: "budget_tokens", budget_tokens: 8_192 },
|
||||
contextWindow: 64_000,
|
||||
contextTokens: 12_000,
|
||||
contextSource: "estimated",
|
||||
},
|
||||
});
|
||||
expect(view.container.textContent).toContain("claude · 8.2k token budget");
|
||||
expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)");
|
||||
|
||||
await view.rerender({
|
||||
metadata: {
|
||||
model: "claude",
|
||||
reasoning: { kind: "budget_tokens", budget_tokens: -1 },
|
||||
contextWindow: 64_000,
|
||||
contextTokens: 12_000,
|
||||
contextSource: "estimated",
|
||||
},
|
||||
});
|
||||
expect(view.container.textContent).toContain("claude · dynamic token budget");
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import type { ConsoleWorkerMetadata } from "./model";
|
||||
import {
|
||||
formatContextSummary,
|
||||
formatModelSummary,
|
||||
} from "./worker-metadata";
|
||||
|
||||
type Props = {
|
||||
metadata: ConsoleWorkerMetadata | null;
|
||||
};
|
||||
|
||||
let { metadata }: Props = $props();
|
||||
const modelSummary = $derived(formatModelSummary(metadata));
|
||||
const contextSummary = $derived(formatContextSummary(metadata));
|
||||
</script>
|
||||
|
||||
<div class="worker-context-status" aria-label="Worker model and context">
|
||||
<span class="worker-model" title={modelSummary}>{modelSummary}</span>
|
||||
<span class="separator" aria-hidden="true">|</span>
|
||||
<span class="worker-context" title={contextSummary}>{contextSummary}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.worker-context-status {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-compact);
|
||||
line-height: var(--line-height-compact);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.worker-model,
|
||||
.worker-context {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.worker-model {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: 0 0 auto;
|
||||
color: var(--line-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.worker-context-status {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.worker-model,
|
||||
.worker-context {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -60,7 +60,7 @@
|
||||
<span aria-hidden="true">・</span>
|
||||
<span>{requests} {requestLabel}</span>
|
||||
<span aria-hidden="true">|</span>
|
||||
<span>↑{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)}</span>
|
||||
<span>Run ↑{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -346,6 +346,36 @@ Deno.test("line previews include a current turn outside the retained five-turn p
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("line previews track a snapshot-restored assistant as its body streams", () => {
|
||||
const lines = [{
|
||||
id: "user",
|
||||
entryId: "user-entry",
|
||||
kind: "user" as const,
|
||||
title: "User",
|
||||
body: "question",
|
||||
source: "event" as const,
|
||||
}, {
|
||||
id: "restored-assistant",
|
||||
kind: "assistant" as const,
|
||||
title: "assistant streaming",
|
||||
body: "**hel",
|
||||
source: "event" as const,
|
||||
streaming: true,
|
||||
}];
|
||||
|
||||
assert(
|
||||
conversationTurnPreviewsFromLines(lines)[0].assistant.join("|") ===
|
||||
"**hel",
|
||||
"restored assistant prefix must use the ordinary assistant preview path",
|
||||
);
|
||||
const continued = [lines[0], { ...lines[1], body: "**hello**" }];
|
||||
assert(
|
||||
conversationTurnPreviewsFromLines(continued)[0].assistant.join("|") ===
|
||||
"**hello**",
|
||||
"live suffixes must update the same turn preview",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => {
|
||||
const preview = conversationTurnPreviews([turn(4)])[0];
|
||||
assert(preview.user === "question 4", "user preview must use its first line");
|
||||
|
||||
@@ -203,8 +203,7 @@ export function conversationTurnPreviewsFromLines(
|
||||
assistants: [],
|
||||
};
|
||||
} else if (
|
||||
(line.kind === "assistant" ||
|
||||
(line.kind === "in_flight" && line.title === "in-flight assistant text")) &&
|
||||
line.kind === "assistant" &&
|
||||
current && line.body.trim().length > 0
|
||||
) {
|
||||
current.assistants.push(line.body);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Event,
|
||||
InFlightBlock,
|
||||
SessionSnapshotEntry,
|
||||
WorkerStateSnapshot,
|
||||
WorkerStatus,
|
||||
@@ -176,6 +177,127 @@ function snapshotEvent(cwd: string, entries: unknown[] = []): Event {
|
||||
};
|
||||
}
|
||||
|
||||
type SnapshotEvent = Extract<Event, { event: "snapshot" }>;
|
||||
|
||||
function snapshotWithInFlight(
|
||||
blocks: InFlightBlock[],
|
||||
entries: unknown[] = [],
|
||||
): SnapshotEvent {
|
||||
const snapshot = snapshotEvent("/repo", entries) as SnapshotEvent;
|
||||
snapshot.data.in_flight = { blocks };
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function semanticOutput(lines: readonly ConsoleLine[]) {
|
||||
return lines.map((line) => ({
|
||||
kind: line.kind,
|
||||
title: line.title,
|
||||
body: line.body,
|
||||
streaming: line.streaming,
|
||||
entryId: line.entryId,
|
||||
toolCall: line.toolCall
|
||||
? {
|
||||
id: line.toolCall.id,
|
||||
name: line.toolCall.name,
|
||||
argsStream: line.toolCall.argsStream,
|
||||
arguments: line.toolCall.arguments,
|
||||
state: line.toolCall.state,
|
||||
summary: line.toolCall.summary,
|
||||
output: line.toolCall.output,
|
||||
isError: line.toolCall.isError,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
Deno.test("snapshot and usage keep current context separate from run traffic", () => {
|
||||
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
|
||||
snapshot.data.greeting.model = "gpt-6-astra";
|
||||
snapshot.data.greeting.reasoning = { kind: "effort", effort: "high" };
|
||||
snapshot.data.greeting.context_window = 272_000;
|
||||
snapshot.data.greeting.context_tokens = 1;
|
||||
snapshot.data.greeting.context_usage = { tokens: 142_000, source: "estimated" };
|
||||
const projection = projectConsole([
|
||||
{ eventId: "snapshot", event: snapshot, observedAtMs: 1 },
|
||||
{ eventId: "usage", event: { event: "usage", data: {
|
||||
input_tokens: 150_000,
|
||||
cache_read_input_tokens: 140_000,
|
||||
output_tokens: 2_000,
|
||||
} }, observedAtMs: 2 },
|
||||
{ eventId: "context", event: { event: "context_usage", data: {
|
||||
usage: { tokens: 158_000, source: "estimated" },
|
||||
} }, observedAtMs: 3 },
|
||||
]);
|
||||
|
||||
assertEquals(projection.workerMetadata, {
|
||||
model: "gpt-6-astra",
|
||||
reasoning: { kind: "effort", effort: "high" },
|
||||
contextWindow: 272_000,
|
||||
contextTokens: 158_000,
|
||||
contextSource: "estimated",
|
||||
});
|
||||
assertEquals(projection.runActivity.uploadTokens, 10_000);
|
||||
assertEquals(projection.runActivity.outputTokens, 2_000);
|
||||
});
|
||||
|
||||
Deno.test("snapshot metadata supports budget reasoning and legacy context fallback", () => {
|
||||
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
|
||||
snapshot.data.greeting.reasoning = { kind: "budget_tokens", budget_tokens: 16_384 };
|
||||
delete snapshot.data.greeting.context_usage;
|
||||
const projection = projectConsole([{ eventId: "snapshot", event: snapshot }]);
|
||||
|
||||
assertEquals(projection.workerMetadata, {
|
||||
model: "model",
|
||||
reasoning: { kind: "budget_tokens", budget_tokens: 16_384 },
|
||||
contextWindow: 100,
|
||||
contextTokens: 20,
|
||||
contextSource: "estimated",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("unknown and destructive boundaries never fabricate context percentages", () => {
|
||||
const retained = snapshotEvent("") as SnapshotEvent;
|
||||
retained.data.greeting.model = "";
|
||||
retained.data.greeting.context_window = 0;
|
||||
retained.data.greeting.context_tokens = 0;
|
||||
delete retained.data.greeting.context_usage;
|
||||
let projection = projectConsole([{ eventId: "retained", event: retained }]);
|
||||
assertEquals(projection.workerMetadata, {
|
||||
model: null,
|
||||
reasoning: null,
|
||||
contextWindow: null,
|
||||
contextTokens: null,
|
||||
contextSource: null,
|
||||
});
|
||||
|
||||
const live = snapshotEvent("/repo") as SnapshotEvent;
|
||||
live.data.greeting.context_usage = { tokens: 20, source: "measured" };
|
||||
projection = projectConsole([
|
||||
{ eventId: "live", event: live },
|
||||
{ eventId: "rewind", event: { event: "rewind_applied", data: {
|
||||
session: canonicalSession([]),
|
||||
input: [],
|
||||
summary: { truncated_to_entries: 0, discarded_entries: 1, tool_side_effect_warning: false },
|
||||
} } },
|
||||
]);
|
||||
assertEquals(projection.workerMetadata?.contextTokens, null);
|
||||
assertEquals(projection.workerMetadata?.contextSource, null);
|
||||
|
||||
projection = projectConsole([
|
||||
{ eventId: "live", event: live },
|
||||
{ eventId: "rewind", event: { event: "rewind_applied", data: {
|
||||
session: canonicalSession([]),
|
||||
input: [],
|
||||
summary: { truncated_to_entries: 0, discarded_entries: 1, tool_side_effect_warning: false },
|
||||
} } },
|
||||
{ eventId: "post-rewind-context", event: { event: "context_usage", data: {
|
||||
usage: { tokens: 12, source: "estimated" },
|
||||
} } },
|
||||
]);
|
||||
assertEquals(projection.workerMetadata?.contextTokens, 12);
|
||||
assertEquals(projection.workerMetadata?.contextSource, "estimated");
|
||||
});
|
||||
|
||||
Deno.test("large paste segments project compact artifact metadata", () => {
|
||||
const body = "secret pasted body";
|
||||
const text = segmentsToText([{
|
||||
@@ -2334,11 +2456,273 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
||||
"user:new user:false",
|
||||
"assistant:assistant reply:false",
|
||||
"tool:Read(1 file)\n /tmp/a.md:false",
|
||||
"in_flight:partial:true",
|
||||
"assistant:partial:true",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("snapshot text blocks match live prefixes, suffixes, and ordered commits", () => {
|
||||
const committedPrefix = [{
|
||||
kind: "user_input",
|
||||
segments: [{ kind: "text", content: "question" }],
|
||||
}];
|
||||
const livePrefix: ConsoleEventInput[] = [
|
||||
{ eventId: "live-base", event: snapshotEvent("/repo", committedPrefix) },
|
||||
{
|
||||
eventId: "live-first-delta",
|
||||
event: { event: "text_delta", data: { text: "first draft" } },
|
||||
},
|
||||
{
|
||||
eventId: "live-first-done",
|
||||
event: { event: "text_done", data: { text: "first draft" } },
|
||||
},
|
||||
{
|
||||
eventId: "live-second-delta",
|
||||
event: { event: "text_delta", data: { text: "**hel" } },
|
||||
},
|
||||
];
|
||||
const restoredPrefix: ConsoleEventInput[] = [{
|
||||
eventId: "restored-base",
|
||||
event: snapshotWithInFlight([
|
||||
{ kind: "text", text: "first draft", finished: true },
|
||||
{ kind: "text", text: "**hel", finished: false },
|
||||
], committedPrefix),
|
||||
}];
|
||||
|
||||
assertEquals(
|
||||
semanticOutput(projectConsole(restoredPrefix).lines),
|
||||
semanticOutput(projectConsole(livePrefix).lines),
|
||||
);
|
||||
|
||||
const firstCommit: SessionSnapshotEntry = {
|
||||
entry_id: "assistant-first",
|
||||
timestamp: 2,
|
||||
provenance: "model_output",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
content: [{ kind: "text", text: "first canonical" }],
|
||||
};
|
||||
const secondCommit: SessionSnapshotEntry = {
|
||||
entry_id: "assistant-second",
|
||||
timestamp: 3,
|
||||
provenance: "model_output",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
content: [{ kind: "text", text: "**hello**" }],
|
||||
};
|
||||
const suffix: ConsoleEventInput[] = [
|
||||
{
|
||||
eventId: "commit-first",
|
||||
event: {
|
||||
event: "session_entry_committed",
|
||||
data: { entry: firstCommit },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "second-suffix",
|
||||
event: { event: "text_delta", data: { text: "lo**" } },
|
||||
},
|
||||
{
|
||||
eventId: "second-done",
|
||||
event: { event: "text_done", data: { text: "**hello**" } },
|
||||
},
|
||||
{
|
||||
eventId: "commit-second",
|
||||
event: {
|
||||
event: "session_entry_committed",
|
||||
data: { entry: secondCommit },
|
||||
},
|
||||
},
|
||||
];
|
||||
const liveFinal = projectConsole([...livePrefix, ...suffix]);
|
||||
const restoredFinal = projectConsole([...restoredPrefix, ...suffix]);
|
||||
|
||||
assertEquals(
|
||||
semanticOutput(restoredFinal.lines),
|
||||
semanticOutput(liveFinal.lines),
|
||||
);
|
||||
assertEquals(
|
||||
restoredFinal.lines.filter((line) => line.kind === "assistant").map(
|
||||
(line) => [line.body, line.entryId, line.streaming],
|
||||
),
|
||||
[
|
||||
["first canonical", "assistant-first", false],
|
||||
["**hello**", "assistant-second", false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("snapshot thinking blocks preserve boundaries and live continuation", () => {
|
||||
const livePrefix: ConsoleEventInput[] = [
|
||||
{ eventId: "base", event: snapshotEvent("/repo") },
|
||||
{ eventId: "thinking-1-start", event: { event: "thinking_start" } },
|
||||
{
|
||||
eventId: "thinking-1-delta",
|
||||
event: { event: "thinking_delta", data: { text: "finished thought" } },
|
||||
},
|
||||
{
|
||||
eventId: "thinking-1-done",
|
||||
event: { event: "thinking_done", data: { text: "finished thought" } },
|
||||
},
|
||||
{ eventId: "thinking-2-start", event: { event: "thinking_start" } },
|
||||
{
|
||||
eventId: "thinking-2-delta",
|
||||
event: { event: "thinking_delta", data: { text: "next tho" } },
|
||||
},
|
||||
];
|
||||
const restoredPrefix: ConsoleEventInput[] = [{
|
||||
eventId: "restored",
|
||||
event: snapshotWithInFlight([
|
||||
{ kind: "thinking", text: "finished thought", finished: true },
|
||||
{ kind: "thinking", text: "next tho", finished: false },
|
||||
]),
|
||||
}];
|
||||
|
||||
assertEquals(
|
||||
semanticOutput(projectConsole(restoredPrefix).lines),
|
||||
semanticOutput(projectConsole(livePrefix).lines),
|
||||
);
|
||||
|
||||
const suffix: ConsoleEventInput[] = [
|
||||
{
|
||||
eventId: "thinking-2-suffix",
|
||||
event: { event: "thinking_delta", data: { text: "ught" } },
|
||||
},
|
||||
{
|
||||
eventId: "thinking-2-done",
|
||||
event: { event: "thinking_done", data: { text: "next thought" } },
|
||||
},
|
||||
];
|
||||
const restored = projectConsole([...restoredPrefix, ...suffix]);
|
||||
assertEquals(
|
||||
semanticOutput(restored.lines),
|
||||
semanticOutput(projectConsole([...livePrefix, ...suffix]).lines),
|
||||
);
|
||||
assertEquals(
|
||||
restored.lines.map((line) => [line.kind, line.body, line.streaming]),
|
||||
[
|
||||
["thinking", "finished thought", false],
|
||||
["thinking", "next thought", false],
|
||||
],
|
||||
);
|
||||
assertEquals(projectOverviewLines(restored.lines), []);
|
||||
});
|
||||
|
||||
Deno.test("snapshot tool calls retain identity and state through live suffixes", () => {
|
||||
const livePrefix: ConsoleEventInput[] = [
|
||||
{ eventId: "base", event: snapshotEvent("/repo") },
|
||||
{
|
||||
eventId: "pending-start",
|
||||
event: {
|
||||
event: "tool_call_start",
|
||||
data: { id: "pending", name: "Read" },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "streaming-start",
|
||||
event: {
|
||||
event: "tool_call_start",
|
||||
data: { id: "streaming", name: "Bash" },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "streaming-args",
|
||||
event: {
|
||||
event: "tool_call_args_delta",
|
||||
data: { id: "streaming", json: '{"command":"ec' },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "done-start",
|
||||
event: { event: "tool_call_start", data: { id: "done", name: "Glob" } },
|
||||
},
|
||||
{
|
||||
eventId: "done-args",
|
||||
event: {
|
||||
event: "tool_call_args_delta",
|
||||
data: { id: "done", json: '{"pattern":"*.ts"}' },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "done-call",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: { id: "done", name: "Glob", arguments: '{"pattern":"*.ts"}' },
|
||||
},
|
||||
},
|
||||
];
|
||||
const restoredPrefix: ConsoleEventInput[] = [{
|
||||
eventId: "restored",
|
||||
event: snapshotWithInFlight([
|
||||
{
|
||||
kind: "tool_call",
|
||||
id: "pending",
|
||||
name: "Read",
|
||||
args: "",
|
||||
state: "pending",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
id: "streaming",
|
||||
name: "Bash",
|
||||
args: '{"command":"ec',
|
||||
state: "streaming_args",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
id: "done",
|
||||
name: "Glob",
|
||||
args: '{"pattern":"*.ts"}',
|
||||
state: "done",
|
||||
},
|
||||
]),
|
||||
}];
|
||||
|
||||
assertEquals(
|
||||
semanticOutput(projectConsole(restoredPrefix).lines),
|
||||
semanticOutput(projectConsole(livePrefix).lines),
|
||||
);
|
||||
|
||||
const suffix: ConsoleEventInput[] = [
|
||||
{
|
||||
eventId: "pending-done",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: { id: "pending", name: "Read", arguments: '{"file_path":"a"}' },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "streaming-suffix",
|
||||
event: {
|
||||
event: "tool_call_args_delta",
|
||||
data: { id: "streaming", json: 'ho hi"}' },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "streaming-done",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "streaming",
|
||||
name: "Bash",
|
||||
arguments: '{"command":"echo hi"}',
|
||||
},
|
||||
},
|
||||
},
|
||||
...["pending", "streaming", "done"].map((id): ConsoleEventInput => ({
|
||||
eventId: `${id}-result`,
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: { id, summary: `${id} result`, output: id, is_error: false },
|
||||
},
|
||||
})),
|
||||
];
|
||||
assertEquals(
|
||||
semanticOutput(projectConsole([...restoredPrefix, ...suffix]).lines),
|
||||
semanticOutput(projectConsole([...livePrefix, ...suffix]).lines),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole restores system items from snapshot entries", () => {
|
||||
const projection = projectConsole([{
|
||||
eventId: "system-snapshot",
|
||||
@@ -2666,6 +3050,133 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
||||
assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null);
|
||||
});
|
||||
|
||||
Deno.test("live-created Internal Worker receives own metadata without reconnect", () => {
|
||||
const worker = {
|
||||
session_id: "live-child",
|
||||
name: "research",
|
||||
parent_session_id: "parent-session",
|
||||
kind: "sub_worker" as const,
|
||||
};
|
||||
const childSnapshot = snapshotEvent("/child") as SnapshotEvent;
|
||||
childSnapshot.data.greeting.worker_name = "research";
|
||||
childSnapshot.data.greeting.model = "child-model";
|
||||
childSnapshot.data.greeting.reasoning = { kind: "effort", effort: "high" };
|
||||
childSnapshot.data.greeting.context_window = 64_000;
|
||||
childSnapshot.data.greeting.context_usage = { tokens: 12_000, source: "measured" };
|
||||
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "child-snapshot",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: { worker, revision: 1, event: childSnapshot },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "child-context",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 2,
|
||||
event: {
|
||||
event: "context_usage",
|
||||
data: { usage: { tokens: 14_000, source: "estimated" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(projection.workerMetadata, null);
|
||||
assertEquals(projection.internalWorkers[0].console.workerMetadata, {
|
||||
model: "child-model",
|
||||
reasoning: { kind: "effort", effort: "high" },
|
||||
contextWindow: 64_000,
|
||||
contextTokens: 14_000,
|
||||
contextSource: "estimated",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("Internal Worker snapshot output continues without entering the parent view", () => {
|
||||
const worker = {
|
||||
session_id: "child-reconnect",
|
||||
name: "research",
|
||||
parent_session_id: "parent-session",
|
||||
kind: "sub_worker" as const,
|
||||
};
|
||||
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
|
||||
snapshot.data.in_flight = {
|
||||
blocks: [{ kind: "text", text: "parent", finished: false }],
|
||||
};
|
||||
snapshot.data.internal_workers = [{
|
||||
worker,
|
||||
revision: 4,
|
||||
session: canonicalSession([]),
|
||||
greeting: {
|
||||
worker_name: "research",
|
||||
cwd: "/child",
|
||||
provider: "anthropic",
|
||||
model: "child-model",
|
||||
reasoning: { kind: "budget_tokens", budget_tokens: 8_192 },
|
||||
scope_summary: "child scope",
|
||||
tools: [],
|
||||
context_window: 64_000,
|
||||
context_tokens: 12_000,
|
||||
context_usage: { tokens: 12_000, source: "measured" },
|
||||
},
|
||||
status: "running",
|
||||
in_flight: {
|
||||
blocks: [{ kind: "text", text: "**chi", finished: false }],
|
||||
},
|
||||
internal_workers: [],
|
||||
}];
|
||||
|
||||
const projector = createConsoleProjector();
|
||||
let projection = projector.append([{ eventId: "snapshot", event: snapshot }]);
|
||||
assertEquals(projection.lines.map((line) => line.body), ["parent"]);
|
||||
assertEquals(
|
||||
projection.internalWorkers[0].console.lines.map((line) => line.body),
|
||||
["**chi"],
|
||||
);
|
||||
assertEquals(projection.workerMetadata?.model, "model");
|
||||
assertEquals(projection.internalWorkers[0].console.workerMetadata, {
|
||||
model: "child-model",
|
||||
reasoning: { kind: "budget_tokens", budget_tokens: 8_192 },
|
||||
contextWindow: 64_000,
|
||||
contextTokens: 12_000,
|
||||
contextSource: "measured",
|
||||
});
|
||||
|
||||
projection = projector.append([
|
||||
{
|
||||
eventId: "parent-suffix",
|
||||
event: { event: "text_delta", data: { text: " only" } },
|
||||
},
|
||||
{
|
||||
eventId: "child-suffix",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 5,
|
||||
event: { event: "text_delta", data: { text: "ld**" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(projection.lines.map((line) => line.body), ["parent only"]);
|
||||
assertEquals(
|
||||
projection.internalWorkers[0].console.lines.map((line) => [
|
||||
line.kind,
|
||||
line.body,
|
||||
line.streaming,
|
||||
]),
|
||||
[["assistant", "**child**", true]],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("console Worker views expose only direct Internal Workers", () => {
|
||||
const projector = createConsoleProjector();
|
||||
projector.append([{
|
||||
@@ -3032,10 +3543,7 @@ Deno.test("overview hides thinking and aggregates uninterrupted tool activity",
|
||||
|
||||
Deno.test("overview keeps tool failure counts without marking the activity as an error", () => {
|
||||
const overview = projectOverviewLines([
|
||||
{
|
||||
...consoleLine("thinking-in-flight", "in_flight"),
|
||||
title: "in-flight thinking",
|
||||
},
|
||||
consoleLine("thinking-active", "thinking"),
|
||||
{
|
||||
...consoleLine("failed-read", "tool"),
|
||||
error: true,
|
||||
@@ -3398,7 +3906,8 @@ Deno.test("committed history inserts current live rows after their last stable a
|
||||
historyUser.entryId = "user-1";
|
||||
const currentUser = consoleLine("current-user", "user");
|
||||
currentUser.entryId = "user-1";
|
||||
const live = consoleLine("live-assistant", "in_flight");
|
||||
const live = consoleLine("live-assistant", "assistant");
|
||||
live.streaming = true;
|
||||
|
||||
assertEquals(
|
||||
mergeCommittedHistoryLines([historyUser], [currentUser, live]).map((line) =>
|
||||
|
||||
@@ -3,12 +3,15 @@ import type {
|
||||
CommandEvent,
|
||||
CommandSnapshot,
|
||||
CommandStreamSlice,
|
||||
ContextUsage,
|
||||
Event as ProtocolEvent,
|
||||
Greeting,
|
||||
InFlightBlock,
|
||||
InFlightCompaction,
|
||||
InFlightToolCallState,
|
||||
InternalWorkerRef,
|
||||
InternalWorkerSnapshot,
|
||||
ReasoningConfig,
|
||||
Segment,
|
||||
SessionSnapshotEntry,
|
||||
WorkerState,
|
||||
@@ -40,7 +43,6 @@ export type ConsoleLineKind =
|
||||
| "status"
|
||||
| "error"
|
||||
| "usage"
|
||||
| "in_flight"
|
||||
| "system";
|
||||
|
||||
type ToolCallState =
|
||||
@@ -165,6 +167,14 @@ export function resolveConsoleWorkerView(
|
||||
return views.find((view) => view.sessionId === selectedSessionId) ?? views[0];
|
||||
}
|
||||
|
||||
export type ConsoleWorkerMetadata = {
|
||||
model: string | null;
|
||||
reasoning: ReasoningConfig | null;
|
||||
contextWindow: number | null;
|
||||
contextTokens: number | null;
|
||||
contextSource: "measured" | "estimated" | null;
|
||||
};
|
||||
|
||||
export type ConsoleProjection = {
|
||||
lines: ConsoleLine[];
|
||||
tasks: ConsoleTask[];
|
||||
@@ -175,6 +185,7 @@ export type ConsoleProjection = {
|
||||
usage: string | null;
|
||||
runActivity: RunActivityStats;
|
||||
cwd: string | null;
|
||||
workerMetadata: ConsoleWorkerMetadata | null;
|
||||
lastEventId: string | null;
|
||||
internalWorkers: InternalWorkerProjection[];
|
||||
/** Terminal child-session fences, reset only by an authoritative snapshot. */
|
||||
@@ -205,6 +216,71 @@ function workerStatusFromState(snapshot: WorkerStateSnapshot): WorkerStatus {
|
||||
return "running";
|
||||
}
|
||||
|
||||
function safeTokenCount(value: unknown, allowZero = true): number | null {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) &&
|
||||
(allowZero ? value >= 0 : value > 0)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function metadataFromGreeting(greeting: Greeting): ConsoleWorkerMetadata {
|
||||
const contextWindow = safeTokenCount(greeting.context_window, false);
|
||||
const typedTokens = safeTokenCount(greeting.context_usage?.tokens);
|
||||
const legacyTokens = contextWindow === null
|
||||
? null
|
||||
: safeTokenCount(greeting.context_tokens);
|
||||
const model = greeting.model.trim();
|
||||
return {
|
||||
model: model.length > 0 ? model : null,
|
||||
reasoning: greeting.reasoning ?? null,
|
||||
contextWindow,
|
||||
contextTokens: typedTokens ?? legacyTokens,
|
||||
contextSource: typedTokens !== null
|
||||
? greeting.context_usage?.source ?? null
|
||||
: legacyTokens !== null
|
||||
? "estimated"
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function metadataWithUsage(
|
||||
metadata: ConsoleWorkerMetadata | null,
|
||||
inputTokens: number | null | undefined,
|
||||
): ConsoleWorkerMetadata {
|
||||
const tokens = safeTokenCount(inputTokens);
|
||||
return {
|
||||
model: metadata?.model ?? null,
|
||||
reasoning: metadata?.reasoning ?? null,
|
||||
contextWindow: metadata?.contextWindow ?? null,
|
||||
contextTokens: tokens,
|
||||
contextSource: tokens === null ? null : "measured",
|
||||
};
|
||||
}
|
||||
|
||||
function metadataWithContextUsage(
|
||||
metadata: ConsoleWorkerMetadata | null,
|
||||
usage: ContextUsage | null | undefined,
|
||||
): ConsoleWorkerMetadata {
|
||||
const tokens = safeTokenCount(usage?.tokens);
|
||||
return {
|
||||
model: metadata?.model ?? null,
|
||||
reasoning: metadata?.reasoning ?? null,
|
||||
contextWindow: metadata?.contextWindow ?? null,
|
||||
contextTokens: tokens,
|
||||
contextSource: tokens === null ? null : usage?.source ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function metadataWithoutContext(
|
||||
metadata: ConsoleWorkerMetadata | null,
|
||||
): ConsoleWorkerMetadata | null {
|
||||
return metadata && {
|
||||
...metadata,
|
||||
contextTokens: null,
|
||||
contextSource: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyConsoleProjection(): ConsoleProjection {
|
||||
return {
|
||||
lines: [],
|
||||
@@ -216,6 +292,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
|
||||
usage: null,
|
||||
runActivity: emptyRunActivityStats(),
|
||||
cwd: null,
|
||||
workerMetadata: null,
|
||||
lastEventId: null,
|
||||
internalWorkers: [],
|
||||
removedInternalWorkers: {},
|
||||
@@ -361,8 +438,7 @@ function projectVisibleConsole(
|
||||
}
|
||||
|
||||
function isOverviewThinkingLine(line: ConsoleLine): boolean {
|
||||
return line.kind === "thinking" ||
|
||||
(line.kind === "in_flight" && line.title === "in-flight thinking");
|
||||
return line.kind === "thinking";
|
||||
}
|
||||
|
||||
function representedToolCallCount(line: ConsoleLine): number {
|
||||
@@ -559,12 +635,58 @@ function appendSnapshotInFlightLines(
|
||||
eventId: string,
|
||||
cwd: string | null,
|
||||
): void {
|
||||
const lineIds = new Set(projection.lines.map((line) => line.id));
|
||||
blocks.forEach((block, index) => {
|
||||
const pending = inFlightLine(`${eventId}:${index}`, block, cwd);
|
||||
if (lineIds.has(pending.id)) return;
|
||||
projection.lines.push(pending);
|
||||
lineIds.add(pending.id);
|
||||
const blockEventId = `${eventId}:${index}`;
|
||||
switch (block.kind) {
|
||||
case "text":
|
||||
appendStreaming(
|
||||
projection,
|
||||
blockEventId,
|
||||
"assistant",
|
||||
"assistant streaming",
|
||||
block.text,
|
||||
);
|
||||
if (block.finished) {
|
||||
finalizeStreaming(
|
||||
projection,
|
||||
"assistant",
|
||||
blockEventId,
|
||||
"assistant",
|
||||
block.text,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "thinking":
|
||||
startStreaming(projection, blockEventId, "thinking", "Thinking...");
|
||||
if (block.text) {
|
||||
appendStreaming(
|
||||
projection,
|
||||
blockEventId,
|
||||
"thinking",
|
||||
"Thinking...",
|
||||
block.text,
|
||||
);
|
||||
}
|
||||
if (block.finished) {
|
||||
finalizeStreaming(
|
||||
projection,
|
||||
"thinking",
|
||||
blockEventId,
|
||||
"Thought",
|
||||
block.text,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "tool_call":
|
||||
upsertToolCall(projection, blockEventId, block.id, {
|
||||
name: block.name,
|
||||
argsStream: block.args,
|
||||
arguments: block.state === "done" ? block.args : undefined,
|
||||
state: inFlightToolState(block.state),
|
||||
cwd,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -735,6 +857,9 @@ function projectInternalWorkerSnapshot(
|
||||
cwd,
|
||||
);
|
||||
console.status = snapshot.status;
|
||||
console.workerMetadata = snapshot.greeting
|
||||
? metadataFromGreeting(snapshot.greeting)
|
||||
: null;
|
||||
appendSnapshotInFlightLines(
|
||||
console,
|
||||
snapshot.in_flight?.blocks ?? [],
|
||||
@@ -907,6 +1032,7 @@ export function applyProtocolEvent(
|
||||
envelope.observedAtMs ?? 0,
|
||||
),
|
||||
cwd: projection.cwd,
|
||||
workerMetadata: projection.workerMetadata,
|
||||
lastEventId: envelope.eventId,
|
||||
internalWorkers: [...projection.internalWorkers],
|
||||
removedInternalWorkers: { ...projection.removedInternalWorkers },
|
||||
@@ -924,10 +1050,23 @@ export function applyProtocolEvent(
|
||||
entryId: event.data.entry_id ?? undefined,
|
||||
});
|
||||
break;
|
||||
case "session_entry_committed":
|
||||
reconcileCommittedSessionEntry(next, event.data.entry);
|
||||
case "session_entry_committed": {
|
||||
const replacementIndex = reconcileCommittedSessionEntry(
|
||||
next,
|
||||
event.data.entry,
|
||||
);
|
||||
applySessionEntry(next, envelope.eventId, event.data.entry);
|
||||
if (replacementIndex !== undefined) {
|
||||
const committedIndex = next.lines.findIndex((line) =>
|
||||
line.entryId === event.data.entry.entry_id
|
||||
);
|
||||
if (committedIndex >= 0) {
|
||||
const [committed] = next.lines.splice(committedIndex, 1);
|
||||
next.lines.splice(replacementIndex, 0, committed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "system_item":
|
||||
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
|
||||
applyTaskSystemItem(next, event.data.item);
|
||||
@@ -951,9 +1090,7 @@ export function applyProtocolEvent(
|
||||
);
|
||||
break;
|
||||
case "thinking_start":
|
||||
next.lines.push(
|
||||
line(envelope.eventId, "thinking", "Thinking...", "", undefined, true),
|
||||
);
|
||||
startStreaming(next, envelope.eventId, "thinking", "Thinking...");
|
||||
break;
|
||||
case "thinking_delta":
|
||||
appendStreaming(
|
||||
@@ -1000,6 +1137,16 @@ export function applyProtocolEvent(
|
||||
break;
|
||||
case "usage":
|
||||
next.usage = usageText(event.data);
|
||||
next.workerMetadata = metadataWithUsage(
|
||||
next.workerMetadata,
|
||||
event.data.input_tokens,
|
||||
);
|
||||
break;
|
||||
case "context_usage":
|
||||
next.workerMetadata = metadataWithContextUsage(
|
||||
next.workerMetadata,
|
||||
event.data.usage,
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
if (hasDurableRunFailure(next, event.data.message)) break;
|
||||
@@ -1017,6 +1164,7 @@ export function applyProtocolEvent(
|
||||
break;
|
||||
case "snapshot": {
|
||||
next.cwd = event.data.greeting.cwd;
|
||||
next.workerMetadata = metadataFromGreeting(event.data.greeting);
|
||||
const snapshot = snapshotProjectionFromSession(
|
||||
envelope.eventId,
|
||||
event.data.session,
|
||||
@@ -1116,6 +1264,7 @@ export function applyProtocolEvent(
|
||||
next.lines = [...segment.lines, ...retainedErrors, ...retainedCompaction];
|
||||
next.tasks = segment.tasks;
|
||||
next.taskNextId = segment.taskNextId;
|
||||
next.workerMetadata = metadataWithoutContext(next.workerMetadata);
|
||||
break;
|
||||
}
|
||||
case "invoke_start":
|
||||
@@ -1141,21 +1290,25 @@ export function applyProtocolEvent(
|
||||
case "memory_worker":
|
||||
case "completions":
|
||||
case "rewind_targets":
|
||||
case "rewind_applied":
|
||||
case "workers_listed":
|
||||
case "worker_restored":
|
||||
case "peer_registered":
|
||||
// These are protocol/status/control events. TUI Console does not append
|
||||
// them to the conversation surface; browser Console should not either.
|
||||
break;
|
||||
case "rewind_applied":
|
||||
next.workerMetadata = metadataWithoutContext(next.workerMetadata);
|
||||
break;
|
||||
case "compaction_progress":
|
||||
return applyInFlightCompaction(next, event.data.compaction ?? null);
|
||||
case "compact_start":
|
||||
case "compact_done":
|
||||
case "compact_failed":
|
||||
// Durable lifecycle events are retained only as historical protocol
|
||||
// compatibility. Runtime progress is the sole Console block authority.
|
||||
break;
|
||||
case "compact_done":
|
||||
next.workerMetadata = metadataWithoutContext(next.workerMetadata);
|
||||
break;
|
||||
case "shutdown":
|
||||
next.status = "shutdown";
|
||||
break;
|
||||
@@ -1296,7 +1449,8 @@ function appendDurableRunFailure(
|
||||
) return;
|
||||
projection.lines = projection.lines.filter((line) => {
|
||||
if (isDurableRunFailure(line)) return true;
|
||||
return !(line.kind === "error" && runFailureMessagesMatch(line.body, message));
|
||||
return !(line.kind === "error" &&
|
||||
runFailureMessagesMatch(line.body, message));
|
||||
});
|
||||
projection.lines.push({
|
||||
...line(
|
||||
@@ -1344,6 +1498,15 @@ function findLastLineIndex(
|
||||
return -1;
|
||||
}
|
||||
|
||||
function startStreaming(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
kind: "assistant" | "thinking",
|
||||
title: string,
|
||||
): void {
|
||||
projection.lines.push(line(eventId, kind, title, "", undefined, true));
|
||||
}
|
||||
|
||||
function appendStreaming(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
@@ -2117,6 +2280,7 @@ function snapshotProjectionFromSession(
|
||||
usage: null,
|
||||
runActivity: emptyRunActivityStats(),
|
||||
cwd,
|
||||
workerMetadata: null,
|
||||
lastEventId: eventId,
|
||||
internalWorkers: [],
|
||||
removedInternalWorkers: {},
|
||||
@@ -2131,16 +2295,22 @@ function snapshotProjectionFromSession(
|
||||
function reconcileCommittedSessionEntry(
|
||||
projection: ConsoleProjection,
|
||||
entry: SessionSnapshotEntry,
|
||||
): void {
|
||||
): number | undefined {
|
||||
if (entry.kind !== "message" || entry.role !== "assistant") return;
|
||||
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
|
||||
const turnStart = findLastLineIndex(
|
||||
projection.lines,
|
||||
(line) => line.kind === "user",
|
||||
);
|
||||
for (let index = turnStart + 1; index < projection.lines.length; index += 1) {
|
||||
const line = projection.lines[index];
|
||||
if (line.entryId) continue;
|
||||
if (line.kind === "assistant" || line.kind === "in_flight") {
|
||||
if (
|
||||
line.kind === "assistant" &&
|
||||
!line.entryId &&
|
||||
line.streaming !== true
|
||||
) {
|
||||
projection.lines.splice(index, 1);
|
||||
return;
|
||||
return index;
|
||||
}
|
||||
if (line.kind === "user") return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2358,42 +2528,6 @@ function loggedContentText(parts: unknown[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function inFlightLine(
|
||||
eventId: string,
|
||||
block: InFlightBlock,
|
||||
cwd: string | null,
|
||||
): ConsoleLine {
|
||||
switch (block.kind) {
|
||||
case "text":
|
||||
return line(
|
||||
eventId,
|
||||
"in_flight",
|
||||
"in-flight assistant text",
|
||||
block.text,
|
||||
undefined,
|
||||
!block.finished,
|
||||
);
|
||||
case "thinking":
|
||||
return line(
|
||||
eventId,
|
||||
"in_flight",
|
||||
"in-flight thinking",
|
||||
block.text,
|
||||
undefined,
|
||||
!block.finished,
|
||||
);
|
||||
case "tool_call":
|
||||
return toolLine(eventId, {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
argsStream: block.args,
|
||||
arguments: block.state === "done" ? block.args : undefined,
|
||||
state: inFlightToolState(block.state),
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function inFlightToolState(
|
||||
state: InFlightToolCallState | undefined,
|
||||
): ToolCallState {
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
resolveCompactionStatusPresentation,
|
||||
visibleCompactionProgress,
|
||||
} from "./run-status.ts";
|
||||
import {
|
||||
formatContextSummary,
|
||||
formatModelSummary,
|
||||
formatReasoning,
|
||||
} from "./worker-metadata.ts";
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
@@ -17,6 +22,61 @@ function assertEquals(actual: unknown, expected: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("worker metadata formats effort, budget, estimate, and unknown safely", () => {
|
||||
assertEquals(
|
||||
formatModelSummary({
|
||||
model: "gpt-6-astra",
|
||||
reasoning: { kind: "effort", effort: "high" },
|
||||
contextWindow: 272_000,
|
||||
contextTokens: 142_000,
|
||||
contextSource: "measured",
|
||||
}),
|
||||
"gpt-6-astra · high",
|
||||
);
|
||||
assertEquals(
|
||||
formatReasoning({ kind: "budget_tokens", budget_tokens: 16_384 }),
|
||||
"16.4k token budget",
|
||||
);
|
||||
assertEquals(
|
||||
formatContextSummary({
|
||||
model: "model",
|
||||
reasoning: null,
|
||||
contextWindow: 272_000,
|
||||
contextTokens: 142_000,
|
||||
contextSource: "estimated",
|
||||
}),
|
||||
"Context ~142.0k / 272.0k (52%)",
|
||||
);
|
||||
assertEquals(formatModelSummary(null), "Model unavailable");
|
||||
assertEquals(
|
||||
formatReasoning({ kind: "effort", effort: "" }),
|
||||
"reasoning unavailable",
|
||||
);
|
||||
assertEquals(
|
||||
formatReasoning({ kind: "budget_tokens", budget_tokens: Number.NaN }),
|
||||
"reasoning unavailable",
|
||||
);
|
||||
assertEquals(
|
||||
formatReasoning({ kind: "budget_tokens", budget_tokens: -1 }),
|
||||
"dynamic token budget",
|
||||
);
|
||||
assertEquals(
|
||||
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
|
||||
"reasoning unavailable",
|
||||
);
|
||||
assertEquals(formatContextSummary(null), "Context unavailable");
|
||||
assertEquals(
|
||||
formatContextSummary({
|
||||
model: null,
|
||||
reasoning: null,
|
||||
contextWindow: 0,
|
||||
contextTokens: 0,
|
||||
contextSource: null,
|
||||
}),
|
||||
"Context unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("run activity follows TUI request and net-token accounting", () => {
|
||||
let stats = applyRunActivityEvent(
|
||||
emptyRunActivityStats(),
|
||||
|
||||
@@ -54,11 +54,11 @@ Deno.test("streaming updates change the preview without creating another turn",
|
||||
equal(answer.body, "Hi");
|
||||
});
|
||||
|
||||
Deno.test("snapshot in-flight assistant text is previewed but in-flight thinking is not", () => {
|
||||
Deno.test("snapshot-restored assistant text is previewed but thinking is not", () => {
|
||||
equal(consoleTurns([
|
||||
line("u", "user", "Question"),
|
||||
{ ...line("reasoning", "in_flight", "Private"), title: "in-flight thinking" },
|
||||
{ ...line("text", "in_flight", "Answer in progress"), title: "in-flight assistant text" },
|
||||
line("reasoning", "thinking", "Private"),
|
||||
line("text", "assistant", "Answer in progress"),
|
||||
]), [{ id: "u", user: "Question", assistant: "Answer in progress" }]);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,11 +23,7 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] {
|
||||
if (line.kind === "user") {
|
||||
current = { id: line.id, user: previewText(line.body), assistant: "" };
|
||||
turns.push(current);
|
||||
} else if (
|
||||
current &&
|
||||
(line.kind === "assistant" ||
|
||||
(line.kind === "in_flight" && line.title === "in-flight assistant text"))
|
||||
) {
|
||||
} else if (current && line.kind === "assistant") {
|
||||
const assistant = previewText(line.body);
|
||||
if (assistant) current.assistant = assistant;
|
||||
}
|
||||
|
||||
@@ -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,13 @@ 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", "ConsoleDisplayState.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",
|
||||
];
|
||||
@@ -728,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(
|
||||
@@ -736,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)") &&
|
||||
@@ -763,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);")),
|
||||
@@ -784,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)") &&
|
||||
@@ -813,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",
|
||||
);
|
||||
@@ -895,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) &&
|
||||
@@ -904,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",
|
||||
);
|
||||
@@ -996,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") &&
|
||||
@@ -1487,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"'),
|
||||
@@ -1532,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",
|
||||
);
|
||||
});
|
||||
@@ -1580,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",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReasoningConfig } from "#lib/generated/protocol.ts";
|
||||
import type { ConsoleWorkerMetadata } from "./model";
|
||||
import { formatRunTokens } from "./run-status";
|
||||
|
||||
export function formatReasoning(reasoning: ReasoningConfig | null): string {
|
||||
if (!reasoning) return "reasoning unavailable";
|
||||
if (reasoning.kind === "effort") {
|
||||
const effort = reasoning.effort.trim();
|
||||
return effort.length > 0 ? effort : "reasoning unavailable";
|
||||
}
|
||||
if (!Number.isSafeInteger(reasoning.budget_tokens)) {
|
||||
return "reasoning unavailable";
|
||||
}
|
||||
if (reasoning.budget_tokens === -1) {
|
||||
return "dynamic token budget";
|
||||
}
|
||||
if (reasoning.budget_tokens <= 0) {
|
||||
return "reasoning unavailable";
|
||||
}
|
||||
return `${formatRunTokens(reasoning.budget_tokens)} token budget`;
|
||||
}
|
||||
|
||||
export function formatModelSummary(
|
||||
metadata: ConsoleWorkerMetadata | null,
|
||||
): string {
|
||||
if (!metadata?.model) return "Model unavailable";
|
||||
return `${metadata.model} · ${formatReasoning(metadata.reasoning)}`;
|
||||
}
|
||||
|
||||
export function formatContextSummary(
|
||||
metadata: ConsoleWorkerMetadata | null,
|
||||
): string {
|
||||
const used = metadata?.contextTokens;
|
||||
const limit = metadata?.contextWindow;
|
||||
if (
|
||||
used === null || used === undefined || limit === null ||
|
||||
limit === undefined || !Number.isSafeInteger(used) ||
|
||||
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
|
||||
) return "Context unavailable";
|
||||
const percent = Math.round((used / limit) * 100);
|
||||
if (!Number.isFinite(percent)) return "Context unavailable";
|
||||
const estimate = metadata?.contextSource === "estimated" ? "~" : "";
|
||||
return `Context ${estimate}${formatRunTokens(used)} / ${
|
||||
formatRunTokens(limit)
|
||||
} (${percent}%)`;
|
||||
}
|
||||
@@ -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.ts";
|
||||
import { parseWorkerLaunchWorkerSummary } from "#lib/workspace/api/workers.ts";
|
||||
|
||||
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,
|
||||
@@ -283,6 +321,556 @@ 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", "next_cursor", "has_more"],
|
||||
"Subject list response",
|
||||
["next_cursor"],
|
||||
);
|
||||
const limit = positiveInteger(record.limit, "limit");
|
||||
if (limit > MAX_SUBJECTS) invalid("limit exceeds the Subject list bound");
|
||||
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");
|
||||
const hasMore = requiredBoolean(record, "has_more");
|
||||
const nextCursor = optionalNullableString(record, "next_cursor");
|
||||
assertCursorInvariant(hasMore, nextCursor, "Subject list");
|
||||
const result: SubjektivSubjectListResponse = {
|
||||
limit,
|
||||
items,
|
||||
has_more: hasMore,
|
||||
};
|
||||
if (nextCursor !== undefined) result.next_cursor = nextCursor;
|
||||
return result;
|
||||
}
|
||||
|
||||
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 +972,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 +1032,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(
|
||||
|
||||
@@ -83,7 +83,7 @@ test('invalid inbound frame closes before dispatch and reconnects for a fresh sn
|
||||
|
||||
first.message(
|
||||
JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'subscription_closed',
|
||||
@@ -141,7 +141,7 @@ test('valid subscription closure remains isolated and resubscribes only its sele
|
||||
: { topic: 'workspace_workdirs', data: { workdirs: [] } };
|
||||
socket.message(
|
||||
JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
@@ -149,7 +149,6 @@ test('valid subscription closure remains isolated and resubscribes only its sele
|
||||
request_id: request.message.params.request_id,
|
||||
subscription_id: `subscription-${index + 1}`,
|
||||
selector,
|
||||
snapshot_revision: 1,
|
||||
snapshot,
|
||||
},
|
||||
},
|
||||
@@ -160,7 +159,7 @@ test('valid subscription closure remains isolated and resubscribes only its sele
|
||||
const sentBeforeClosure = socket.sent.length;
|
||||
socket.message(
|
||||
JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'subscription_closed',
|
||||
|
||||
@@ -138,7 +138,7 @@ export class WorkspaceMultiplexer {
|
||||
subscription.requestId = requestId;
|
||||
this.#requests.set(requestId, subscription.clientId);
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'request',
|
||||
message: {
|
||||
method: 'subscribe_events',
|
||||
@@ -213,7 +213,7 @@ export class WorkspaceMultiplexer {
|
||||
const subscription = this.#subscriptions.get(clientId);
|
||||
if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open');
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'worker_protocol',
|
||||
message: { subscription_id: subscription.subscriptionId, method },
|
||||
});
|
||||
@@ -225,7 +225,7 @@ export class WorkspaceMultiplexer {
|
||||
this.#subscriptions.delete(clientId);
|
||||
if (subscription.subscriptionId && this.#socket?.readyState === WebSocket.OPEN) {
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'request',
|
||||
message: {
|
||||
method: 'unsubscribe_events',
|
||||
|
||||
@@ -16,7 +16,7 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||
|
||||
function subscribedFrame(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
@@ -24,7 +24,6 @@ function subscribedFrame(overrides: Record<string, unknown> = {}): Record<string
|
||||
request_id: 'request-1',
|
||||
subscription_id: 'subscription-1',
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: 'workers', data: { workers: [] } },
|
||||
...overrides,
|
||||
},
|
||||
@@ -86,7 +85,6 @@ Deno.test('generated subscription validator accepts an external Workdir Worker',
|
||||
runtime_id: 'runtime-1',
|
||||
resource_key: 'worker-resource-1',
|
||||
availability: 'observed',
|
||||
subject_revision: 1,
|
||||
state: 'idle',
|
||||
has_running_internal_workers: false,
|
||||
workspace_id: 'workspace-1',
|
||||
@@ -123,8 +121,8 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
|
||||
'unknown nested field',
|
||||
subscribedFrame({ snapshot: { topic: 'workers', data: { workers: [], future: true } } }),
|
||||
],
|
||||
['wrong field type', subscribedFrame({ snapshot_revision: '1' })],
|
||||
['unsafe integer', subscribedFrame({ snapshot_revision: Number.MAX_SAFE_INTEGER + 1 })],
|
||||
['wrong field type', { ...subscribedFrame(), protocol_version: '2' }],
|
||||
['unsafe integer', { ...subscribedFrame(), protocol_version: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[
|
||||
'missing serialized Worker defaults',
|
||||
subscribedFrame({
|
||||
@@ -132,23 +130,22 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
|
||||
topic: 'workers',
|
||||
data: {
|
||||
workers: [
|
||||
{ worker_id: 'worker-1', subject_revision: 1, state: 'idle' },
|
||||
{ worker_id: 'worker-1', state: 'idle' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
['unsupported version', { ...subscribedFrame(), protocol_version: 2 }],
|
||||
['unsupported version', { ...subscribedFrame(), protocol_version: 1 }],
|
||||
[
|
||||
'newer Worker event',
|
||||
{
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_protocol',
|
||||
data: { worker_id: 'worker-1', event: { event: 'future_event' } },
|
||||
@@ -160,13 +157,12 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
|
||||
[
|
||||
'out-of-range Rust u32',
|
||||
{
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_protocol',
|
||||
data: {
|
||||
@@ -207,7 +203,7 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
[
|
||||
'oversized rejection message',
|
||||
{
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscription_rejected',
|
||||
@@ -242,7 +238,6 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
worker_id: 'worker-1',
|
||||
runtime_id: 'runtime-1',
|
||||
availability: 'observed',
|
||||
subject_revision: 1,
|
||||
state: 'idle',
|
||||
has_running_internal_workers: false,
|
||||
},
|
||||
@@ -250,7 +245,6 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
worker_id: 'worker-1',
|
||||
runtime_id: 'runtime-1',
|
||||
availability: 'observed',
|
||||
subject_revision: 2,
|
||||
state: 'running',
|
||||
has_running_internal_workers: false,
|
||||
},
|
||||
@@ -262,7 +256,7 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
[
|
||||
'server-to-Browser direction mismatch',
|
||||
{
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'request',
|
||||
message: {
|
||||
method: 'unsubscribe_events',
|
||||
@@ -281,13 +275,12 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
Deno.test('aggregate limits include keys in unconstrained Worker JSON values', () => {
|
||||
const oversizedKey = 'k'.repeat(MAX_SUBSCRIPTION_STRING_BYTES + 1);
|
||||
const result = decode({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_protocol',
|
||||
data: {
|
||||
@@ -306,13 +299,12 @@ Deno.test('aggregate limits include keys in unconstrained Worker JSON values', (
|
||||
|
||||
Deno.test('stateful selector validation fences a routed event before projection', () => {
|
||||
const frame: SubscriptionFrame = {
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_protocol',
|
||||
data: { worker_id: 'worker-2', event: { event: 'thinking_start' } },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -43,7 +43,7 @@ function snapshot(
|
||||
availability: SubscriptionWorker["availability"],
|
||||
): SubscriptionFrame {
|
||||
return {
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
@@ -51,7 +51,6 @@ function snapshot(
|
||||
request_id: "request-1",
|
||||
subscription_id: "subscription-1",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot_revision: 1,
|
||||
snapshot: {
|
||||
topic: "workers",
|
||||
data: {
|
||||
@@ -60,7 +59,6 @@ function snapshot(
|
||||
runtime_id: "runtime-a",
|
||||
resource_key: "W-1",
|
||||
availability,
|
||||
subject_revision: 1,
|
||||
state,
|
||||
// An absent foreground snapshot must not prevent stopping an active catalog Worker.
|
||||
worker_state: null,
|
||||
|
||||
@@ -6,55 +6,55 @@ import type {
|
||||
|
||||
export type WorkspaceWorkersProjection = {
|
||||
workers: Map<string, SubscriptionWorker>;
|
||||
revisions: Map<string, number>;
|
||||
subscriptionId: string | null;
|
||||
};
|
||||
|
||||
export function createWorkspaceWorkersProjection(): WorkspaceWorkersProjection {
|
||||
return { workers: new Map(), revisions: new Map() };
|
||||
return { workers: new Map(), subscriptionId: null };
|
||||
}
|
||||
|
||||
export function applyWorkspaceWorkersFrame(
|
||||
projection: WorkspaceWorkersProjection,
|
||||
frame: SubscriptionFrame,
|
||||
): void {
|
||||
if (frame.protocol_version !== 1) throw new Error('unsupported Worker subscription protocol');
|
||||
if (frame.protocol_version !== 2) throw new Error('unsupported Worker subscription protocol');
|
||||
if (frame.frame === 'response' && frame.message.result === 'subscribed') {
|
||||
if (frame.message.payload.selector.topic !== 'workspace_workers') return;
|
||||
const snapshot = frame.message.payload.snapshot;
|
||||
if (snapshot.topic !== 'workers') throw new Error('workspace_workers returned a non-Worker snapshot');
|
||||
projection.workers.clear();
|
||||
projection.revisions.clear();
|
||||
|
||||
// Build the replacement before mutating the live projection so one malformed
|
||||
// Worker cannot partially clear the previous subscription lifetime.
|
||||
const workers = new Map<string, SubscriptionWorker>();
|
||||
for (const worker of snapshot.data.workers) {
|
||||
const key = workerKey(worker.runtime_id, worker.worker_id);
|
||||
projection.workers.set(key, worker);
|
||||
projection.revisions.set(key, worker.subject_revision);
|
||||
workers.set(workerKey(worker.runtime_id, worker.worker_id), worker);
|
||||
}
|
||||
projection.workers = workers;
|
||||
projection.subscriptionId = frame.message.payload.subscription_id;
|
||||
return;
|
||||
}
|
||||
if (frame.frame !== 'event' || frame.message.event !== 'event') return;
|
||||
applyPayload(projection, frame.message.data.subject_revision, frame.message.data.payload);
|
||||
const subscriptionId = frame.message.data.subscription_id;
|
||||
if (!projection.subscriptionId) {
|
||||
throw new Error('workspace_workers event arrived before its snapshot');
|
||||
}
|
||||
if (subscriptionId !== projection.subscriptionId) return;
|
||||
applyPayload(projection, frame.message.data.payload);
|
||||
}
|
||||
|
||||
function applyPayload(
|
||||
projection: WorkspaceWorkersProjection,
|
||||
subjectRevision: number,
|
||||
payload: SubscriptionEventPayload,
|
||||
): void {
|
||||
if (payload.event === 'worker_upserted') {
|
||||
const worker = payload.data.worker;
|
||||
const key = workerKey(worker.runtime_id, worker.worker_id);
|
||||
if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return;
|
||||
projection.revisions.set(key, subjectRevision);
|
||||
projection.workers.set(key, worker);
|
||||
projection.workers.set(workerKey(worker.runtime_id, worker.worker_id), worker);
|
||||
} else if (payload.event === 'worker_removed') {
|
||||
const key = workerKey(payload.data.runtime_id, payload.data.worker_id);
|
||||
if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return;
|
||||
projection.revisions.set(key, subjectRevision);
|
||||
projection.workers.delete(key);
|
||||
projection.workers.delete(workerKey(payload.data.runtime_id, payload.data.worker_id));
|
||||
}
|
||||
}
|
||||
|
||||
function workerKey(runtimeId: string | null | undefined, workerId: string): string {
|
||||
if (!runtimeId) throw new Error('Workspace Worker projection is missing runtime_id');
|
||||
return `${runtimeId}:${workerId}`;
|
||||
return JSON.stringify([runtimeId, workerId]);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import type { SubscriptionFrame, SubscriptionWorker } from '#lib/generated/protocol.ts';
|
||||
import type {
|
||||
SubscriptionEventPayload,
|
||||
SubscriptionFrame,
|
||||
SubscriptionWorker,
|
||||
} from '#lib/generated/protocol.ts';
|
||||
|
||||
import { liveWorkerState } from './worker-state';
|
||||
import {
|
||||
applyWorkspaceWorkersFrame,
|
||||
createWorkspaceWorkersProjection,
|
||||
} from './worker-subscription-model';
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
import { liveWorkerState } from './worker-state';
|
||||
import {
|
||||
applyWorkspaceWorkersFrame,
|
||||
createWorkspaceWorkersProjection,
|
||||
} from './worker-subscription-model';
|
||||
|
||||
function assertThrows(fn: () => void, message: string): void {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
@@ -18,7 +32,6 @@ declare const Deno: {
|
||||
function worker(
|
||||
runtimeId: string,
|
||||
workerId: string,
|
||||
revision: number,
|
||||
hasRunningInternalWorkers = false,
|
||||
availability: 'observed' | 'unavailable' = 'observed',
|
||||
): SubscriptionWorker {
|
||||
@@ -26,7 +39,6 @@ function worker(
|
||||
worker_id: workerId,
|
||||
runtime_id: runtimeId,
|
||||
availability,
|
||||
subject_revision: revision,
|
||||
state: 'idle',
|
||||
has_running_internal_workers: hasRunningInternalWorkers,
|
||||
workspace_id: 'workspace-test',
|
||||
@@ -36,15 +48,52 @@ function worker(
|
||||
};
|
||||
}
|
||||
|
||||
function key(runtimeId: string, workerId: string): string {
|
||||
return JSON.stringify([runtimeId, workerId]);
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
subscriptionId: string,
|
||||
workers: SubscriptionWorker[],
|
||||
): SubscriptionFrame {
|
||||
return {
|
||||
protocol_version: 2,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
payload: {
|
||||
request_id: `request-${subscriptionId}`,
|
||||
subscription_id: subscriptionId,
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot: { topic: 'workers', data: { workers } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function event(
|
||||
subscriptionId: string,
|
||||
payload: SubscriptionEventPayload,
|
||||
): SubscriptionFrame {
|
||||
return {
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: { subscription_id: subscriptionId, payload },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test('Worker list state uses the authoritative live snapshot separately from lifecycle', () => {
|
||||
const active = worker('runtime-a', 'worker-1', 1);
|
||||
const active = worker('runtime-a', 'worker-1');
|
||||
active.worker_state = {
|
||||
last_command_id: 1,
|
||||
state: { kind: 'busy', state: { kind: 'run', state: 'paused' } },
|
||||
};
|
||||
assertEquals(liveWorkerState(active), 'paused');
|
||||
|
||||
const unavailable = worker('runtime-a', 'worker-2', 1, false, 'unavailable');
|
||||
const unavailable = worker('runtime-a', 'worker-2', false, 'unavailable');
|
||||
unavailable.worker_state = {
|
||||
last_command_id: 2,
|
||||
state: { kind: 'busy', state: { kind: 'run', state: 'running' } },
|
||||
@@ -67,127 +116,118 @@ Deno.test('Worker list state uses the authoritative live snapshot separately fro
|
||||
|
||||
Deno.test('workspace Worker snapshot keeps equal local ids from different Runtimes', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
const frame: SubscriptionFrame = {
|
||||
protocol_version: 1,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
payload: {
|
||||
request_id: 'request-1',
|
||||
subscription_id: 'subscription-1',
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot_revision: 1,
|
||||
snapshot: {
|
||||
topic: 'workers',
|
||||
data: { workers: [worker('runtime-a', '1', 1), worker('runtime-b', '1', 1)] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
applyWorkspaceWorkersFrame(projection, frame);
|
||||
assertEquals([...projection.workers.keys()].sort(), ['runtime-a:1', 'runtime-b:1']);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-1', [worker('runtime-a', '1'), worker('runtime-b', '1')]),
|
||||
);
|
||||
assertEquals([...projection.workers.keys()].sort(), [key('runtime-a', '1'), key('runtime-b', '1')]);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker snapshot and live update preserve canonical Job metadata', () => {
|
||||
Deno.test('workspace Worker snapshot and ordered live update preserve canonical Job metadata', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
const snapshotWorker = worker('runtime-a', 'job-worker', 1);
|
||||
const snapshotWorker = worker('runtime-a', 'job-worker');
|
||||
snapshotWorker.job = {
|
||||
job_id: 'check:T-1:r1',
|
||||
attempt_id: 'check:T-1:r1:attempt:1',
|
||||
purpose: 'ticket_item_check',
|
||||
};
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
payload: {
|
||||
request_id: 'request-1',
|
||||
subscription_id: 'subscription-1',
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: 'workers', data: { workers: [snapshotWorker] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.workers.get('runtime-a:job-worker')?.job, snapshotWorker.job);
|
||||
applyWorkspaceWorkersFrame(projection, snapshot('subscription-1', [snapshotWorker]));
|
||||
assertEquals(projection.workers.get(key('runtime-a', 'job-worker'))?.job, snapshotWorker.job);
|
||||
|
||||
const updated = { ...snapshotWorker, subject_revision: 2 };
|
||||
const updated = { ...snapshotWorker };
|
||||
updated.job = { ...snapshotWorker.job, attempt_id: 'check:T-1:r1:attempt:2' };
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: { event: 'worker_upserted', data: { worker: updated } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.workers.get('runtime-a:job-worker')?.job, updated.job);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', { event: 'worker_upserted', data: { worker: updated } }),
|
||||
);
|
||||
assertEquals(projection.workers.get(key('runtime-a', 'job-worker'))?.job, updated.job);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker reducer ignores stale events and removes composite subject', () => {
|
||||
Deno.test('fresh subscription snapshot replaces old state and fences delayed old events', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 3));
|
||||
projection.revisions.set('runtime-a:1', 3);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-old', [worker('runtime-a', 'old'), worker('runtime-b', 'same')]),
|
||||
);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-old', {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', 'before-reconnect') },
|
||||
}),
|
||||
);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: { event: 'worker_upserted', data: { worker: worker('runtime-a', '1', 2) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 3);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-new', [worker('runtime-a', 'current')]),
|
||||
);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-old', {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', 'late-old-event') },
|
||||
}),
|
||||
);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 4,
|
||||
payload: {
|
||||
event: 'worker_removed',
|
||||
data: { worker_id: '1', runtime_id: 'runtime-a' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.workers.size, 0);
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 4);
|
||||
assertEquals(projection.subscriptionId, 'subscription-new');
|
||||
assertEquals([...projection.workers.keys()], [key('runtime-a', 'current')]);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker reducer applies ordered updates and composite removal', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-1', [worker('runtime-a', '1'), worker('runtime-b', '1')]),
|
||||
);
|
||||
|
||||
const updated = worker('runtime-a', '1');
|
||||
updated.state = 'running';
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', { event: 'worker_upserted', data: { worker: updated } }),
|
||||
);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', {
|
||||
event: 'worker_removed',
|
||||
data: { worker_id: '1', runtime_id: 'runtime-a' },
|
||||
}),
|
||||
);
|
||||
|
||||
assertEquals([...projection.workers.keys()], [key('runtime-b', '1')]);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker event before a snapshot is rejected', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
assertThrows(
|
||||
() =>
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', '1') },
|
||||
}),
|
||||
),
|
||||
'event-before-snapshot must be rejected',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('fatal child stop replaces the running-child sidebar projection', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 1, true));
|
||||
projection.revisions.set('runtime-a:1', 1);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-1', [worker('runtime-a', '1', true)]),
|
||||
);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', '1', false) },
|
||||
}),
|
||||
);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', '1', 2, false) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(projection.workers.get('runtime-a:1')?.has_running_internal_workers, false);
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 2);
|
||||
assertEquals(
|
||||
projection.workers.get(key('runtime-a', '1'))?.has_running_internal_workers,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,83 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate } from '#lib/workspace/api/http.ts';
|
||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
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)}`);
|
||||
}
|
||||
|
||||
function pageHref(cursor?: string | null): string {
|
||||
const path = workspaceRoute(data.workspaceId, '/memory');
|
||||
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
||||
}
|
||||
</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>
|
||||
{#if data.cursor}<p><a href={pageHref()}>Return to the first page</a></p>{/if}
|
||||
</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>
|
||||
<nav class="memory-pagination" aria-label="Subject pages">
|
||||
{#if data.cursor}<a href={pageHref()}>First page</a>{/if}
|
||||
{#if data.subjects.data.has_more && data.subjects.data.next_cursor}
|
||||
<a href={pageHref(data.subjects.data.next_cursor)}>Next page →</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
{:else if data.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 +87,168 @@
|
||||
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-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.memory-state p {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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,32 @@
|
||||
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import { parseMemoryDocumentResponse } from "#lib/workspace/memory/api.ts";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivSubjectListResponse,
|
||||
} from "#lib/workspace/memory/api.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const MAX_CURSOR_BYTES = 16_384;
|
||||
|
||||
function boundedCursor(value: string | null): string | null {
|
||||
if (value === null || value.length === 0) return null;
|
||||
return new TextEncoder().encode(value).byteLength <= MAX_CURSOR_BYTES
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
const cursor = boundedCursor(url.searchParams.get("cursor"));
|
||||
const query = new URLSearchParams({ limit: "100" });
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
memory: await loadJson(
|
||||
cursor,
|
||||
subjects: await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/memory"),
|
||||
`${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?${query}`,
|
||||
undefined,
|
||||
parseMemoryDocumentResponse,
|
||||
{
|
||||
diagnosticLabel: "Memory API",
|
||||
maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes,
|
||||
},
|
||||
parseSubjektivSubjectListResponse,
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
<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 { 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 memoryPageHref(cursor?: string | null): string {
|
||||
const path = workspaceRoute(
|
||||
data.workspaceId,
|
||||
`/memory/${encodeURIComponent(data.subjectId)}`,
|
||||
);
|
||||
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
||||
}
|
||||
|
||||
function stateLabel(state: SubjektivMemoryState): string {
|
||||
return state[0].toUpperCase() + state.slice(1);
|
||||
}
|
||||
|
||||
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>
|
||||
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-list" aria-label="Current committed Memories">
|
||||
{#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>
|
||||
<nav class="memory-pagination" aria-label="Current Memory pages">
|
||||
{#if data.cursor}<a href={memoryPageHref()}>First page</a>{/if}
|
||||
{#if data.memories.data.has_more && data.memories.data.next_cursor}
|
||||
<a href={memoryPageHref(data.memories.data.next_cursor)}>Next page →</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
{:else if data.memories.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<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 {
|
||||
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-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.memory-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.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,59 @@
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
parseSubjektivResidentSurfaceResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "#lib/workspace/memory/api.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const MAX_CURSOR_BYTES = 16_384;
|
||||
|
||||
function boundedCursor(value: string | null): string | null {
|
||||
if (value === null || value.length === 0) return null;
|
||||
return new TextEncoder().encode(value).byteLength <= MAX_CURSOR_BYTES
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
const subjectId = params.subjectId;
|
||||
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
|
||||
const cursor = boundedCursor(url.searchParams.get("cursor"));
|
||||
const memoryQuery = new URLSearchParams({ limit: "100" });
|
||||
if (cursor) memoryQuery.set("cursor", cursor);
|
||||
const [subject, surface, memories] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
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`)
|
||||
}?${memoryQuery}`,
|
||||
undefined,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
subjectId,
|
||||
cursor,
|
||||
subject,
|
||||
surface,
|
||||
memories,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,650 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
import type {
|
||||
MemoryEvidenceOrigin,
|
||||
SubjektivMemoryRevisionRef,
|
||||
SubjektivMemorySourceEvidenceRef,
|
||||
} from '#lib/generated/memory-api.ts';
|
||||
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 revisionPageHref(cursor?: string | null): string {
|
||||
const query = new URLSearchParams();
|
||||
if (memory) query.set('revision', String(memory.revision));
|
||||
if (cursor) query.set('revision_cursor', cursor);
|
||||
const encoded = query.toString();
|
||||
return encoded ? `${memoryPath()}?${encoded}` : memoryPath();
|
||||
}
|
||||
|
||||
function derivedHref(reference: SubjektivMemoryRevisionRef): string {
|
||||
return `${memoryPath(reference.memory_id)}?revision=${reference.revision}`;
|
||||
}
|
||||
|
||||
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}
|
||||
<div>
|
||||
<p class="empty-copy">No revision history is available.</p>
|
||||
{#if data.revisionCursor}<p><a href={revisionPageHref()}>Return to the first page</a></p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ol class="revision-list">
|
||||
{#each revisions as revision (revision.revision)}
|
||||
<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>
|
||||
<nav class="memory-pagination" aria-label="Revision history pages">
|
||||
{#if data.revisionCursor}<a href={revisionPageHref()}>First page</a>{/if}
|
||||
{#if data.revisions.data.has_more && data.revisions.data.next_cursor}
|
||||
<a href={revisionPageHref(data.revisions.data.next_cursor)}>Next page →</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
{:else if data.revisions.error}
|
||||
<div class="memory-state is-error" role="alert"><strong>Revision history unavailable.</strong><p>{data.revisions.error}</p></div>
|
||||
{: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;
|
||||
}
|
||||
|
||||
.memory-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.candidate-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.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,95 @@
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivMemoryListRevisionsResponse,
|
||||
parseSubjektivMemoryReadResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "#lib/workspace/memory/api.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/;
|
||||
const MAX_CURSOR_BYTES = 16_384;
|
||||
|
||||
function boundedCursor(
|
||||
value: string | null,
|
||||
maxBytes = MAX_CURSOR_BYTES,
|
||||
): string | null {
|
||||
if (value === null || value.length === 0) return null;
|
||||
return new TextEncoder().encode(value).byteLength <= maxBytes ? value : null;
|
||||
}
|
||||
|
||||
function safeIntegerQuery(
|
||||
source: URLSearchParams,
|
||||
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 = boundedCursor(
|
||||
url.searchParams.get("evidence_cursor"),
|
||||
512,
|
||||
);
|
||||
if (evidenceCursor) detailQuery.set("evidence_cursor", evidenceCursor);
|
||||
const revisionCursor = boundedCursor(url.searchParams.get("revision_cursor"));
|
||||
const revisionQuery = new URLSearchParams({ limit: "100" });
|
||||
if (revisionCursor) revisionQuery.set("cursor", revisionCursor);
|
||||
|
||||
const [subject, memory, revisions] = await Promise.all([
|
||||
loadJson(
|
||||
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`)
|
||||
}?${revisionQuery}`,
|
||||
undefined,
|
||||
(value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
subjectId,
|
||||
memoryId,
|
||||
revisionCursor,
|
||||
subject,
|
||||
memory,
|
||||
revisions,
|
||||
};
|
||||
};
|
||||
@@ -2,75 +2,272 @@
|
||||
|
||||
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",
|
||||
cursor: "subject-current",
|
||||
subjects: result({
|
||||
limit: 100,
|
||||
items: [subject()],
|
||||
next_cursor: "subject-next",
|
||||
has_more: true,
|
||||
}),
|
||||
},
|
||||
} 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.getByRole("link", { name: "First page" }).getAttribute("href"))
|
||||
.toBe("/w/workspace-1/memory");
|
||||
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||
.toContain("cursor=subject-next");
|
||||
expect(screen.queryByText("Staging")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders a ready resident surface and committed Memory lifecycle states", async () => {
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
cursor: "memory-current",
|
||||
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",
|
||||
},
|
||||
],
|
||||
next_cursor: "memory-next",
|
||||
has_more: true,
|
||||
}),
|
||||
},
|
||||
} 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();
|
||||
expect(screen.getByRole("navigation", { name: "Current Memory pages" }))
|
||||
.not.toBeNull();
|
||||
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||
.toContain("cursor=memory-next");
|
||||
});
|
||||
|
||||
test("distinguishes 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",
|
||||
revisionCursor: "revision-current",
|
||||
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",
|
||||
}],
|
||||
next_cursor: "revision-next",
|
||||
has_more: true,
|
||||
}),
|
||||
},
|
||||
} 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();
|
||||
expect(screen.getByRole("navigation", { name: "Revision history pages" }))
|
||||
.not.toBeNull();
|
||||
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||
.toContain("revision_cursor=revision-next");
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
} from "#lib/workspace/console/composer-command.ts";
|
||||
import { FileCompletions } from "#lib/workspace/console/file-completions.ts";
|
||||
import WorkerRunStatus from "#lib/workspace/console/WorkerRunStatus.svelte";
|
||||
import WorkerContextStatus from "#lib/workspace/console/WorkerContextStatus.svelte";
|
||||
import { resolveWorkerControlShortcut } from "#lib/workspace/console/worker-control-shortcuts.ts";
|
||||
import {
|
||||
consoleWorkerViews,
|
||||
@@ -1651,6 +1652,7 @@
|
||||
|
||||
<div class="console-shell worker-console-shell">
|
||||
<section class="console-header card" aria-label="Worker controls">
|
||||
<WorkerContextStatus metadata={selectedConsoleProjection.workerMetadata} />
|
||||
<div class="console-header-actions">
|
||||
<div
|
||||
class="console-view-modes"
|
||||
@@ -2119,8 +2121,9 @@
|
||||
|
||||
.console-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -2133,6 +2136,7 @@
|
||||
|
||||
.console-header-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
@@ -2670,7 +2674,12 @@
|
||||
}
|
||||
|
||||
.console-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.console-header-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,404 @@ 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);
|
||||
const nextPage = {
|
||||
limit: 1,
|
||||
items: [subject()],
|
||||
next_cursor: "subjektiv.subjects.next",
|
||||
has_more: true,
|
||||
};
|
||||
assertEquals(parseSubjektivSubjectListResponse(nextPage), nextPage);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectListResponse({
|
||||
limit: 1,
|
||||
items: [subject()],
|
||||
has_more: true,
|
||||
}),
|
||||
"cursor does not match has_more",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectListResponse({
|
||||
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 +483,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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
readTextFile(path: URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
@@ -88,7 +93,7 @@ Deno.test("workspace creation preserves caller-owned operation key across retry"
|
||||
operation_key: "web-create-1",
|
||||
display_name: "Alpha",
|
||||
repository: {
|
||||
repository_key: "main",
|
||||
repository_key: "platform",
|
||||
uri: "/srv/alpha",
|
||||
default_ref: "develop",
|
||||
},
|
||||
@@ -113,3 +118,27 @@ Deno.test("workspace creation preserves caller-owned operation key across retry"
|
||||
);
|
||||
assertEquals(bodies, [request, request]);
|
||||
});
|
||||
|
||||
Deno.test("workspace creation form starts with an explicit required Repository key", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL("../src/routes/+page.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
source.includes('let repositoryKey = $state("");'),
|
||||
"the form must start without an implicit Repository key",
|
||||
);
|
||||
assert(
|
||||
source.includes("<input bind:value={repositoryKey} required"),
|
||||
"the Browser must require creator input for the Repository key",
|
||||
);
|
||||
assert(
|
||||
source.includes("repository_key: normalized.repositoryKey"),
|
||||
"the Browser must submit the creator-provided Repository key",
|
||||
);
|
||||
assert(
|
||||
!source.includes('repositoryKey || "main"') &&
|
||||
!source.includes("repositoryKey || 'main'"),
|
||||
"the Browser must not fall back to main",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user