diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts new file mode 100644 index 00000000..ebed5766 --- /dev/null +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -0,0 +1,164 @@ +// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`. +// Do not edit this file directly. + +export type WorkspaceSummary = { + workspace_id: string; + owner_account_id: string | null; + display_name: string; + state: string; + created_at: string; + updated_at: string; +}; + +export type WorkspaceRepositoryRecord = { + workspace_id: string; + repository_id: string; + name: string; + kind: string; + provider: string | null; + source: RepositorySource; + default_ref: string | null; + source_revision: number; + source_fingerprint: string; + observed_status: RepositoryObservedStatus; + observed_at: string | null; + created_at: string; + updated_at: string; +}; + +export type WorkspaceCreateResponse = { + workspace: WorkspaceSummary; + repository: WorkspaceRepositoryRecord; + config_revision: number; + request_fingerprint: string; + replayed: boolean; +}; + +export type WorkspaceAuthConfig = { + "Passkey": { + rp_id: string; + origin: string; + public_base_url: string; + cookie_name: string; + }; +}; + +export type WorkspacePermissionSummary = { + manage_repositories: boolean; + manage_secrets: boolean; +}; + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type Diagnostic = { + code: string; + severity: DiagnosticSeverity; + message: string; +}; + +export type WorkspaceExtensionPointState = { + status: string; + note: string; + diagnostics: Array; +}; + +export type WorkspaceExtensionPoints = { + store: string; + event_stream: WorkspaceExtensionPointState; + host_worker_bridge: WorkspaceExtensionPointState; + companion_console: WorkspaceExtensionPointState; +}; + +export type WorkspaceResponse = { + workspace_id: string; + display_name: string; + record_authority: string; + schema_version: number; + auth: WorkspaceAuthConfig; + permissions: WorkspacePermissionSummary; + extension_points: WorkspaceExtensionPoints; +}; + +export type RepositorySourceKind = + | "local_path" + | "file" + | "ssh" + | "http" + | "https" + | "invalid"; + +export type RepositorySource = { + kind: RepositorySourceKind; + /** + * Canonical source representation. This is an absolute local path for + * `local_path`, and a normalized URI/remote specification otherwise. + */ + uri: string; +}; + +export type RepositoryObservedStatus = "unverified" | "ready" | "invalid"; + +export type RepositoryDiagnostic = { + severity: string; + code: string; + message: string; +}; + +export type GitRemoteSummary = { name: string; fetch_url: string }; + +export type GitRepositorySummary = { + status: string; + head: string | null; + branch: string | null; + dirty: boolean; + remotes: Array; +}; + +export type RepositorySummary = { + id: string; + display_name: string; + kind: string; + provider: string; + source: RepositorySource; + source_revision: number; + source_fingerprint: string; + observed_status: RepositoryObservedStatus; + observed_at?: string | null; + default_selector?: string | null; + record_authority: string; + git?: GitRepositorySummary | null; + diagnostics?: Array | null; +}; + +export type GitCommitSummary = { + hash: string; + short_hash: string; + summary: string; + author_name: string; + author_email: string; + author_date: string; + parents: Array; + refs: Array; +}; + +export type RepositoryListResponse = { + workspace_id: string; + items: Array; + source: string; + diagnostics: Array; +}; + +export type RepositoryDetailResponse = { + workspace_id: string; + item: RepositorySummary; + source: string; +}; + +export type RepositoryLogResponse = { + workspace_id: string; + repository_id: string; + default_selector?: string | null; + limit: number; + items: Array; + diagnostics: Array; +}; diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 6878faf4..7555ba52 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -1,41 +1,18 @@ -export type WorkspaceCatalogRecord = { - workspace_id: string; - owner_account_id: string | null; - display_name: string; - state: string; - created_at: string; - updated_at: string; -}; - -export type RepositorySourceKind = - | "local_path" - | "file" - | "ssh" - | "http" - | "https" - | "invalid"; - -export type WorkspaceRepositoryRecord = { - workspace_id: string; - repository_id: string; - name: string; - kind: string; - provider: string | null; - source: { - kind: RepositorySourceKind; - uri: string; - }; - default_ref: string | null; - source_revision: number; - source_fingerprint: string; - observed_status: "unverified" | "ready" | "invalid"; - observed_at: string | null; -}; +import { + parseRepositoryListResponse, + parseWorkspaceCatalogResponse, + parseWorkspaceCreateResponse, + type RepositorySummary, + type WorkspaceCreateResponse, + type WorkspaceSummary, +} from "$lib/workspace/api/workspace-model"; +export type WorkspaceCatalogRecord = WorkspaceSummary; export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { - repositories: WorkspaceRepositoryRecord[]; + repositories: RepositorySummary[]; repository_error?: string; }; +export type CreateWorkspaceResponse = WorkspaceCreateResponse; export type CreateWorkspaceRequest = { operation_key: string; @@ -47,14 +24,6 @@ export type CreateWorkspaceRequest = { }; }; -export type CreateWorkspaceResponse = { - workspace: WorkspaceCatalogRecord; - repository: WorkspaceRepositoryRecord; - config_revision: number; - request_fingerprint: string; - replayed: boolean; -}; - export class WorkspaceCatalogError extends Error { constructor( public readonly status: number | null, @@ -70,20 +39,21 @@ type Fetch = typeof globalThis.fetch; export async function listWorkspaces( fetcher: Fetch, ): Promise { - return await fetchJson( - fetcher, - "/api/workspaces?limit=200", + return parseWorkspaceCatalogResponse( + await fetchJson(fetcher, "/api/workspaces?limit=200"), ); } export async function listWorkspaceRepositories( fetcher: Fetch, workspaceId: string, -): Promise { - return await fetchJson( - fetcher, - `/api/w/${encodeURIComponent(workspaceId)}/repositories`, - ); +): Promise { + return parseRepositoryListResponse( + await fetchJson( + fetcher, + `/api/w/${encodeURIComponent(workspaceId)}/repositories`, + ), + ).items; } export async function loadWorkspaceCatalog( @@ -115,11 +85,13 @@ export async function createWorkspace( fetcher: Fetch, request: CreateWorkspaceRequest, ): Promise { - return await fetchJson(fetcher, "/api/workspaces", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(request), - }); + return parseWorkspaceCreateResponse( + await fetchJson(fetcher, "/api/workspaces", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }), + ); } export function creationErrorMessage(error: unknown): string { @@ -150,11 +122,11 @@ export function createOperationKey(): string { }`; } -async function fetchJson( +async function fetchJson( fetcher: Fetch, input: string, init?: RequestInit, -): Promise { +): Promise { let response: Response; try { response = await fetcher(input, init); @@ -172,7 +144,7 @@ async function fetchJson( } throw new WorkspaceCatalogError(response.status, detail); } - return await response.json() as T; + return await response.json() as unknown; } function errorMessage(error: unknown): string { diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts new file mode 100644 index 00000000..a915f17e --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -0,0 +1,591 @@ +import type { + Diagnostic, + GitCommitSummary, + GitRemoteSummary, + GitRepositorySummary, + RepositoryDetailResponse, + RepositoryDiagnostic, + RepositoryListResponse, + RepositoryLogResponse, + RepositorySource, + RepositorySourceKind, + RepositorySummary, + WorkspaceAuthConfig, + WorkspaceCreateResponse, + WorkspaceExtensionPoints, + WorkspaceExtensionPointState, + WorkspacePermissionSummary, + WorkspaceRepositoryRecord, + WorkspaceResponse, + WorkspaceSummary, +} from "$lib/generated/workspace-api.ts"; + +export type { + GitCommitSummary, + GitRemoteSummary, + GitRepositorySummary, + RepositoryDetailResponse, + RepositoryListResponse, + RepositoryLogResponse, + RepositorySummary, + WorkspaceCreateResponse, + WorkspacePermissionSummary, + WorkspaceResponse, + WorkspaceSummary, +} from "$lib/generated/workspace-api.ts"; + +type JsonObject = Record; + +const SOURCE_KINDS = new Set([ + "local_path", + "file", + "ssh", + "http", + "https", + "invalid", +]); +const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]); +const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]); + +function object(value: unknown, path: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } + return value as JsonObject; +} + +function array(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value; +} + +function string(value: unknown, path: string): string { + if (typeof value !== "string") throw new Error(`${path} must be a string`); + return value; +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`); + return value; +} + +function integer(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw new Error(`${path} must be a safe integer`); + } + return value; +} + +function nullableString(value: unknown, path: string): string | null { + return value === null ? null : string(value, path); +} + +function optionalNullableString( + value: unknown, + path: string, +): string | null | undefined { + return value === undefined ? undefined : nullableString(value, path); +} + +function exactKeys( + value: JsonObject, + keys: readonly string[], + path: string, +): void { + const allowed = new Set(keys); + const unexpected = Object.keys(value).find((key) => !allowed.has(key)); + if (unexpected) { + throw new Error(`${path}.${unexpected} is not part of the wire contract`); + } +} + +function diagnostic(value: unknown, path: string): Diagnostic { + const item = object(value, path); + exactKeys(item, ["code", "severity", "message"], path); + const severity = string(item.severity, `${path}.severity`); + if (!DIAGNOSTIC_SEVERITIES.has(severity)) { + throw new Error(`${path}.severity is invalid`); + } + return { + code: string(item.code, `${path}.code`), + severity: severity as Diagnostic["severity"], + message: string(item.message, `${path}.message`), + }; +} + +function repositoryDiagnostic( + value: unknown, + path: string, +): RepositoryDiagnostic { + const item = object(value, path); + exactKeys(item, ["severity", "code", "message"], path); + return { + severity: string(item.severity, `${path}.severity`), + code: string(item.code, `${path}.code`), + message: string(item.message, `${path}.message`), + }; +} + +function repositorySource(value: unknown, path: string): RepositorySource { + const source = object(value, path); + exactKeys(source, ["kind", "uri"], path); + const kind = string(source.kind, `${path}.kind`); + if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) { + throw new Error(`${path}.kind is invalid`); + } + return { + kind: kind as RepositorySourceKind, + uri: string(source.uri, `${path}.uri`), + }; +} + +function gitRemote(value: unknown, path: string): GitRemoteSummary { + const remote = object(value, path); + exactKeys(remote, ["name", "fetch_url"], path); + return { + name: string(remote.name, `${path}.name`), + fetch_url: string(remote.fetch_url, `${path}.fetch_url`), + }; +} + +function gitSummary(value: unknown, path: string): GitRepositorySummary { + const git = object(value, path); + exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path); + return { + status: string(git.status, `${path}.status`), + head: nullableString(git.head, `${path}.head`), + branch: nullableString(git.branch, `${path}.branch`), + dirty: boolean(git.dirty, `${path}.dirty`), + remotes: array(git.remotes, `${path}.remotes`).map((item, index) => + gitRemote(item, `${path}.remotes[${index}]`) + ), + }; +} + +function repositorySummary(value: unknown, path: string): RepositorySummary { + const item = object(value, path); + exactKeys( + item, + [ + "id", + "display_name", + "kind", + "provider", + "source", + "source_revision", + "source_fingerprint", + "observed_status", + "observed_at", + "default_selector", + "record_authority", + "git", + "diagnostics", + ], + path, + ); + const observedStatus = string( + item.observed_status, + `${path}.observed_status`, + ); + if (!OBSERVED_STATUSES.has(observedStatus)) { + throw new Error(`${path}.observed_status is invalid`); + } + const diagnostics = + item.diagnostics === undefined || item.diagnostics === null + ? item.diagnostics + : array(item.diagnostics, `${path}.diagnostics`).map((entry, index) => + repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`) + ); + return { + id: string(item.id, `${path}.id`), + display_name: string(item.display_name, `${path}.display_name`), + kind: string(item.kind, `${path}.kind`), + provider: string(item.provider, `${path}.provider`), + source: repositorySource(item.source, `${path}.source`), + source_revision: integer(item.source_revision, `${path}.source_revision`), + source_fingerprint: string( + item.source_fingerprint, + `${path}.source_fingerprint`, + ), + observed_status: observedStatus as RepositorySummary["observed_status"], + observed_at: optionalNullableString( + item.observed_at, + `${path}.observed_at`, + ), + default_selector: optionalNullableString( + item.default_selector, + `${path}.default_selector`, + ), + record_authority: string(item.record_authority, `${path}.record_authority`), + git: item.git === undefined || item.git === null + ? item.git + : gitSummary(item.git, `${path}.git`), + diagnostics, + }; +} + +function workspaceSummary(value: unknown, path: string): WorkspaceSummary { + const item = object(value, path); + exactKeys( + item, + [ + "workspace_id", + "owner_account_id", + "display_name", + "state", + "created_at", + "updated_at", + ], + path, + ); + return { + workspace_id: string(item.workspace_id, `${path}.workspace_id`), + owner_account_id: nullableString( + item.owner_account_id, + `${path}.owner_account_id`, + ), + display_name: string(item.display_name, `${path}.display_name`), + state: string(item.state, `${path}.state`), + created_at: string(item.created_at, `${path}.created_at`), + updated_at: string(item.updated_at, `${path}.updated_at`), + }; +} + +function workspaceRepositoryRecord( + value: unknown, + path: string, +): WorkspaceRepositoryRecord { + const item = object(value, path); + exactKeys( + item, + [ + "workspace_id", + "repository_id", + "name", + "kind", + "provider", + "source", + "default_ref", + "source_revision", + "source_fingerprint", + "observed_status", + "observed_at", + "created_at", + "updated_at", + ], + path, + ); + const observedStatus = string( + item.observed_status, + `${path}.observed_status`, + ); + if (!OBSERVED_STATUSES.has(observedStatus)) { + throw new Error(`${path}.observed_status is invalid`); + } + return { + workspace_id: string(item.workspace_id, `${path}.workspace_id`), + repository_id: string(item.repository_id, `${path}.repository_id`), + name: string(item.name, `${path}.name`), + kind: string(item.kind, `${path}.kind`), + provider: nullableString(item.provider, `${path}.provider`), + source: repositorySource(item.source, `${path}.source`), + default_ref: nullableString(item.default_ref, `${path}.default_ref`), + source_revision: integer(item.source_revision, `${path}.source_revision`), + source_fingerprint: string( + item.source_fingerprint, + `${path}.source_fingerprint`, + ), + observed_status: + observedStatus as WorkspaceRepositoryRecord["observed_status"], + observed_at: nullableString(item.observed_at, `${path}.observed_at`), + created_at: string(item.created_at, `${path}.created_at`), + updated_at: string(item.updated_at, `${path}.updated_at`), + }; +} + +function extensionPoint( + value: unknown, + path: string, +): WorkspaceExtensionPointState { + const item = object(value, path); + exactKeys(item, ["status", "note", "diagnostics"], path); + return { + status: string(item.status, `${path}.status`), + note: string(item.note, `${path}.note`), + diagnostics: array(item.diagnostics, `${path}.diagnostics`).map(( + entry, + index, + ) => diagnostic(entry, `${path}.diagnostics[${index}]`)), + }; +} + +function extensionPoints( + value: unknown, + path: string, +): WorkspaceExtensionPoints { + const item = object(value, path); + exactKeys(item, [ + "store", + "event_stream", + "host_worker_bridge", + "companion_console", + ], path); + return { + store: string(item.store, `${path}.store`), + event_stream: extensionPoint(item.event_stream, `${path}.event_stream`), + host_worker_bridge: extensionPoint( + item.host_worker_bridge, + `${path}.host_worker_bridge`, + ), + companion_console: extensionPoint( + item.companion_console, + `${path}.companion_console`, + ), + }; +} + +function authConfig(value: unknown, path: string): WorkspaceAuthConfig { + const auth = object(value, path); + exactKeys(auth, ["Passkey"], path); + const passkey = object(auth.Passkey, `${path}.Passkey`); + exactKeys( + passkey, + ["rp_id", "origin", "public_base_url", "cookie_name"], + `${path}.Passkey`, + ); + return { + Passkey: { + rp_id: string(passkey.rp_id, `${path}.Passkey.rp_id`), + origin: string(passkey.origin, `${path}.Passkey.origin`), + public_base_url: string( + passkey.public_base_url, + `${path}.Passkey.public_base_url`, + ), + cookie_name: string(passkey.cookie_name, `${path}.Passkey.cookie_name`), + }, + }; +} + +function permissions(value: unknown, path: string): WorkspacePermissionSummary { + const item = object(value, path); + exactKeys(item, ["manage_repositories", "manage_secrets"], path); + return { + manage_repositories: boolean( + item.manage_repositories, + `${path}.manage_repositories`, + ), + manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`), + }; +} + +function commitSummary(value: unknown, path: string): GitCommitSummary { + const item = object(value, path); + exactKeys( + item, + [ + "hash", + "short_hash", + "summary", + "author_name", + "author_email", + "author_date", + "parents", + "refs", + ], + path, + ); + return { + hash: string(item.hash, `${path}.hash`), + short_hash: string(item.short_hash, `${path}.short_hash`), + summary: string(item.summary, `${path}.summary`), + author_name: string(item.author_name, `${path}.author_name`), + author_email: string(item.author_email, `${path}.author_email`), + author_date: string(item.author_date, `${path}.author_date`), + parents: array(item.parents, `${path}.parents`).map((entry, index) => + string(entry, `${path}.parents[${index}]`) + ), + refs: array(item.refs, `${path}.refs`).map((entry, index) => + string(entry, `${path}.refs[${index}]`) + ), + }; +} + +export function parseWorkspaceCatalogResponse( + value: unknown, +): WorkspaceSummary[] { + return array(value, "workspaces").map((item, index) => + workspaceSummary(item, `workspaces[${index}]`) + ); +} + +export function parseWorkspaceCreateResponse( + value: unknown, +): WorkspaceCreateResponse { + const response = object(value, "workspace create response"); + exactKeys( + response, + [ + "workspace", + "repository", + "config_revision", + "request_fingerprint", + "replayed", + ], + "workspace create response", + ); + return { + workspace: workspaceSummary( + response.workspace, + "workspace create response.workspace", + ), + repository: workspaceRepositoryRecord( + response.repository, + "workspace create response.repository", + ), + config_revision: integer( + response.config_revision, + "workspace create response.config_revision", + ), + request_fingerprint: string( + response.request_fingerprint, + "workspace create response.request_fingerprint", + ), + replayed: boolean(response.replayed, "workspace create response.replayed"), + }; +} + +export function parseWorkspaceResponse(value: unknown): WorkspaceResponse { + const response = object(value, "workspace response"); + exactKeys( + response, + [ + "workspace_id", + "display_name", + "record_authority", + "schema_version", + "auth", + "permissions", + "extension_points", + ], + "workspace response", + ); + return { + workspace_id: string( + response.workspace_id, + "workspace response.workspace_id", + ), + display_name: string( + response.display_name, + "workspace response.display_name", + ), + record_authority: string( + response.record_authority, + "workspace response.record_authority", + ), + schema_version: integer( + response.schema_version, + "workspace response.schema_version", + ), + auth: authConfig(response.auth, "workspace response.auth"), + permissions: permissions( + response.permissions, + "workspace response.permissions", + ), + extension_points: extensionPoints( + response.extension_points, + "workspace response.extension_points", + ), + }; +} + +export function parseRepositoryListResponse( + value: unknown, +): RepositoryListResponse { + const response = object(value, "repository list response"); + exactKeys( + response, + ["workspace_id", "items", "source", "diagnostics"], + "repository list response", + ); + return { + workspace_id: string( + response.workspace_id, + "repository list response.workspace_id", + ), + items: array(response.items, "repository list response.items").map(( + item, + index, + ) => repositorySummary(item, `repository list response.items[${index}]`)), + source: string(response.source, "repository list response.source"), + diagnostics: array( + response.diagnostics, + "repository list response.diagnostics", + ).map( + (item, index) => + diagnostic(item, `repository list response.diagnostics[${index}]`), + ), + }; +} + +export function parseRepositoryDetailResponse( + value: unknown, +): RepositoryDetailResponse { + const response = object(value, "repository detail response"); + exactKeys( + response, + ["workspace_id", "item", "source"], + "repository detail response", + ); + return { + workspace_id: string( + response.workspace_id, + "repository detail response.workspace_id", + ), + item: repositorySummary(response.item, "repository detail response.item"), + source: string(response.source, "repository detail response.source"), + }; +} + +export function parseRepositoryLogResponse( + value: unknown, +): RepositoryLogResponse { + const response = object(value, "repository log response"); + exactKeys( + response, + [ + "workspace_id", + "repository_id", + "default_selector", + "limit", + "items", + "diagnostics", + ], + "repository log response", + ); + return { + workspace_id: string( + response.workspace_id, + "repository log response.workspace_id", + ), + repository_id: string( + response.repository_id, + "repository log response.repository_id", + ), + default_selector: optionalNullableString( + response.default_selector, + "repository log response.default_selector", + ), + limit: integer(response.limit, "repository log response.limit"), + items: array(response.items, "repository log response.items").map(( + item, + index, + ) => commitSummary(item, `repository log response.items[${index}]`)), + diagnostics: array( + response.diagnostics, + "repository log response.diagnostics", + ).map( + (item, index) => + diagnostic(item, `repository log response.diagnostics[${index}]`), + ), + }; +} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 0cf9cf23..c5d5a37c 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -3,25 +3,19 @@ import type { Method as PodProtocolMethod, Segment as PodProtocolSegment, } from "$lib/generated/protocol"; +import type { + GitCommitSummary as SharedGitCommitSummary, + GitRemoteSummary as SharedGitRemoteSummary, + GitRepositorySummary as SharedGitRepositorySummary, + RepositoryDetailResponse as SharedRepositoryDetailResponse, + RepositoryListResponse as SharedRepositoryListResponse, + RepositoryLogResponse as SharedRepositoryLogResponse, + RepositorySummary as SharedRepositorySummary, + WorkspaceResponse as SharedWorkspaceResponse, +} from "$lib/workspace/api/workspace-model"; export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment }; - -export type ExtensionPoint = { - status: string; - note: string; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceResponse = { - workspace_id: string; - display_name: string; - record_authority: string; - extension_points: { - event_stream: ExtensionPoint; - host_worker_bridge: ExtensionPoint; - companion_console: ExtensionPoint; - }; -}; +export type WorkspaceResponse = SharedWorkspaceResponse; export type Diagnostic = { code: string; @@ -257,70 +251,13 @@ export type ListResponse = { diagnostics: Diagnostic[]; }; -export type RepositorySummary = { - id: string; - display_name: string; - kind: string; - provider: string; - source: { - kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid"; - uri: string; - }; - source_revision: number; - source_fingerprint: string; - observed_status: "unverified" | "ready" | "invalid"; - observed_at?: string | null; - default_selector?: string | null; - record_authority: string; - git?: GitRepositorySummary | null; - diagnostics?: Diagnostic[]; -}; - -export type GitRepositorySummary = { - status: string; - branch?: string | null; - head?: string | null; - dirty: boolean; - remotes: GitRemoteSummary[]; -}; - -export type GitRemoteSummary = { - name: string; - fetch_url: string; -}; - -export type GitCommitSummary = { - hash: string; - short_hash: string; - summary: string; - author_name: string; - author_email: string; - author_date: string; - parents: string[]; - refs: string[]; -}; - -export type RepositoryListResponse = { - workspace_id: string; - items: RepositorySummary[]; - source: string; - diagnostics: Diagnostic[]; -}; - -export type RepositoryDetailResponse = { - workspace_id: string; - item: RepositorySummary; - source: string; -}; - -export type RepositoryLogResponse = { - workspace_id: string; - repository_id: string; - default_selector?: string | null; - limit: number; - items: GitCommitSummary[]; - diagnostics: Diagnostic[]; -}; +export type RepositorySummary = SharedRepositorySummary; +export type GitRepositorySummary = SharedGitRepositorySummary; +export type GitRemoteSummary = SharedGitRemoteSummary; +export type GitCommitSummary = SharedGitCommitSummary; +export type RepositoryListResponse = SharedRepositoryListResponse; +export type RepositoryDetailResponse = SharedRepositoryDetailResponse; +export type RepositoryLogResponse = SharedRepositoryLogResponse; export type MemoryDocumentResponse = { body_md: string; diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index 807a51b9..75bf8187 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -132,9 +132,9 @@ {workspace.workspace_id} {#if workspace.repositories[0]} - {workspace.repositories[0].name} + {workspace.repositories[0].display_name} - {workspace.repositories[0].default_ref ?? "repository default"} · + {workspace.repositories[0].default_selector ?? "repository default"} · {workspace.repositories[0].kind} diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.ts b/web/workspace/src/routes/w/[workspaceId]/+layout.ts index 7ec62de3..ad0a58dd 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+layout.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.ts @@ -1,34 +1,51 @@ import { error } from "@sveltejs/kit"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { + parseRepositoryListResponse, + parseWorkspaceResponse, +} from "$lib/workspace/api/workspace-model"; import type { LayoutLoad } from "./$types"; -import type { - RepositoryListResponse, - WorkspaceResponse, -} from "$lib/workspace/sidebar/types"; export const load: LayoutLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; - const [workspace, repositories] = await Promise.all([ - loadJson( - fetch, - workspaceApiPath(workspaceId, "/workspace"), - ), - loadJson( - fetch, - workspaceApiPath(workspaceId, "/repositories"), - ), + const [workspaceResult, repositoryResult] = await Promise.all([ + loadJson(fetch, workspaceApiPath(workspaceId, "/workspace")), + loadJson(fetch, workspaceApiPath(workspaceId, "/repositories")), ]); - if (!workspace.data) { + let workspace = null; + let workspaceError = workspaceResult.error; + if (workspaceResult.data !== null) { + try { + workspace = parseWorkspaceResponse(workspaceResult.data); + } catch (cause) { + workspaceError = cause instanceof Error + ? cause.message + : "invalid workspace response"; + } + } + if (!workspace) { error(404, { - message: workspace.error ?? `Workspace ${workspaceId} is unavailable`, + message: workspaceError ?? `Workspace ${workspaceId} is unavailable`, }); } + let repositories = null; + let repositoriesError = repositoryResult.error; + if (repositoryResult.data !== null) { + try { + repositories = parseRepositoryListResponse(repositoryResult.data); + } catch (cause) { + repositoriesError = cause instanceof Error + ? cause.message + : "invalid repository list response"; + } + } + return { - workspace: workspace.data, + workspace, workspaceError: null, - repositories: repositories.data, - repositoriesError: repositories.error, + repositories, + repositoriesError, }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts index 2b04bdde..b423bbdb 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts @@ -1,29 +1,59 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import type { - RepositoryDetailResponse, - RepositoryLogResponse, -} from "$lib/workspace/sidebar/types"; +import { + parseRepositoryDetailResponse, + parseRepositoryLogResponse, +} from "$lib/workspace/api/workspace-model"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { - const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); + const workspaceId = params.workspaceId; const repositoryId = params.repositoryId; - const [repository, log] = await Promise.all([ - loadJson( + const [repositoryResult, logResult] = await Promise.all([ + loadJson( fetch, - apiPath(`/repositories/${encodeURIComponent(repositoryId)}`), + workspaceApiPath( + workspaceId, + `/repositories/${encodeURIComponent(repositoryId)}`, + ), ), - loadJson( + loadJson( fetch, - apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`), + workspaceApiPath( + workspaceId, + `/repositories/${encodeURIComponent(repositoryId)}/log`, + ), ), ]); + let repository = null; + let repositoryError = repositoryResult.error; + if (repositoryResult.data !== null) { + try { + repository = parseRepositoryDetailResponse(repositoryResult.data); + } catch (cause) { + repositoryError = cause instanceof Error + ? cause.message + : "invalid repository detail response"; + } + } + + let log = null; + let logError = logResult.error; + if (logResult.data !== null) { + try { + log = parseRepositoryLogResponse(logResult.data); + } catch (cause) { + logError = cause instanceof Error + ? cause.message + : "invalid repository log response"; + } + } + return { repositoryId, - repository: repository.data, - repositoryError: repository.error, - repositoryLog: log.data, - repositoryLogError: log.error, + repository, + repositoryError, + repositoryLog: log, + repositoryLogError: logError, }; }; diff --git a/web/workspace/tests/workspace-catalog.test.ts b/web/workspace/tests/workspace-catalog.test.ts index b0576f66..bda0949e 100644 --- a/web/workspace/tests/workspace-catalog.test.ts +++ b/web/workspace/tests/workspace-catalog.test.ts @@ -55,21 +55,30 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si ])); } if (url.includes("w-a")) { - return Promise.resolve(Response.json([{ + return Promise.resolve(Response.json({ workspace_id: "w-a", - repository_id: "main", - name: "Main", - kind: "local_path", - uri: "/srv/alpha", - default_ref: "develop", - }])); + items: [{ + id: "main", + display_name: "Main", + kind: "git", + provider: "git", + source: { kind: "local_path", uri: "/srv/alpha" }, + source_revision: 1, + source_fingerprint: "sha256:alpha", + observed_status: "ready", + default_selector: "develop", + record_authority: "workspace-control-plane", + }], + source: "workspace-control-plane", + diagnostics: [], + })); } return Promise.resolve(new Response("unavailable", { status: 503 })); }; const items = await loadWorkspaceCatalog(fetcher as typeof fetch); assertEquals(items.length, 2); - assertEquals(items[0].repositories[0].repository_id, "main"); + assertEquals(items[0].repositories[0].id, "main"); assertEquals(items[1].repositories, []); assertEquals(typeof items[1].repository_error, "string"); }); diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts new file mode 100644 index 00000000..31b94b79 --- /dev/null +++ b/web/workspace/tests/workspace-model.test.ts @@ -0,0 +1,91 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +import { + parseRepositoryListResponse, + parseWorkspaceResponse, +} from "../src/lib/workspace/api/workspace-model.ts"; + +function assertThrows(operation: () => unknown, expected: string): void { + try { + operation(); + } catch (error) { + if (error instanceof Error && error.message.includes(expected)) return; + throw error; + } + throw new Error("expected operation to throw"); +} + +const repositoryList = { + workspace_id: "w-a", + items: [{ + id: "main", + display_name: "Main", + kind: "git", + provider: "git", + source: { kind: "local_path", uri: "/srv/alpha" }, + source_revision: 1, + source_fingerprint: "sha256:alpha", + observed_status: "ready", + record_authority: "workspace-control-plane", + }], + source: "workspace-control-plane", + diagnostics: [], +}; + +Deno.test("generated repository wrapper validates current Backend JSON", () => { + const parsed = parseRepositoryListResponse(repositoryList); + if (parsed.items[0]?.id !== "main") { + throw new Error("repository id was not preserved"); + } + if (parsed.items[0]?.source.kind !== "local_path") { + throw new Error("repository source kind was not preserved"); + } +}); + +Deno.test("stale repository aliases fail closed at the JSON boundary", () => { + const stale = structuredClone(repositoryList) as Record; + const items = stale.items as Array>; + items[0].repository_id = items[0].id; + delete items[0].id; + assertThrows( + () => parseRepositoryListResponse(stale), + ".repository_id is not part", + ); +}); + +Deno.test("workspace response requires the permission projection", () => { + const stale = { + workspace_id: "w-a", + display_name: "Alpha", + record_authority: "workspace-control-plane", + schema_version: 46, + auth: { + Passkey: { + rp_id: "example.test", + origin: "https://example.test", + public_base_url: "https://example.test", + cookie_name: "yoi_session", + }, + }, + extension_points: { + store: "sqlite", + event_stream: { status: "available", note: "ready", diagnostics: [] }, + host_worker_bridge: { + status: "available", + note: "ready", + diagnostics: [], + }, + companion_console: { + status: "available", + note: "ready", + diagnostics: [], + }, + }, + }; + assertThrows( + () => parseWorkspaceResponse(stale), + "permissions must be an object", + ); +});