From 3481682cb413335429f427d6d08844bb0855662b Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 01:42:17 +0900 Subject: [PATCH] feat: validate shared workdir REST contracts in web --- web/workspace/src/lib/workspace/api/http.ts | 4 +- .../src/lib/workspace/api/workdirs.ts | 250 ++++++++++++++++++ .../src/lib/workspace/sidebar/types.ts | 64 ++--- .../runtimes/[runtimeId]/workdirs/+page.ts | 6 +- .../w/[workspaceId]/workers/new/+page.svelte | 18 +- web/workspace/tests/workdir-api.test.ts | 89 +++++++ 6 files changed, 376 insertions(+), 55 deletions(-) create mode 100644 web/workspace/src/lib/workspace/api/workdirs.ts create mode 100644 web/workspace/tests/workdir-api.test.ts diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 235cd6fb..bd5c51b8 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -113,6 +113,7 @@ export async function loadJson( fetchFn: typeof fetch, path: string, init?: RequestInit, + parse: (value: unknown) => T = (value) => value as T, ): Promise> { try { const response = await fetchFn(path, init); @@ -123,7 +124,8 @@ export async function loadJson( 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, diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts new file mode 100644 index 00000000..4ca39ced --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -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, + label: string, +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const record = value as Record; + 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, 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, key: string): unknown[] { + const value = record[key]; + if (!Array.isArray(value)) throw new Error(`${key} must be an array`); + return value; +} + +function enumField( + record: Record, + 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( + target: T, + source: Record, + key: string, +): void { + const value = source[key]; + if (value === undefined) return; + if (value === null) { + (target as Record)[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)[key] = value; +} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 0cf9cf23..e69ae0be 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -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[]; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts index 4543ff7a..0c3bd9ad 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts @@ -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( + loadJson( fetch, workspaceApiPath( params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/working-directories`, ), + undefined, + parseWorkingDirectoryListResponse, ), loadJson( fetch, diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 80b43799..1feeae68 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -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 ? { diff --git a/web/workspace/tests/workdir-api.test.ts b/web/workspace/tests/workdir-api.test.ts new file mode 100644 index 00000000..ada83ce4 --- /dev/null +++ b/web/workspace/tests/workdir-api.test.ts @@ -0,0 +1,89 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): 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)}`, + ); + } + } +});