feat: unify Memory REST DTO authority

This commit is contained in:
2026-09-03 15:54:48 +09:00
parent 8022128993
commit 5cec2eef60
15 changed files with 1232 additions and 162 deletions
@@ -0,0 +1,100 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts
export type DiagnosticSeverity = "info" | "warning" | "error";
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 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 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 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 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 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>;
};
@@ -0,0 +1,392 @@
import type {
Diagnostic,
DiagnosticSeverity,
MemoryCandidateKind,
MemoryDocumentResponse,
MemoryEvidenceOrigin,
MemoryEvidenceOriginKind,
MemorySourceEvidenceRef,
MemorySourceRef,
MemoryStagingEntry,
MemoryStagingEvidence,
MemoryStagingListResponse,
MemoryStagingRecord,
} from "$lib/generated/memory-api";
const MAX_STAGING_ITEMS = 500;
const MAX_EVIDENCE_PER_RECORD = 500;
const MAX_SOURCE_REFS_PER_RECORD = 500;
const MAX_ORIGIN_VALUE_LENGTH = 512;
const candidateKinds = new Set<MemoryCandidateKind>([
"preference",
"working_assumption",
"constraint",
"decision",
"open_question",
"lesson",
]);
const originKinds = new Set<MemoryEvidenceOriginKind>([
"human_input",
"worker_input",
"flow_instruction",
"backend_instruction",
"model_output",
"tool_output",
"derived_summary",
"legacy_unknown",
]);
const diagnosticSeverities = new Set<DiagnosticSeverity>([
"info",
"warning",
"error",
]);
export function parseMemoryDocumentResponse(
value: unknown,
): MemoryDocumentResponse {
const record = strictRecord(
value,
["body_md", "created_at", "updated_at", "bytes", "record_source"],
"Memory document response",
);
return {
body_md: requiredString(record, "body_md"),
created_at: requiredString(record, "created_at"),
updated_at: requiredString(record, "updated_at"),
bytes: requiredNonNegativeInteger(record, "bytes"),
record_source: requiredString(record, "record_source"),
};
}
export function parseMemoryStagingListResponse(
value: unknown,
): MemoryStagingListResponse {
const record = strictRecord(
value,
[
"limit",
"returned_count",
"total_valid_count",
"invalid_count",
"truncated",
"order",
"record_authority",
"items",
"diagnostics",
],
"Memory staging list response",
);
const items = boundedArray(record.items, MAX_STAGING_ITEMS, "items").map(
parseStagingEntry,
);
const diagnostics = boundedArray(
record.diagnostics,
MAX_STAGING_ITEMS,
"diagnostics",
).map(parseDiagnostic);
const returnedCount = requiredNonNegativeInteger(record, "returned_count");
if (returnedCount !== items.length) {
invalid("returned_count does not match items");
}
return {
limit: requiredNonNegativeInteger(record, "limit"),
returned_count: returnedCount,
total_valid_count: requiredNonNegativeInteger(record, "total_valid_count"),
invalid_count: requiredNonNegativeInteger(record, "invalid_count"),
truncated: requiredBoolean(record, "truncated"),
order: requiredString(record, "order"),
record_authority: requiredString(record, "record_authority"),
items,
diagnostics,
};
}
function parseStagingEntry(value: unknown): MemoryStagingEntry {
const record = strictRecord(
value,
["id", "byte_len", "record"],
"Memory staging entry",
);
return {
id: requiredString(record, "id"),
byte_len: requiredNonNegativeInteger(record, "byte_len"),
record: parseStagingRecord(record.record),
};
}
function parseStagingRecord(value: unknown): MemoryStagingRecord {
const record = strictRecord(
value,
[
"schema_version",
"id",
"extract_run_id",
"source",
"kind",
"claim",
"why_useful",
"staleness",
"evidence",
"source_refs",
],
"Memory staging record",
);
const kind = requiredString(record, "kind") as MemoryCandidateKind;
if (!candidateKinds.has(kind)) {
invalid("unknown Memory candidate kind");
}
return {
schema_version: requiredNonNegativeInteger(record, "schema_version"),
id: requiredString(record, "id"),
extract_run_id: requiredString(record, "extract_run_id"),
source: parseSourceRef(record.source),
kind,
claim: requiredString(record, "claim"),
why_useful: requiredString(record, "why_useful"),
staleness: nullableString(record, "staleness"),
evidence: boundedArray(
record.evidence,
MAX_EVIDENCE_PER_RECORD,
"evidence",
).map(parseStagingEvidence),
source_refs: boundedArray(
record.source_refs,
MAX_SOURCE_REFS_PER_RECORD,
"source_refs",
).map(parseSourceEvidenceRef),
};
}
function parseSourceRef(value: unknown): MemorySourceRef {
const record = strictRecord(
value,
["segment_id", "range"],
"Memory source ref",
);
return {
segment_id: requiredString(record, "segment_id"),
range: parseEntryRange(record.range, "range"),
};
}
function parseStagingEvidence(value: unknown): MemoryStagingEvidence {
const record = strictRecord(
value,
["id", "kind", "entry_range", "origin", "excerpt", "summary"],
"Memory staging evidence",
["origin"],
);
const result: MemoryStagingEvidence = {
id: requiredString(record, "id"),
kind: requiredString(record, "kind"),
entry_range: parseNullableEntryRange(record.entry_range, "entry_range"),
excerpt: nullableString(record, "excerpt"),
summary: nullableString(record, "summary"),
};
if ("origin" in record) {
result.origin = record.origin === null
? null
: parseEvidenceOrigin(record.origin);
}
return result;
}
function parseSourceEvidenceRef(value: unknown): MemorySourceEvidenceRef {
const record = strictRecord(
value,
[
"session_id",
"segment_id",
"entry_range",
"evidence_id",
"origin",
"evidence_kind",
"label",
"summary",
],
"Memory source evidence ref",
["origin"],
);
const result: MemorySourceEvidenceRef = {
session_id: nullableString(record, "session_id"),
segment_id: nullableString(record, "segment_id"),
entry_range: parseNullableEntryRange(record.entry_range, "entry_range"),
evidence_id: nullableString(record, "evidence_id"),
evidence_kind: nullableString(record, "evidence_kind"),
label: nullableString(record, "label"),
summary: nullableString(record, "summary"),
};
if ("origin" in record) {
result.origin = record.origin === null
? null
: parseEvidenceOrigin(record.origin);
}
return result;
}
function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin {
const optional = [
"account_id",
"workspace_id",
"runtime_id",
"worker_id",
"flow_selector",
"flow_definition_id",
"flow_definition_revision",
] as const;
const record = strictRecord(
value,
["kind", ...optional],
"Memory evidence origin",
[...optional],
);
const kind = requiredString(record, "kind") as MemoryEvidenceOriginKind;
if (!originKinds.has(kind)) {
invalid("unknown Memory evidence origin kind");
}
const result: MemoryEvidenceOrigin = { kind };
for (
const key of [
"account_id",
"workspace_id",
"runtime_id",
"worker_id",
"flow_selector",
"flow_definition_id",
] as const
) {
if (key in record) {
const text = nullableString(record, key);
if (text !== null && text.length > MAX_ORIGIN_VALUE_LENGTH) {
invalid(`${key} exceeds the Memory origin limit`);
}
result[key] = text;
}
}
if ("flow_definition_revision" in record) {
result.flow_definition_revision = record.flow_definition_revision === null
? null
: nonNegativeInteger(
record.flow_definition_revision,
"flow_definition_revision",
);
}
return result;
}
function parseDiagnostic(value: unknown): Diagnostic {
const record = strictRecord(
value,
["code", "severity", "message"],
"Memory diagnostic",
);
const severity = requiredString(record, "severity") as DiagnosticSeverity;
if (!diagnosticSeverities.has(severity)) {
invalid("unknown diagnostic severity");
}
return {
code: requiredString(record, "code"),
severity,
message: requiredString(record, "message"),
};
}
function strictRecord(
value: unknown,
keys: readonly string[],
label: string,
optionalKeys: readonly string[] = [],
): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
invalid(`${label} must be an object`);
}
const record = value as Record<string, unknown>;
const allowed = new Set(keys);
for (const key of Object.keys(record)) {
if (!allowed.has(key)) {
invalid(`${label} has an unknown field`);
}
}
const optional = new Set(optionalKeys);
for (const key of keys) {
if (!optional.has(key) && !(key in record)) {
invalid(`${label} is missing a required field`);
}
}
return record;
}
function boundedArray(
value: unknown,
maximum: number,
label: string,
): unknown[] {
if (!Array.isArray(value) || value.length > maximum) {
invalid(`${label} must be a bounded array`);
}
return value;
}
function requiredString(record: Record<string, unknown>, key: string): string {
if (typeof record[key] !== "string") {
invalid(`${key} must be a string`);
}
return record[key];
}
function nullableString(
record: Record<string, unknown>,
key: string,
): string | null {
const value = record[key];
if (value !== null && typeof value !== "string") {
invalid(`${key} must be a string or null`);
}
return value;
}
function requiredBoolean(
record: Record<string, unknown>,
key: string,
): boolean {
if (typeof record[key] !== "boolean") {
invalid(`${key} must be a boolean`);
}
return record[key];
}
function requiredNonNegativeInteger(
record: Record<string, unknown>,
key: string,
): number {
return nonNegativeInteger(record[key], 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`);
}
return value as number;
}
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),
];
}
function parseNullableEntryRange(
value: unknown,
label: string,
): [number, number] | null {
return value === null ? null : parseEntryRange(value, label);
}
function invalid(message: string): never {
throw new Error(`Invalid Memory API response: ${message}`);
}
@@ -206,75 +206,6 @@ export type RepositoryListResponse = SharedRepositoryListResponse;
export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
export type RepositoryLogResponse = SharedRepositoryLogResponse;
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 MemorySourceRef = {
segment_id: string;
range: [number, number];
};
export type MemoryStagingEvidence = {
id: string;
kind: string;
entry_range?: [number, number] | 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;
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?: MemoryStagingEvidence[];
source_refs?: MemorySourceEvidenceRef[];
};
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: MemoryStagingEntry[];
};
export type {
DerivedTicketRelation,
TicketDetail,
@@ -1,13 +1,15 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { MemoryDocumentResponse } from "$lib/workspace/sidebar/types";
import { parseMemoryDocumentResponse } from "$lib/workspace/memory/api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
return {
workspaceId: params.workspaceId,
memory: await loadJson<MemoryDocumentResponse>(
memory: await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/memory"),
undefined,
parseMemoryDocumentResponse,
),
};
};
@@ -1,5 +1,5 @@
<script lang="ts">
import type { MemoryStagingEntry, MemoryStagingRecord } from '$lib/workspace/sidebar/types';
import type { MemoryStagingEntry, MemoryStagingRecord } from '$lib/generated/memory-api';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -68,6 +68,12 @@
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
{/if}
{#each data.staging.data.diagnostics as diagnostic (diagnostic.code)}
<p class:error={diagnostic.severity === 'error'} class="section-note">
{diagnostic.message}
</p>
{/each}
{#if entries.length === 0}
<p>No Memory Staging records are present.</p>
{:else}
@@ -1,13 +1,15 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { MemoryStagingListResponse } from "$lib/workspace/sidebar/types";
import { parseMemoryStagingListResponse } from "$lib/workspace/memory/api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
return {
workspaceId: params.workspaceId,
staging: await loadJson<MemoryStagingListResponse>(
staging: await loadJson(
fetch,
`${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`,
undefined,
parseMemoryStagingListResponse,
),
};
};
+166
View File
@@ -0,0 +1,166 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
parseMemoryDocumentResponse,
parseMemoryStagingListResponse,
} from "../src/lib/workspace/memory/api.ts";
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)}`,
);
}
}
function assertThrows(fn: () => void, expectedMessage: string): void {
try {
fn();
} catch (error) {
if (error instanceof Error && error.message.includes(expectedMessage)) {
return;
}
throw error;
}
throw new Error(`expected function to throw ${expectedMessage}`);
}
function fixture(origin: Record<string, unknown>) {
return {
limit: 100,
returned_count: 1,
total_valid_count: 1,
invalid_count: 0,
truncated: false,
order: "imported_at_desc_candidate_id_asc",
record_authority: "sqlite_workspace_authority.memory_staging",
items: [{
id: "candidate-1",
byte_len: 128,
record: {
schema_version: 2,
id: "candidate-1",
extract_run_id: "extract-run-1",
source: { segment_id: "segment-1", range: [10, 20] },
kind: "decision",
claim: "Keep provenance typed.",
why_useful: "Prevents origin loss.",
staleness: null,
evidence: [{
id: "evidence-1",
kind: "message",
entry_range: [10, 10],
origin,
excerpt: null,
summary: "bounded summary",
}],
source_refs: [{
session_id: "session-1",
segment_id: "segment-1",
entry_range: [10, 10],
evidence_id: "evidence-1",
origin,
evidence_kind: "message",
label: "source",
summary: null,
}],
},
}],
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",
);
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",
);
});