fix: align Companion REST DTO contracts
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user