fix: align Companion REST DTO contracts

This commit is contained in:
2026-09-01 01:29:04 +09:00
parent 9756174676
commit a9ad42a970
10 changed files with 863 additions and 211 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -0,0 +1,64 @@
// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.
// Do not edit manually.
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
};
export type WorkspaceWorkerSubject = {
"kind": "runtime_worker";
runtime_id: string;
worker_id: string;
};
export type WorkspaceWorkerDiscoveryItem = {
subject: WorkspaceWorkerSubject;
resource_key: string;
display_name: string;
profile: string | null;
status?: string | null;
};
export type CompanionLifecycleState = "idle" | "running" | "stopped";
export type CompanionMessageDisposition = "accepted" | "rejected";
export type CompanionTransportSummary = { mode: string; available: boolean };
export type CompanionStatusResponse = {
state: CompanionLifecycleState;
worker: WorkspaceWorkerDiscoveryItem | null;
transport: CompanionTransportSummary;
diagnostics: Array<Diagnostic>;
};
export type CompanionMessageRequest = { content: string };
export type CompanionCancelRequest = { reason?: string | null };
export type CompanionMessageResponse = {
state: CompanionMessageDisposition;
message: string;
};
export type CompanionTranscriptRole = "user" | "assistant";
export type CompanionTranscriptItem = {
sequence: number;
role: CompanionTranscriptRole;
content: string;
created_at: string;
};
export type CompanionTranscriptProjection = {
state: CompanionLifecycleState;
start: number;
limit: number;
total: number;
next: number | null;
items: Array<CompanionTranscriptItem>;
};
@@ -0,0 +1,177 @@
import {
parseCompanionMessageResponse,
parseCompanionStatusResponse,
parseCompanionTranscriptProjection,
} from "./api.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assertEquals<T>(actual: T, expected: T): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
function assertThrows(fn: () => unknown, message: string): void {
try {
fn();
} catch {
return;
}
throw new Error(message);
}
const worker = {
subject: {
kind: "runtime_worker",
runtime_id: "arcadia",
worker_id: "worker-7",
},
resource_key: "W-7",
display_name: "Companion",
profile: "builtin:companion",
status: "idle",
};
Deno.test("Companion status boundary accepts every public lifecycle state", () => {
for (const state of ["idle", "running", "stopped"] as const) {
const parsed = parseCompanionStatusResponse({
state,
worker,
transport: {
mode: "worker_runtime",
available: state !== "stopped",
},
diagnostics: [],
});
assertEquals(parsed.state, state);
assertEquals(parsed.worker?.subject, worker.subject);
assertEquals(parsed.worker?.resource_key, "W-7");
assertEquals(parsed.worker?.display_name, "Companion");
}
});
Deno.test("Companion message boundary accepts accepted and rejected fixtures", () => {
assertEquals(
parseCompanionMessageResponse({
state: "accepted",
message: "accepted",
}),
{ state: "accepted", message: "accepted" },
);
assertEquals(
parseCompanionMessageResponse({
state: "rejected",
message: "rejected",
}),
{ state: "rejected", message: "rejected" },
);
assertThrows(
() =>
parseCompanionMessageResponse({
state: "accepted",
message: "accepted",
provider_request_id: "private-request",
}),
"private message response fields should be rejected",
);
});
Deno.test("Companion transcript boundary accepts only bounded user-visible items", () => {
const fixture = {
state: "idle" as const,
start: 0,
limit: 2,
total: 2,
next: null,
items: [
{
sequence: 1,
role: "user" as const,
content: "hello",
created_at: "2026-08-31T00:00:00Z",
},
{
sequence: 2,
role: "assistant" as const,
content: "hi",
created_at: "2026-08-31T00:00:01Z",
},
],
};
assertEquals(parseCompanionTranscriptProjection(fixture), fixture);
assertThrows(
() =>
parseCompanionTranscriptProjection({
...fixture,
items: [...fixture.items, fixture.items[0]],
}),
"items beyond the declared limit should be rejected",
);
});
Deno.test("Companion transcript boundary rejects system and private fields", () => {
const base = {
state: "idle",
start: 0,
limit: 1,
total: 1,
next: null,
};
assertThrows(
() =>
parseCompanionTranscriptProjection({
...base,
items: [{
sequence: 1,
role: "system",
content: "raw system prompt",
created_at: "2026-08-31T00:00:00Z",
}],
}),
"system transcript content should be rejected",
);
assertThrows(
() =>
parseCompanionTranscriptProjection({
...base,
items: [{
sequence: 1,
role: "assistant",
content: "visible",
created_at: "2026-08-31T00:00:00Z",
reasoning: "hidden",
credential: "secret",
provider_session_id: "private-session",
}],
}),
"private transcript fields should be rejected",
);
});
Deno.test("Companion status boundary does not use display_name as Worker identity", () => {
const fixture = {
state: "idle",
worker: { ...worker, display_name: "W-999" },
transport: { mode: "worker_runtime", available: true },
diagnostics: [],
};
const parsed = parseCompanionStatusResponse(fixture);
assertEquals(parsed.worker?.subject, worker.subject);
assertEquals(parsed.worker?.resource_key, "W-7");
assertEquals(parsed.worker?.display_name, "W-999");
assertThrows(
() =>
parseCompanionStatusResponse({
...fixture,
worker: { ...worker, resource_key: "Companion" },
}),
"display names must not substitute for canonical Worker resource keys",
);
});
@@ -0,0 +1,225 @@
import type {
CompanionLifecycleState,
CompanionMessageDisposition,
CompanionMessageResponse,
CompanionStatusResponse,
CompanionTranscriptItem,
CompanionTranscriptProjection,
Diagnostic,
DiagnosticSeverity,
WorkspaceWorkerDiscoveryItem,
WorkspaceWorkerSubject,
} from "$lib/generated/companion-api";
const MAX_TRANSCRIPT_ITEMS = 200;
const MAX_DIAGNOSTICS = 100;
const MAX_CONTENT_LENGTH = 64 * 1024;
export function parseCompanionStatusResponse(
value: unknown,
): CompanionStatusResponse {
const record = strictRecord(value, [
"state",
"worker",
"transport",
"diagnostics",
]);
const transport = strictRecord(record.transport, ["mode", "available"]);
const diagnostics = boundedArray(record.diagnostics, MAX_DIAGNOSTICS).map(
parseDiagnostic,
);
return {
state: lifecycleState(record.state),
worker: record.worker === null ? null : parseWorker(record.worker),
transport: {
mode: boundedString(transport.mode, 100),
available: booleanValue(transport.available),
},
diagnostics,
};
}
export function parseCompanionMessageResponse(
value: unknown,
): CompanionMessageResponse {
const record = strictRecord(value, ["state", "message"]);
return {
state: messageDisposition(record.state),
message: boundedString(record.message, 8 * 1024),
};
}
export function parseCompanionTranscriptProjection(
value: unknown,
): CompanionTranscriptProjection {
const record = strictRecord(value, [
"state",
"start",
"limit",
"total",
"next",
"items",
]);
const start = boundedInteger(record.start);
const limit = boundedInteger(record.limit);
if (limit < 1 || limit > MAX_TRANSCRIPT_ITEMS) {
throw new TypeError("Companion transcript limit is out of range");
}
const items = boundedArray(record.items, limit).map(parseTranscriptItem);
const total = boundedInteger(record.total);
if (total < items.length) {
throw new TypeError("Companion transcript total is smaller than its items");
}
const next = record.next === null ? null : boundedInteger(record.next);
return {
state: lifecycleState(record.state),
start,
limit,
total,
next,
items,
};
}
function parseTranscriptItem(value: unknown): CompanionTranscriptItem {
const record = strictRecord(value, [
"sequence",
"role",
"content",
"created_at",
]);
const role = record.role;
if (role !== "user" && role !== "assistant") {
throw new TypeError("Companion transcript role is not user-visible");
}
return {
sequence: boundedInteger(record.sequence),
role,
content: boundedString(record.content, MAX_CONTENT_LENGTH),
created_at: boundedString(record.created_at, 100),
};
}
function parseWorker(value: unknown): WorkspaceWorkerDiscoveryItem {
const record = strictRecord(value, [
"subject",
"resource_key",
"display_name",
"profile",
"status",
], ["status"]);
const subject = parseWorkerSubject(record.subject);
const resourceKey = boundedString(record.resource_key, 100);
if (!/^W-[1-9][0-9]*$/.test(resourceKey)) {
throw new TypeError("Companion worker resource_key is not canonical");
}
return {
subject,
resource_key: resourceKey,
display_name: boundedString(record.display_name, 256),
profile: nullableString(record.profile, 256),
...(record.status === undefined
? {}
: { status: nullableString(record.status, 100) }),
};
}
function parseWorkerSubject(value: unknown): WorkspaceWorkerSubject {
const record = strictRecord(value, ["kind", "runtime_id", "worker_id"]);
if (record.kind !== "runtime_worker") {
throw new TypeError("Companion worker subject kind is invalid");
}
return {
kind: "runtime_worker",
runtime_id: boundedString(record.runtime_id, 256),
worker_id: boundedString(record.worker_id, 256),
};
}
function parseDiagnostic(value: unknown): Diagnostic {
const record = strictRecord(value, ["code", "severity", "message"]);
return {
code: boundedString(record.code, 256),
severity: diagnosticSeverity(record.severity),
message: boundedString(record.message, 4 * 1024),
};
}
function lifecycleState(value: unknown): CompanionLifecycleState {
if (value !== "idle" && value !== "running" && value !== "stopped") {
throw new TypeError("Companion lifecycle state is invalid");
}
return value;
}
function messageDisposition(value: unknown): CompanionMessageDisposition {
if (value !== "accepted" && value !== "rejected") {
throw new TypeError("Companion message disposition is invalid");
}
return value;
}
function diagnosticSeverity(value: unknown): DiagnosticSeverity {
if (value !== "info" && value !== "warning" && value !== "error") {
throw new TypeError("Companion diagnostic severity is invalid");
}
return value;
}
function strictRecord(
value: unknown,
keys: readonly string[],
optionalKeys: readonly string[] = [],
): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeError("Companion API value is not 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)) {
throw new TypeError(`Companion API field is not public: ${key}`);
}
}
const optional = new Set(optionalKeys);
for (const key of keys) {
if (!optional.has(key) && !(key in record)) {
throw new TypeError(`Companion API field is missing: ${key}`);
}
}
return record;
}
function boundedArray(value: unknown, limit: number): unknown[] {
if (!Array.isArray(value) || value.length > limit) {
throw new TypeError("Companion API array is invalid or exceeds its limit");
}
return value;
}
function boundedString(value: unknown, limit: number): string {
if (typeof value !== "string" || value.length > limit) {
throw new TypeError("Companion API string is invalid or exceeds its limit");
}
return value;
}
function nullableString(value: unknown, limit: number): string | null {
return value === null ? null : boundedString(value, limit);
}
function booleanValue(value: unknown): boolean {
if (typeof value !== "boolean") {
throw new TypeError("Companion API value is not a boolean");
}
return value;
}
function boundedInteger(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new TypeError("Companion API value is not a non-negative integer");
}
return value as number;
}
@@ -448,56 +448,15 @@ export type ObjectiveListResponse = {
record_authority: string;
};
export type CompanionState =
| "ready"
| "busy"
| "error"
| "timeout"
| "cancelled"
| "accepted"
| "rejected";
export type CompanionTransportSummary = {
kind: string;
completion: string;
limitation: string;
};
export type CompanionStatusResponse = {
state: CompanionState;
worker?: Worker | null;
transport: CompanionTransportSummary;
diagnostics: Diagnostic[];
};
export type CompanionTranscriptItem = {
sequence: number;
role: "user" | "assistant" | "system" | string;
content: string;
created_at: string;
source: string;
status: string;
};
export type CompanionTranscriptProjection = {
state: CompanionState;
start: number;
limit: number;
total_items: number;
next_start?: number | null;
items: CompanionTranscriptItem[];
diagnostics: Diagnostic[];
};
export type CompanionMessageRequest = {
content: string;
};
export type CompanionMessageResponse = {
state: CompanionState;
worker?: Worker | null;
user_item?: CompanionTranscriptItem | null;
assistant_item?: CompanionTranscriptItem | null;
transcript: CompanionTranscriptProjection;
diagnostics: Diagnostic[];
};
export type {
CompanionCancelRequest,
CompanionLifecycleState,
CompanionMessageDisposition,
CompanionMessageRequest,
CompanionMessageResponse,
CompanionStatusResponse,
CompanionTranscriptItem,
CompanionTranscriptProjection,
CompanionTranscriptRole,
CompanionTransportSummary,
} from "$lib/generated/companion-api";