feat: validate shared workdir REST contracts in web

This commit is contained in:
2026-09-01 01:42:17 +09:00
parent 996b7f2468
commit 3481682cb4
6 changed files with 376 additions and 55 deletions
+3 -1
View File
@@ -113,6 +113,7 @@ export async function loadJson<T>(
fetchFn: typeof fetch,
path: string,
init?: RequestInit,
parse: (value: unknown) => T = (value) => value as T,
): Promise<ApiResult<T>> {
try {
const response = await fetchFn(path, init);
@@ -123,7 +124,8 @@ export async function loadJson<T>(
error: text || `${path} request failed (${response.status})`,
};
}
return { data: (await response.json()) as T, error: null };
const payload: unknown = await response.json();
return { data: parse(payload), error: null };
} catch (error) {
return {
data: null,
@@ -0,0 +1,250 @@
import type {
Diagnostic,
WorkingDirectoryCleanupTarget,
WorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
WorkingDirectoryOccupancy,
WorkingDirectorySummary,
} from "../../generated/workdir-api";
const SUMMARY_KEYS = new Set([
"working_directory_id",
"repository_id",
"creation_selector",
"creation_ref",
"creation_tree",
"current_selector",
"current_ref",
"current_tree",
"observed_at_epoch_seconds",
"materializer_kind",
"cleanup_target",
"status",
"cleanliness",
"primary_worker_id",
"occupied_by",
]);
const CREATE_REQUEST_KEYS = new Set([
"runtime_id",
"repository_id",
"selector",
"operation_id",
]);
const DIAGNOSTIC_KEYS = new Set(["code", "severity", "message"]);
const CLEANUP_TARGET_KEYS = new Set([
"kind",
"working_directory_id",
"repository_id",
]);
const OCCUPANCY_KEYS = new Set([
"runtime_id",
"worker_id",
"display_name",
"linked_at",
]);
export function parseWorkingDirectoryListResponse(
value: unknown,
): WorkingDirectoryListResponse {
const record = exactRecord(
value,
new Set(["workspace_id", "items", "diagnostics"]),
"Workdir list response",
);
return {
workspace_id: stringField(record, "workspace_id"),
items: arrayField(record, "items").map(parseSummary),
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
};
}
export function parseWorkingDirectoryDetailResponse(
value: unknown,
): WorkingDirectoryDetailResponse {
return parseDetailLike(value, "Workdir detail response");
}
export function parseWorkingDirectoryCreateResponse(
value: unknown,
): WorkingDirectoryCreateResponse {
return parseDetailLike(value, "Workdir create response");
}
export function validateWorkingDirectoryCreateRequest(
value: unknown,
): WorkingDirectoryCreateRequest {
const record = exactRecord(
value,
CREATE_REQUEST_KEYS,
"Workdir create request",
);
const request: WorkingDirectoryCreateRequest = {
repository_id: stringField(record, "repository_id"),
};
assignOptionalString(request, record, "runtime_id");
assignOptionalString(request, record, "selector");
assignOptionalString(request, record, "operation_id");
return request;
}
function parseDetailLike(
value: unknown,
label: string,
): WorkingDirectoryDetailResponse {
const record = exactRecord(
value,
new Set(["workspace_id", "runtime_id", "item", "diagnostics"]),
label,
);
return {
workspace_id: stringField(record, "workspace_id"),
runtime_id: stringField(record, "runtime_id"),
item: parseSummary(record.item),
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
};
}
function parseSummary(value: unknown): WorkingDirectorySummary {
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
const summary: WorkingDirectorySummary = {
working_directory_id: stringField(record, "working_directory_id"),
repository_id: stringField(record, "repository_id"),
materializer_kind: enumField(record, "materializer_kind", [
"runtime_git_cache",
"local_git_worktree",
]),
status: enumField(record, "status", [
"active",
"cleanup_pending",
"corrupted",
"not_found",
"unknown",
]),
};
assignOptionalString(summary, record, "creation_selector");
assignOptionalString(summary, record, "creation_ref");
assignOptionalString(summary, record, "creation_tree");
assignOptionalString(summary, record, "current_selector");
assignOptionalString(summary, record, "current_ref");
assignOptionalString(summary, record, "current_tree");
assignOptionalString(summary, record, "cleanliness");
assignOptionalString(summary, record, "primary_worker_id");
if (record.observed_at_epoch_seconds !== undefined) {
const observedAt = record.observed_at_epoch_seconds;
if (observedAt === null) {
summary.observed_at_epoch_seconds = null;
} else {
if (!Number.isSafeInteger(observedAt) || Number(observedAt) < 0) {
throw new Error(
"Workdir summary.observed_at_epoch_seconds must be a non-negative safe integer or null",
);
}
summary.observed_at_epoch_seconds = Number(observedAt);
}
}
if (record.cleanup_target !== undefined) {
summary.cleanup_target = record.cleanup_target === null
? null
: parseCleanupTarget(record.cleanup_target);
}
if (record.occupied_by !== undefined) {
summary.occupied_by = record.occupied_by === null
? null
: parseOccupancy(record.occupied_by);
}
return summary;
}
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
const record = exactRecord(
value,
CLEANUP_TARGET_KEYS,
"Workdir cleanup target",
);
return {
kind: stringField(record, "kind"),
working_directory_id: stringField(record, "working_directory_id"),
repository_id: stringField(record, "repository_id"),
};
}
function parseOccupancy(value: unknown): WorkingDirectoryOccupancy {
const record = exactRecord(value, OCCUPANCY_KEYS, "Workdir occupancy");
return {
runtime_id: stringField(record, "runtime_id"),
worker_id: stringField(record, "worker_id"),
display_name: stringField(record, "display_name"),
linked_at: stringField(record, "linked_at"),
};
}
function parseDiagnostic(value: unknown): Diagnostic {
const record = exactRecord(value, DIAGNOSTIC_KEYS, "Workdir diagnostic");
return {
code: stringField(record, "code"),
severity: enumField(record, "severity", ["info", "warning", "error"]),
message: stringField(record, "message"),
};
}
function exactRecord(
value: unknown,
keys: ReadonlySet<string>,
label: string,
): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!keys.has(key)) {
throw new Error(`${label} contains unknown field ${key}`);
}
}
return record;
}
function stringField(record: Record<string, unknown>, key: string): string {
const value = record[key];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${key} must be a non-empty string`);
}
return value;
}
function arrayField(record: Record<string, unknown>, key: string): unknown[] {
const value = record[key];
if (!Array.isArray(value)) throw new Error(`${key} must be an array`);
return value;
}
function enumField<T extends string>(
record: Record<string, unknown>,
key: string,
values: readonly T[],
): T {
const value = record[key];
if (typeof value !== "string" || !values.includes(value as T)) {
throw new Error(`${key} has an unsupported value`);
}
return value as T;
}
function assignOptionalString<T extends object>(
target: T,
source: Record<string, unknown>,
key: string,
): void {
const value = source[key];
if (value === undefined) return;
if (value === null) {
(target as Record<string, unknown>)[key] = null;
return;
}
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${key} must be a non-empty string or null`);
}
(target as Record<string, unknown>)[key] = value;
}
@@ -1,10 +1,28 @@
import type {
WorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
WorkingDirectoryOccupancy,
WorkingDirectorySummary,
} from "$lib/generated/workdir-api";
import type {
Event as PodProtocolEvent,
Method as PodProtocolMethod,
Segment as PodProtocolSegment,
} from "$lib/generated/protocol";
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
export type {
PodProtocolEvent,
PodProtocolMethod,
PodProtocolSegment,
WorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
WorkingDirectoryOccupancy,
WorkingDirectorySummary,
};
export type ExtensionPoint = {
status: string;
@@ -111,44 +129,6 @@ export type WorkingDirectoryRepositoryOption = {
default_selector?: string | null;
};
export type WorkingDirectoryOccupancy = {
runtime_id: string;
worker_id: string;
display_name: string;
linked_at: string;
};
export type WorkingDirectorySummary = {
working_directory_id: string;
repository_id: string;
creation_selector?: string | null;
creation_ref?: string | null;
current_selector?: string | null;
current_ref?: string | null;
materializer_kind: string;
status: string;
cleanliness?: string | null;
primary_worker_id?: string | null;
occupied_by?: WorkingDirectoryOccupancy | null;
cleanup_target: {
kind: string;
working_directory_id: string;
repository_id: string;
};
};
export type BrowserWorkingDirectoryCreateResponse = {
workspace_id: string;
item: WorkingDirectorySummary;
diagnostics: Diagnostic[];
};
export type BrowserWorkingDirectoryListResponse = {
workspace_id: string;
items: WorkingDirectorySummary[];
diagnostics: Diagnostic[];
};
export type CleanupTargetKind =
| "worker_delete"
| "workdir_clean_cleanup"
@@ -217,12 +197,6 @@ export type BrowserWorkerWorkingDirectorySelection = {
relative_cwd?: string | null;
};
export type BrowserWorkingDirectoryCreateRequest = {
runtime_id: string;
repository_id: string;
selector?: string | null;
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[];
@@ -1,6 +1,6 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkingDirectoryListResponse } from "$lib/workspace/api/workdirs";
import type {
BrowserWorkingDirectoryListResponse,
ListResponse,
Runtime,
RuntimeCleanupPlanResponse,
@@ -14,12 +14,14 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch,
workspaceApiPath(params.workspaceId, "/runtimes"),
),
loadJson<BrowserWorkingDirectoryListResponse>(
loadJson(
fetch,
workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
),
undefined,
parseWorkingDirectoryListResponse,
),
loadJson<RuntimeCleanupPlanResponse>(
fetch,
@@ -2,11 +2,14 @@
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { workspaceApiPath } from '$lib/workspace/api/http';
import {
parseWorkingDirectoryCreateResponse,
validateWorkingDirectoryCreateRequest,
} from '$lib/workspace/api/workdirs';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
import type {
BrowserCreateWorkerResponse,
BrowserWorkingDirectoryCreateResponse,
Diagnostic,
WorkerLaunchOptionsResponse,
WorkingDirectorySummary,
@@ -160,22 +163,23 @@
creatingWorkingDirectory = true;
submitError = null;
try {
const request = validateWorkingDirectoryCreateRequest({
runtime_id: runtimeId,
repository_id: workingDirectoryRepositoryId,
...(workingDirectorySelector ? { selector: workingDirectorySelector } : {}),
});
const response = await fetch(
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
runtime_id: runtimeId,
repository_id: workingDirectoryRepositoryId,
selector: workingDirectorySelector || null,
}),
body: JSON.stringify(request),
},
);
if (!response.ok) {
submitError = await responseDisplayError(response, 'workdir create failed');
return;
}
const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse;
const payload = parseWorkingDirectoryCreateResponse(await response.json());
const items = options?.working_directories ?? [];
options = options
? {
+89
View File
@@ -0,0 +1,89 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
parseWorkingDirectoryCreateResponse,
parseWorkingDirectoryListResponse,
validateWorkingDirectoryCreateRequest,
} from "../src/lib/workspace/api/workdirs.ts";
const summary = {
working_directory_id: "workdir-1",
repository_id: "main",
materializer_kind: "runtime_git_cache",
status: "active",
occupied_by: {
runtime_id: "arcadia",
worker_id: "worker-1",
display_name: "Coder",
linked_at: "2026-01-01T00:00:00Z",
},
};
Deno.test("Workdir REST validation accepts the generated list and create contracts", () => {
const list = parseWorkingDirectoryListResponse({
workspace_id: "workspace-a",
items: [summary],
diagnostics: [],
});
if (list.items[0]?.occupied_by?.runtime_id !== "arcadia") {
throw new Error("occupancy subject was not preserved");
}
const created = parseWorkingDirectoryCreateResponse({
workspace_id: "workspace-a",
runtime_id: "arcadia",
item: summary,
diagnostics: [],
});
if (created.runtime_id !== "arcadia") {
throw new Error("create Runtime was not preserved");
}
});
Deno.test("Workdir REST validation rejects stale response JSON", () => {
let rejected = false;
try {
parseWorkingDirectoryListResponse({
workspace_id: "workspace-a",
items: [summary],
diagnostics: [],
source: "legacy-runtime",
});
} catch {
rejected = true;
}
if (!rejected) throw new Error("stale response field was accepted");
});
Deno.test("Workdir REST validation enforces create operation fields", () => {
const request = validateWorkingDirectoryCreateRequest({
runtime_id: "arcadia",
repository_id: "main",
selector: "develop",
operation_id: "operation-1",
});
if (request.operation_id !== "operation-1") {
throw new Error("operation id was not preserved");
}
for (
const invalid of [
{ runtime_id: "arcadia", operation_id: "operation-1" },
{ repository_id: "main", operation_key: "operation-1" },
]
) {
let rejected = false;
try {
validateWorkingDirectoryCreateRequest(invalid);
} catch {
rejected = true;
}
if (!rejected) {
throw new Error(
`invalid request was accepted: ${JSON.stringify(invalid)}`,
);
}
}
});