feat: validate shared workdir REST contracts in web
This commit is contained in:
@@ -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[];
|
||||
|
||||
+4
-2
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user