Add persistent Subject behavior context
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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
|
||||
// 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
|
||||
|
||||
@@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = {
|
||||
maxIdentifierBytes: 512,
|
||||
} as const;
|
||||
|
||||
export const SUBJEKTIV_API_LIMITS = {
|
||||
maxBehaviorBytes: 16384,
|
||||
} as const;
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||
@@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number,
|
||||
|
||||
export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, };
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
|
||||
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 SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, 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, };
|
||||
|
||||
|
||||
@@ -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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
|
||||
// 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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
|
||||
// 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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
|
||||
import {
|
||||
MEMORY_API_LIMITS,
|
||||
SUBJEKTIV_API_LIMITS,
|
||||
} from "#lib/generated/memory-api.ts";
|
||||
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import type {
|
||||
Diagnostic,
|
||||
@@ -26,6 +29,7 @@ import type {
|
||||
SubjektivResidentSurfaceAvailability,
|
||||
SubjektivResidentSurfaceResponse,
|
||||
SubjektivResidentSurfaceSnapshot,
|
||||
SubjektivSubjectBehaviorUpdateRequest,
|
||||
SubjektivSubjectCreateRequest,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
|
||||
} as const;
|
||||
|
||||
const MAX_SUBJECT_ROLE_BYTES = 256;
|
||||
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
|
||||
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
|
||||
|
||||
export type SubjektivSubjectCreateErrorKind =
|
||||
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
|
||||
"Subject role must be at most 256 bytes and contain no control characters.",
|
||||
);
|
||||
}
|
||||
return { role: request.role };
|
||||
const behaviorMd = request.behavior_md ?? "";
|
||||
validateSubjectBehavior(behaviorMd);
|
||||
return { role: request.role, behavior_md: behaviorMd };
|
||||
}
|
||||
|
||||
function validateSubjectBehavior(behaviorMd: string): void {
|
||||
const bytes = new TextEncoder().encode(behaviorMd).byteLength;
|
||||
if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
`Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`,
|
||||
);
|
||||
}
|
||||
if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
"Subject behavior must be empty or contain non-whitespace text.",
|
||||
);
|
||||
}
|
||||
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
"Subject behavior contains an unsupported control character.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSubjektivSubject(
|
||||
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
|
||||
const subject = parseSubjektivSubjectResponse(
|
||||
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
||||
);
|
||||
if (subject.role !== validated.role) {
|
||||
throw new Error("created Subject role does not match the request");
|
||||
if (
|
||||
subject.role !== validated.role ||
|
||||
subject.behavior_md !== validated.behavior_md
|
||||
) {
|
||||
throw new Error("created Subject does not match the request");
|
||||
}
|
||||
return subject;
|
||||
} catch {
|
||||
@@ -182,6 +214,68 @@ export async function createSubjektivSubject(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSubjektivSubjectBehavior(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
subjectId: string,
|
||||
request: SubjektivSubjectBehaviorUpdateRequest,
|
||||
): Promise<SubjektivSubjectResponse> {
|
||||
validateSubjectBehavior(request.behavior_md);
|
||||
if (
|
||||
!Number.isSafeInteger(request.expected_behavior_revision) ||
|
||||
request.expected_behavior_revision < 0
|
||||
) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
"Subject behavior revision is invalid.",
|
||||
);
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/behavior`,
|
||||
),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"unknown_outcome",
|
||||
"The save outcome is unknown. Reload the Subject before retrying.",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = await subjektivSubjectCreateErrorMessage(response);
|
||||
throw new SubjektivSubjectCreateError(
|
||||
response.status >= 500 ? "unknown_outcome" : "rejected",
|
||||
response.status === 409
|
||||
? "The behavior changed elsewhere. Reload the Subject and review the latest text."
|
||||
: message,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const subject = parseSubjektivSubjectResponse(
|
||||
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
||||
subjectId,
|
||||
);
|
||||
if (subject.behavior_md !== request.behavior_md) {
|
||||
throw new Error("saved Subject behavior does not match the request");
|
||||
}
|
||||
return subject;
|
||||
} catch {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"unknown_outcome",
|
||||
"The behavior may have been saved, but the response could not be confirmed. Reload the Subject.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function subjektivSubjectCreateErrorMessage(
|
||||
response: Response,
|
||||
): Promise<string> {
|
||||
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
|
||||
} catch {
|
||||
// A rejected request still has a known outcome even when its body is invalid.
|
||||
}
|
||||
return `Subject creation failed with HTTP ${response.status}.`;
|
||||
return `Subject request failed with HTTP ${response.status}.`;
|
||||
}
|
||||
|
||||
export function parseMemoryDocumentResponse(
|
||||
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
|
||||
[
|
||||
"id",
|
||||
"role",
|
||||
"behavior_md",
|
||||
"behavior_revision",
|
||||
"state",
|
||||
"store_revision",
|
||||
"created_at",
|
||||
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
|
||||
const result: SubjektivSubjectResponse = {
|
||||
id,
|
||||
role: requiredString(record, "role"),
|
||||
behavior_md: requiredString(
|
||||
record,
|
||||
"behavior_md",
|
||||
MAX_SUBJECT_BEHAVIOR_BYTES,
|
||||
),
|
||||
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
|
||||
state,
|
||||
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
|
||||
@@ -14,13 +14,16 @@
|
||||
const subjects = $derived(data.subjects.data?.items ?? []);
|
||||
let createExpanded = $state(false);
|
||||
let role = $state('');
|
||||
let behaviorMd = $state('');
|
||||
let submitting = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
let roleInvalid = $state(false);
|
||||
let behaviorInvalid = $state(false);
|
||||
let createOutcomeUnknown = $state(false);
|
||||
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
||||
let createButton = $state<HTMLButtonElement>();
|
||||
let roleInput = $state<HTMLInputElement>();
|
||||
let behaviorInput = $state<HTMLTextAreaElement>();
|
||||
|
||||
function subjectHref(subjectId: string): string {
|
||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
||||
@@ -35,6 +38,7 @@
|
||||
createExpanded = true;
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
createdSubject = null;
|
||||
await tick();
|
||||
@@ -45,8 +49,10 @@
|
||||
if (submitting) return;
|
||||
createExpanded = false;
|
||||
role = '';
|
||||
behaviorMd = '';
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
createdSubject = null;
|
||||
await tick();
|
||||
@@ -58,9 +64,13 @@
|
||||
submitting = true;
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
try {
|
||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
|
||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
|
||||
role,
|
||||
behavior_md: behaviorMd,
|
||||
});
|
||||
createdSubject = subject;
|
||||
try {
|
||||
await goto(subjectHref(subject.id));
|
||||
@@ -69,14 +79,16 @@
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SubjektivSubjectCreateError) {
|
||||
roleInvalid = error.kind === 'validation';
|
||||
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
|
||||
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
|
||||
createOutcomeUnknown = error.kind === 'unknown_outcome';
|
||||
createError = error.status === 401 || error.status === 403
|
||||
? 'You do not have permission to create Subjects in this Workspace.'
|
||||
: error.message;
|
||||
if (roleInvalid) {
|
||||
if (roleInvalid || behaviorInvalid) {
|
||||
await tick();
|
||||
roleInput?.focus();
|
||||
if (behaviorInvalid) behaviorInput?.focus();
|
||||
else roleInput?.focus();
|
||||
}
|
||||
} else {
|
||||
createOutcomeUnknown = true;
|
||||
@@ -145,9 +157,22 @@
|
||||
disabled={submitting || createdSubject !== null}
|
||||
onkeydown={createFormKeydown}
|
||||
aria-invalid={roleInvalid ? 'true' : undefined}
|
||||
aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
||||
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
||||
/>
|
||||
</label>
|
||||
<label class="subject-role-field">
|
||||
<span>Behavior <small>Optional, user-managed</small></span>
|
||||
<textarea
|
||||
bind:this={behaviorInput}
|
||||
bind:value={behaviorMd}
|
||||
rows="7"
|
||||
disabled={submitting || createdSubject !== null}
|
||||
onkeydown={createFormKeydown}
|
||||
aria-invalid={behaviorInvalid ? 'true' : undefined}
|
||||
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
|
||||
></textarea>
|
||||
<small id="subject-behavior-help">Persistent guidance injected verbatim for this Subject. It remains separate from generated Memory and can be edited later.</small>
|
||||
</label>
|
||||
{#if createError}
|
||||
<div
|
||||
id="subject-create-error"
|
||||
@@ -309,20 +334,25 @@
|
||||
}
|
||||
|
||||
.subject-create-actions {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.subject-create-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
|
||||
align-items: end;
|
||||
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
|
||||
align-items: start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-soft);
|
||||
}
|
||||
|
||||
.subject-create-copy {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 4;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.subject-create-copy h2,
|
||||
.subject-create-copy p,
|
||||
.subject-create-error p {
|
||||
@@ -344,7 +374,8 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subject-role-field input {
|
||||
.subject-role-field input,
|
||||
.subject-role-field textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
@@ -354,7 +385,15 @@
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.subject-role-field input[aria-invalid='true'] {
|
||||
.subject-role-field textarea {
|
||||
min-height: 8rem;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.subject-role-field input[aria-invalid='true'],
|
||||
.subject-role-field textarea[aria-invalid='true'] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -480,6 +519,7 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.subject-create-copy,
|
||||
.subject-create-error,
|
||||
.subject-create-actions {
|
||||
grid-column: 1;
|
||||
|
||||
@@ -1,11 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
import {
|
||||
SubjektivSubjectCreateError,
|
||||
updateSubjektivSubjectBehavior,
|
||||
} from '#lib/workspace/memory/api.ts';
|
||||
import type { SubjektivMemoryState } from '#lib/generated/memory-api.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let subject = $state(untrack(() => data.subject.data));
|
||||
let editingBehavior = $state(false);
|
||||
let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? ''));
|
||||
let behaviorSaving = $state(false);
|
||||
let behaviorError = $state<string | null>(null);
|
||||
let behaviorSaveConfirmed = $state(false);
|
||||
|
||||
async function saveBehavior(): Promise<void> {
|
||||
if (!subject || behaviorSaving) return;
|
||||
behaviorSaving = true;
|
||||
behaviorError = null;
|
||||
behaviorSaveConfirmed = false;
|
||||
try {
|
||||
subject = await updateSubjektivSubjectBehavior(fetch, data.workspaceId, data.subjectId, {
|
||||
expected_behavior_revision: subject.behavior_revision,
|
||||
behavior_md: behaviorDraft,
|
||||
});
|
||||
behaviorDraft = subject.behavior_md;
|
||||
editingBehavior = false;
|
||||
behaviorSaveConfirmed = true;
|
||||
} catch (error) {
|
||||
behaviorError = error instanceof SubjektivSubjectCreateError
|
||||
? (error.status === 401 || error.status === 403
|
||||
? 'You do not have permission to edit this Subject.'
|
||||
: error.message)
|
||||
: 'The save outcome is unknown. Reload the Subject before retrying.';
|
||||
} finally {
|
||||
behaviorSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function beginBehaviorEdit(): void {
|
||||
if (!subject) return;
|
||||
behaviorDraft = subject.behavior_md;
|
||||
behaviorError = null;
|
||||
behaviorSaveConfirmed = false;
|
||||
editingBehavior = true;
|
||||
}
|
||||
|
||||
function cancelBehaviorEdit(): void {
|
||||
behaviorDraft = subject?.behavior_md ?? '';
|
||||
behaviorError = null;
|
||||
editingBehavior = false;
|
||||
}
|
||||
|
||||
const surface = $derived(data.surface.data);
|
||||
const snapshot = $derived(surface?.snapshot ?? null);
|
||||
const memories = $derived(data.memories.data?.items ?? []);
|
||||
@@ -39,7 +89,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
|
||||
<title>{subject?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Subject resident surface and current committed Memories" />
|
||||
</svelte:head>
|
||||
|
||||
@@ -49,19 +99,19 @@
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Memory subject</p>
|
||||
<h1 id="subject-heading">{data.subject.data?.role ?? data.subjectId}</h1>
|
||||
<h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
|
||||
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
|
||||
</div>
|
||||
<span class="read-only-label">Read-only</span>
|
||||
<span class="read-only-label">Behavior editable</span>
|
||||
</header>
|
||||
|
||||
{#if data.subject.data}
|
||||
{#if subject}
|
||||
<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>
|
||||
<div><dt>Role</dt><dd>{subject.role}</dd></div>
|
||||
<div><dt>State</dt><dd><span class="subject-state is-{subject.state}">{subject.state}</span></dd></div>
|
||||
<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>Current worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
|
||||
</dl>
|
||||
{:else if data.subject.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
@@ -72,6 +122,40 @@
|
||||
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
|
||||
{/if}
|
||||
|
||||
{#if subject}
|
||||
<section class="behavior-section" aria-labelledby="subject-behavior-heading">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
<p class="memory-eyebrow">User-managed context</p>
|
||||
<h2 id="subject-behavior-heading">Behavior</h2>
|
||||
</div>
|
||||
{#if !editingBehavior}
|
||||
<button type="button" class="behavior-edit" onclick={beginBehaviorEdit}>Edit</button>
|
||||
{/if}
|
||||
</header>
|
||||
<p class="behavior-help">The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest revision before later model requests; this page does not report that application.</p>
|
||||
{#if editingBehavior}
|
||||
<form class="behavior-form" aria-busy={behaviorSaving} onsubmit={(event) => { event.preventDefault(); void saveBehavior(); }}>
|
||||
<label>
|
||||
<span class="sr-only">Subject behavior</span>
|
||||
<textarea bind:value={behaviorDraft} rows="9" disabled={behaviorSaving} aria-invalid={behaviorError ? 'true' : undefined} aria-describedby={behaviorError ? 'subject-behavior-edit-error' : undefined}></textarea>
|
||||
</label>
|
||||
{#if behaviorError}<p id="subject-behavior-edit-error" class="behavior-error" role="alert">{behaviorError}</p>{/if}
|
||||
<div class="behavior-actions">
|
||||
<button type="submit" disabled={behaviorSaving}>{behaviorSaving ? 'Saving…' : 'Save behavior'}</button>
|
||||
<button type="button" disabled={behaviorSaving} onclick={cancelBehaviorEdit}>Cancel</button>
|
||||
<button type="button" disabled={behaviorSaving || behaviorDraft.length === 0} onclick={() => { behaviorDraft = ''; }}>Clear</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if subject.behavior_md.length > 0}
|
||||
<article class="behavior-document" aria-label="User-managed Subject behavior"><pre>{subject.behavior_md}</pre></article>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><strong>No behavior is set.</strong><p>Connected Workers receive an explicit empty behavior document.</p></div>
|
||||
{/if}
|
||||
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed at revision {subject.behavior_revision}. This page does not confirm application by a connected Worker.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
@@ -320,6 +404,7 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.behavior-section,
|
||||
.surface-section,
|
||||
.memories-section {
|
||||
display: grid;
|
||||
@@ -328,6 +413,78 @@
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.behavior-help,
|
||||
.behavior-saved,
|
||||
.behavior-error {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.behavior-error { color: var(--danger); }
|
||||
.behavior-saved { color: var(--success); }
|
||||
|
||||
.behavior-form,
|
||||
.behavior-form label {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.behavior-form textarea {
|
||||
width: 100%;
|
||||
min-height: 10rem;
|
||||
padding: var(--space-3);
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.behavior-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.behavior-edit,
|
||||
.behavior-actions button {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.behavior-document pre {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.surface-meta,
|
||||
.pill-row {
|
||||
display: flex;
|
||||
|
||||
@@ -34,6 +34,8 @@ function subject() {
|
||||
return {
|
||||
id: "subject-1",
|
||||
role: "Release coordinator",
|
||||
behavior_md: "Prefer explicit evidence.",
|
||||
behavior_revision: 2,
|
||||
state: "active" as const,
|
||||
store_revision: 9,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
@@ -96,6 +98,12 @@ test("creates one Subject with the exact role and navigates to its generated det
|
||||
}) as HTMLInputElement;
|
||||
await waitFor(() => expect(document.activeElement).toBe(roleInput));
|
||||
await fireEvent.input(roleInput, { target: { value: " Review lead " } });
|
||||
const behaviorInput = screen.getByRole("textbox", {
|
||||
name: /Behavior/,
|
||||
}) as HTMLTextAreaElement;
|
||||
await fireEvent.input(behaviorInput, {
|
||||
target: { value: "Challenge unsupported assumptions." },
|
||||
});
|
||||
|
||||
const submit = screen.getByRole("button", { name: "Create Subject" });
|
||||
await fireEvent.click(submit);
|
||||
@@ -110,6 +118,8 @@ test("creates one Subject with the exact role and navigates to its generated det
|
||||
resolveRequest(Response.json({
|
||||
id: "generated-subject-1",
|
||||
role: " Review lead ",
|
||||
behavior_md: "Challenge unsupported assumptions.",
|
||||
behavior_revision: 0,
|
||||
state: "active",
|
||||
store_revision: 0,
|
||||
created_at: "2026-01-03T00:00:00Z",
|
||||
@@ -127,7 +137,9 @@ test("creates one Subject with the exact role and navigates to its generated det
|
||||
];
|
||||
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe('{"role":" Review lead "}');
|
||||
expect(init.body).toBe(
|
||||
'{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}',
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps creation available for an empty list and restores focus on cancel", async () => {
|
||||
@@ -146,7 +158,7 @@ test("keeps creation available for an empty list and restores focus on cancel",
|
||||
expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull();
|
||||
});
|
||||
|
||||
test("preserves invalid and rejected role drafts with accessible errors", async () => {
|
||||
test("preserves invalid and rejected Subject drafts with accessible field errors", async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.resolve(Response.json(
|
||||
{
|
||||
@@ -172,6 +184,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await fireEvent.input(input, { target: { value: "Release reviewer" } });
|
||||
const behaviorInput = screen.getByRole("textbox", {
|
||||
name: /Behavior/,
|
||||
}) as HTMLTextAreaElement;
|
||||
await fireEvent.input(behaviorInput, { target: { value: " " } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"empty or contain non-whitespace",
|
||||
);
|
||||
expect(behaviorInput.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await fireEvent.input(behaviorInput, {
|
||||
target: { value: "Challenge unsupported assumptions." },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
@@ -267,6 +293,11 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
|
||||
.not.toBeNull();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
)
|
||||
.toContain("Prefer explicit evidence.");
|
||||
expect(screen.getByRole("article", { name: "Resident surface" })).not
|
||||
.toBeNull();
|
||||
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
|
||||
@@ -280,6 +311,69 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
.toContain("cursor=memory-next");
|
||||
});
|
||||
|
||||
test("edits and clears user-managed behavior with CAS while distinguishing save from Worker application", async () => {
|
||||
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
|
||||
const request = JSON.parse(String(init?.body));
|
||||
return Response.json({
|
||||
...subject(),
|
||||
behavior_md: request.behavior_md,
|
||||
behavior_revision: request.expected_behavior_revision + 1,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
subject: result(subject()),
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ungenerated" as const,
|
||||
}),
|
||||
memories: result({ items: [], has_more: false }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
|
||||
await fireEvent.input(textarea, {
|
||||
target: { value: "Ask before irreversible actions." },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const [path, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects/subject-1/behavior");
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(init.body).toBe(
|
||||
'{"expected_behavior_revision":2,"behavior_md":"Ask before irreversible actions."}',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Behavior storage confirmed at revision 3/).textContent,
|
||||
)
|
||||
.toContain("does not confirm application");
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
)
|
||||
.toContain("Ask before irreversible actions.");
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
const secondBody =
|
||||
(fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body;
|
||||
expect(secondBody).toBe('{"expected_behavior_revision":3,"behavior_md":""}');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No behavior is set.")).not.toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||
const base = {
|
||||
workspaceId: "workspace-1",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseSubjektivSubjectListResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
SubjektivSubjectCreateError,
|
||||
updateSubjektivSubjectBehavior,
|
||||
validateSubjektivSubjectCreateRequest,
|
||||
} from "../src/lib/workspace/memory/api.ts";
|
||||
|
||||
@@ -75,6 +76,8 @@ function subject(id = "subject-1") {
|
||||
return {
|
||||
id,
|
||||
role: "Release coordinator",
|
||||
behavior_md: "Prefer explicit evidence.",
|
||||
behavior_revision: 2,
|
||||
state: "active",
|
||||
store_revision: 12,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
@@ -228,7 +231,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
||||
assertEquals(
|
||||
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
||||
{ role: " Release coordinator " },
|
||||
{ role: " Release coordinator ", behavior_md: "" },
|
||||
);
|
||||
assertThrows(
|
||||
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
||||
@@ -242,6 +245,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
|
||||
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
||||
"at most 256 bytes",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
validateSubjektivSubjectCreateRequest({
|
||||
role: "Reviewer",
|
||||
behavior_md: " ".repeat(3),
|
||||
}),
|
||||
"empty or contain non-whitespace",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
validateSubjektivSubjectCreateRequest({
|
||||
role: "Reviewer",
|
||||
behavior_md: "x".repeat(16 * 1024 + 1),
|
||||
}),
|
||||
"at most 16384 bytes",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
|
||||
const requests: Array<{ path: string; init?: RequestInit }> = [];
|
||||
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({ path: String(path), init });
|
||||
return Response.json({
|
||||
...subject("subject/one"),
|
||||
behavior_md: "Line one.\nLine two.",
|
||||
behavior_revision: 3,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const updated = await updateSubjektivSubjectBehavior(
|
||||
fetchFn,
|
||||
"workspace one",
|
||||
"subject/one",
|
||||
{ expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." },
|
||||
);
|
||||
assertEquals(updated.behavior_revision, 3);
|
||||
assertEquals(updated.behavior_md, "Line one.\nLine two.");
|
||||
assertEquals(
|
||||
requests[0].path,
|
||||
"/api/w/workspace%20one/subjektiv/subjects/subject%2Fone/behavior",
|
||||
);
|
||||
assertEquals(requests[0].init?.method, "PATCH");
|
||||
assertEquals(
|
||||
requests[0].init?.body,
|
||||
'{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject behavior update reports conflicts without claiming Worker application", async () => {
|
||||
const conflictFetch =
|
||||
(async () =>
|
||||
Response.json({ message: "revision conflict" }, {
|
||||
status: 409,
|
||||
})) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", {
|
||||
expected_behavior_revision: 1,
|
||||
behavior_md: "new",
|
||||
}),
|
||||
"rejected",
|
||||
"changed elsewhere",
|
||||
409,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => {
|
||||
@@ -251,20 +318,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the
|
||||
return Response.json({
|
||||
...subject("subject-created"),
|
||||
role: " Release coordinator ",
|
||||
behavior_md: "Stay concise.",
|
||||
}, { status: 201 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
||||
role: " Release coordinator ",
|
||||
behavior_md: "Stay concise.",
|
||||
});
|
||||
assertEquals(created, {
|
||||
...subject("subject-created"),
|
||||
role: " Release coordinator ",
|
||||
behavior_md: "Stay concise.",
|
||||
});
|
||||
assertEquals(requests.length, 1);
|
||||
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
|
||||
assertEquals(requests[0].init?.method, "POST");
|
||||
assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}');
|
||||
assertEquals(
|
||||
requests[0].init?.body,
|
||||
'{"role":" Release coordinator ","behavior_md":"Stay concise."}',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {
|
||||
|
||||
Reference in New Issue
Block a user