chore: merge develop into hare/develop
# Conflicts: # crates/client/src/lib.rs # web/workspace/deno.json
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 test/repository-access/ui.test.ts test/repositories/ui.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,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts 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 tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.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,79 @@
|
||||
// Generated from workspace-api. Do not edit by hand.
|
||||
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts
|
||||
|
||||
export type RepositorySshCredential = {
|
||||
credential_id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
public_key_algorithm: string;
|
||||
public_key_fingerprint: string;
|
||||
current_revision: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
rotated_at: string | null;
|
||||
referenced_repositories: Array<string>;
|
||||
};
|
||||
|
||||
export type CreateRepositorySshCredentialRequest = {
|
||||
operation_id: string;
|
||||
credential_id: string;
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string | null;
|
||||
};
|
||||
|
||||
export type RotateRepositorySshCredentialRequest = {
|
||||
operation_id: string;
|
||||
expected_revision: number;
|
||||
private_key: string;
|
||||
passphrase: string | null;
|
||||
};
|
||||
|
||||
export type DeleteRepositorySshCredentialRequest = {
|
||||
operation_id: string;
|
||||
expected_revision: number;
|
||||
};
|
||||
|
||||
export type RepositorySshHostTrust = {
|
||||
host_trust_id: string;
|
||||
workspace_id: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
key_algorithm: string;
|
||||
host_key: string;
|
||||
fingerprint: string;
|
||||
current_revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
referenced_repositories: Array<string>;
|
||||
};
|
||||
|
||||
export type PutRepositorySshHostTrustRequest = {
|
||||
operation_id: string;
|
||||
host_trust_id: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
host_key: string;
|
||||
expected_revision: number | null;
|
||||
};
|
||||
|
||||
export type DeleteRepositorySshHostTrustRequest = {
|
||||
operation_id: string;
|
||||
expected_revision: number;
|
||||
};
|
||||
|
||||
export type RepositoryAccessMode = "read_only" | "read_write";
|
||||
|
||||
export type RepositorySshAccessBinding = {
|
||||
repository_id: string;
|
||||
credential_id: string;
|
||||
host_trust_id: string;
|
||||
access: RepositoryAccessMode;
|
||||
};
|
||||
|
||||
export type RepositoryAccessProjection = {
|
||||
workspace_id: string;
|
||||
config_revision: number;
|
||||
projection_digest: string;
|
||||
bindings: Array<RepositorySshAccessBinding>;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// Generated from workspace-api. Do not edit by hand.
|
||||
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||
|
||||
export type WorkingDirectoryMaterializerKind = "runtime_git_cache" | "local_git_worktree";
|
||||
|
||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||
|
||||
export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, };
|
||||
|
||||
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, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
|
||||
|
||||
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_id: string, selector?: string | null, operation_id?: string | null, };
|
||||
|
||||
export type WorkingDirectoryListResponse = { workspace_id: string, items: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
|
||||
|
||||
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
||||
|
||||
export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
||||
@@ -0,0 +1,166 @@
|
||||
// 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 WorkspaceCatalogListResponse = Array<WorkspaceSummary>;
|
||||
|
||||
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<Diagnostic>;
|
||||
};
|
||||
|
||||
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<GitRemoteSummary>;
|
||||
};
|
||||
|
||||
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<RepositoryDiagnostic> | null;
|
||||
};
|
||||
|
||||
export type GitCommitSummary = {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
summary: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_date: string;
|
||||
parents: Array<string>;
|
||||
refs: Array<string>;
|
||||
};
|
||||
|
||||
export type RepositoryListResponse = {
|
||||
workspace_id: string;
|
||||
items: Array<RepositorySummary>;
|
||||
source: string;
|
||||
diagnostics: Array<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: Array<GitCommitSummary>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
@@ -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,45 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { RepositoryAccessSchemaError } from "./repository-access.ts";
|
||||
|
||||
export async function loadRepositoryAccessJson<T>(
|
||||
fetcher: typeof fetch,
|
||||
path: string,
|
||||
parse: (value: unknown) => T,
|
||||
): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(path, { headers: { accept: "application/json" } });
|
||||
} catch {
|
||||
error(503, { message: "Repository Access is temporarily unavailable." });
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
error(403, {
|
||||
message: "Repository Access is unavailable for this account.",
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
error(502, {
|
||||
message:
|
||||
`Repository Access request failed with status ${response.status}.`,
|
||||
});
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
error(502, {
|
||||
message: "Repository Access returned an invalid JSON response.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return parse(payload);
|
||||
} catch (cause) {
|
||||
if (cause instanceof RepositoryAccessSchemaError) {
|
||||
error(502, { message: cause.message });
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type {
|
||||
RepositoryAccessProjection,
|
||||
RepositorySshCredential,
|
||||
RepositorySshHostTrust,
|
||||
} from "../../generated/repository-access-api.ts";
|
||||
|
||||
export class RepositoryAccessSchemaError extends Error {
|
||||
constructor(path: string, expected: string) {
|
||||
super(
|
||||
`Repository Access response schema mismatch at ${path}: expected ${expected}`,
|
||||
);
|
||||
this.name = "RepositoryAccessSchemaError";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRepositorySshCredentials(
|
||||
value: unknown,
|
||||
): RepositorySshCredential[] {
|
||||
return readArray(value, "credentials").map((entry, index) =>
|
||||
parseRepositorySshCredential(entry, `credentials[${index}]`)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseRepositorySshCredential(
|
||||
value: unknown,
|
||||
path = "credential",
|
||||
): RepositorySshCredential {
|
||||
const record = readRecord(value, path, [
|
||||
"credential_id",
|
||||
"workspace_id",
|
||||
"name",
|
||||
"public_key_algorithm",
|
||||
"public_key_fingerprint",
|
||||
"current_revision",
|
||||
"status",
|
||||
"created_at",
|
||||
"rotated_at",
|
||||
"referenced_repositories",
|
||||
]);
|
||||
readString(record, "credential_id", path);
|
||||
readString(record, "workspace_id", path);
|
||||
readString(record, "name", path);
|
||||
readString(record, "public_key_algorithm", path);
|
||||
readString(record, "public_key_fingerprint", path);
|
||||
readRevision(record, "current_revision", path);
|
||||
readString(record, "status", path);
|
||||
readString(record, "created_at", path);
|
||||
readNullableString(record, "rotated_at", path);
|
||||
readStringArray(record, "referenced_repositories", path);
|
||||
return record as RepositorySshCredential;
|
||||
}
|
||||
|
||||
export function parseRepositorySshHostTrusts(
|
||||
value: unknown,
|
||||
): RepositorySshHostTrust[] {
|
||||
return readArray(value, "host_trusts").map((entry, index) =>
|
||||
parseRepositorySshHostTrust(entry, `host_trusts[${index}]`)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseRepositorySshHostTrust(
|
||||
value: unknown,
|
||||
path = "host_trust",
|
||||
): RepositorySshHostTrust {
|
||||
const record = readRecord(value, path, [
|
||||
"host_trust_id",
|
||||
"workspace_id",
|
||||
"hostname",
|
||||
"port",
|
||||
"key_algorithm",
|
||||
"host_key",
|
||||
"fingerprint",
|
||||
"current_revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"referenced_repositories",
|
||||
]);
|
||||
readString(record, "host_trust_id", path);
|
||||
readString(record, "workspace_id", path);
|
||||
readString(record, "hostname", path);
|
||||
const port = readInteger(record, "port", path);
|
||||
if (port < 1 || port > 65_535) {
|
||||
throw new RepositoryAccessSchemaError(
|
||||
`${path}.port`,
|
||||
"an integer from 1 to 65535",
|
||||
);
|
||||
}
|
||||
readString(record, "key_algorithm", path);
|
||||
readString(record, "host_key", path);
|
||||
readString(record, "fingerprint", path);
|
||||
readRevision(record, "current_revision", path);
|
||||
readString(record, "created_at", path);
|
||||
readString(record, "updated_at", path);
|
||||
readStringArray(record, "referenced_repositories", path);
|
||||
return record as RepositorySshHostTrust;
|
||||
}
|
||||
|
||||
export function parseRepositoryAccessProjection(
|
||||
value: unknown,
|
||||
): RepositoryAccessProjection {
|
||||
const path = "access_projection";
|
||||
const record = readRecord(value, path, [
|
||||
"workspace_id",
|
||||
"config_revision",
|
||||
"projection_digest",
|
||||
"bindings",
|
||||
]);
|
||||
readString(record, "workspace_id", path);
|
||||
readRevision(record, "config_revision", path);
|
||||
readString(record, "projection_digest", path);
|
||||
const bindings = readArray(record.bindings, `${path}.bindings`);
|
||||
bindings.forEach((binding, index) => {
|
||||
const bindingPath = `${path}.bindings[${index}]`;
|
||||
const bindingRecord = readRecord(binding, bindingPath, [
|
||||
"repository_id",
|
||||
"credential_id",
|
||||
"host_trust_id",
|
||||
"access",
|
||||
]);
|
||||
readString(bindingRecord, "repository_id", bindingPath);
|
||||
readString(bindingRecord, "credential_id", bindingPath);
|
||||
readString(bindingRecord, "host_trust_id", bindingPath);
|
||||
const access = readString(bindingRecord, "access", bindingPath);
|
||||
if (access !== "read_only" && access !== "read_write") {
|
||||
throw new RepositoryAccessSchemaError(
|
||||
`${bindingPath}.access`,
|
||||
'"read_only" or "read_write"',
|
||||
);
|
||||
}
|
||||
});
|
||||
return record as RepositoryAccessProjection;
|
||||
}
|
||||
|
||||
function readRecord(
|
||||
value: unknown,
|
||||
path: string,
|
||||
allowedKeys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new RepositoryAccessSchemaError(path, "an object");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const unknownKey = Object.keys(record).find((key) =>
|
||||
!allowedKeys.includes(key)
|
||||
);
|
||||
if (unknownKey !== undefined) {
|
||||
throw new RepositoryAccessSchemaError(
|
||||
`${path}.${unknownKey}`,
|
||||
"no unknown field",
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function readArray(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new RepositoryAccessSchemaError(path, "an array");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") {
|
||||
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readNullableString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
const value = record[key];
|
||||
if (value !== null && typeof value !== "string") {
|
||||
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string or null");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringArray(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
): string[] {
|
||||
const values = readArray(record[key], `${path}.${key}`);
|
||||
values.forEach((value, index) => {
|
||||
if (typeof value !== "string") {
|
||||
throw new RepositoryAccessSchemaError(
|
||||
`${path}.${key}[${index}]`,
|
||||
"a string",
|
||||
);
|
||||
}
|
||||
});
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function readInteger(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
): number {
|
||||
const value = record[key];
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
||||
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a safe integer");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readRevision(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
): number {
|
||||
const revision = readInteger(record, key, path);
|
||||
if (revision < 0) {
|
||||
throw new RepositoryAccessSchemaError(
|
||||
`${path}.${key}`,
|
||||
"a non-negative safe integer",
|
||||
);
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
@@ -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,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<WorkspaceCatalogRecord[]> {
|
||||
return await fetchJson<WorkspaceCatalogRecord[]>(
|
||||
fetcher,
|
||||
"/api/workspaces?limit=200",
|
||||
return parseWorkspaceCatalogResponse(
|
||||
await fetchJson(fetcher, "/api/workspaces?limit=200"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listWorkspaceRepositories(
|
||||
fetcher: Fetch,
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceRepositoryRecord[]> {
|
||||
return await fetchJson<WorkspaceRepositoryRecord[]>(
|
||||
fetcher,
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
|
||||
);
|
||||
): Promise<RepositorySummary[]> {
|
||||
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<CreateWorkspaceResponse> {
|
||||
return await fetchJson<CreateWorkspaceResponse>(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<T>(
|
||||
async function fetchJson(
|
||||
fetcher: Fetch,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(input, init);
|
||||
@@ -172,7 +144,7 @@ async function fetchJson<T>(
|
||||
}
|
||||
throw new WorkspaceCatalogError(response.status, detail);
|
||||
}
|
||||
return await response.json() as T;
|
||||
return await response.json() as unknown;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
import type {
|
||||
Diagnostic,
|
||||
GitCommitSummary,
|
||||
GitRemoteSummary,
|
||||
GitRepositorySummary,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryDiagnostic,
|
||||
RepositoryListResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositorySource,
|
||||
RepositorySourceKind,
|
||||
RepositorySummary,
|
||||
WorkspaceAuthConfig,
|
||||
WorkspaceCatalogListResponse,
|
||||
WorkspaceCreateResponse,
|
||||
WorkspaceExtensionPoints,
|
||||
WorkspaceExtensionPointState,
|
||||
WorkspacePermissionSummary,
|
||||
WorkspaceRepositoryRecord,
|
||||
WorkspaceResponse,
|
||||
WorkspaceSummary,
|
||||
} from "$lib/generated/workspace-api.ts";
|
||||
|
||||
export type {
|
||||
GitCommitSummary,
|
||||
GitRemoteSummary,
|
||||
GitRepositorySummary,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryListResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositorySummary,
|
||||
WorkspaceCatalogListResponse,
|
||||
WorkspaceCreateResponse,
|
||||
WorkspacePermissionSummary,
|
||||
WorkspaceResponse,
|
||||
WorkspaceSummary,
|
||||
} from "$lib/generated/workspace-api.ts";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const SOURCE_KINDS = new Set<RepositorySourceKind>([
|
||||
"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,
|
||||
): WorkspaceCatalogListResponse {
|
||||
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 parseRepositoryListApiResult(
|
||||
result: ApiResult<unknown>,
|
||||
): ApiResult<RepositoryListResponse> {
|
||||
if (result.data === null) return { data: null, error: result.error };
|
||||
try {
|
||||
return { data: parseRepositoryListResponse(result.data), error: null };
|
||||
} catch (cause) {
|
||||
return {
|
||||
data: null,
|
||||
error: cause instanceof Error
|
||||
? cause.message
|
||||
: "invalid repository list response",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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}]`),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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);
|
||||
assertEquals(
|
||||
parseCompanionTranscriptProjection({
|
||||
state: "stopped",
|
||||
start: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
next: null,
|
||||
items: [],
|
||||
}),
|
||||
{
|
||||
state: "stopped",
|
||||
start: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
next: null,
|
||||
items: [],
|
||||
},
|
||||
);
|
||||
|
||||
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 > 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;
|
||||
}
|
||||
@@ -1,27 +1,39 @@
|
||||
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";
|
||||
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 {
|
||||
PodProtocolEvent,
|
||||
PodProtocolMethod,
|
||||
PodProtocolSegment,
|
||||
WorkingDirectoryCreateRequest,
|
||||
WorkingDirectoryCreateResponse,
|
||||
WorkingDirectoryDetailResponse,
|
||||
WorkingDirectoryListResponse,
|
||||
WorkingDirectoryOccupancy,
|
||||
WorkingDirectorySummary,
|
||||
};
|
||||
export type WorkspaceResponse = SharedWorkspaceResponse;
|
||||
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
@@ -111,44 +123,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 +191,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[];
|
||||
@@ -257,70 +225,13 @@ export type ListResponse<T> = {
|
||||
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;
|
||||
@@ -448,56 +359,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";
|
||||
|
||||
@@ -132,9 +132,9 @@
|
||||
<code>{workspace.workspace_id}</code>
|
||||
{#if workspace.repositories[0]}
|
||||
<span class="workspace-repository-summary">
|
||||
{workspace.repositories[0].name}
|
||||
{workspace.repositories[0].display_name}
|
||||
<small>
|
||||
{workspace.repositories[0].default_ref ?? "repository default"} ·
|
||||
{workspace.repositories[0].default_selector ?? "repository default"} ·
|
||||
{workspace.repositories[0].kind}
|
||||
</small>
|
||||
</span>
|
||||
|
||||
@@ -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<WorkspaceResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/workspace"),
|
||||
),
|
||||
loadJson<RepositoryListResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/repositories"),
|
||||
),
|
||||
const [workspaceResult, repositoryResult] = await Promise.all([
|
||||
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
|
||||
loadJson<unknown>(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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<RepositoryDetailResponse>(
|
||||
const [repositoryResult, logResult] = await Promise.all([
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/repositories/${encodeURIComponent(repositoryId)}`,
|
||||
),
|
||||
),
|
||||
loadJson<RepositoryLogResponse>(
|
||||
loadJson<unknown>(
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type {
|
||||
CreateRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshHostTrustRequest,
|
||||
PutRepositorySshHostTrustRequest,
|
||||
RepositorySshCredential,
|
||||
RepositorySshHostTrust,
|
||||
RotateRepositorySshCredentialRequest,
|
||||
} from '$lib/generated/repository-access-api';
|
||||
import {
|
||||
parseRepositorySshCredential,
|
||||
parseRepositorySshHostTrust,
|
||||
} from '$lib/workspace/api/repository-access';
|
||||
import type { PageProps } from './$types';
|
||||
import type { RepositorySshCredential, RepositorySshHostTrust } from './+page';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||
const accessProjection = untrack(() => data.accessProjection);
|
||||
let message = $state<string | null>(null);
|
||||
let pending = $state(false);
|
||||
|
||||
@@ -29,37 +42,52 @@
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, method: string, body: unknown): Promise<T> {
|
||||
function request<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
body: unknown,
|
||||
parse: (value: unknown) => T
|
||||
): Promise<T>;
|
||||
function request(path: string, method: string, body: unknown, parse: null): Promise<void>;
|
||||
async function request<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
body: unknown,
|
||||
parse: ((value: unknown) => T) | null
|
||||
): Promise<T | undefined> {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = `request failed (${response.status})`;
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string; message?: string };
|
||||
detail = payload.message ?? payload.error ?? detail;
|
||||
} catch {
|
||||
// Do not surface submitted secret values from response bodies.
|
||||
}
|
||||
throw new Error(detail);
|
||||
throw new Error(`Repository Access request failed with status ${response.status}.`);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
if (response.status === 204) return undefined;
|
||||
const payload: unknown = await response.json();
|
||||
if (parse === null) {
|
||||
throw new Error('Repository Access returned an unexpected response body.');
|
||||
}
|
||||
return parse(payload);
|
||||
}
|
||||
|
||||
async function createCredential() {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const created = await request<RepositorySshCredential>('/credentials', 'POST', {
|
||||
const body: CreateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-create'),
|
||||
credential_id: credentialId,
|
||||
name: credentialName,
|
||||
private_key: privateKey,
|
||||
passphrase: passphrase || null
|
||||
});
|
||||
};
|
||||
const created = await request<RepositorySshCredential>(
|
||||
'/credentials',
|
||||
'POST',
|
||||
body,
|
||||
parseRepositorySshCredential
|
||||
);
|
||||
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
||||
credentialId = '';
|
||||
credentialName = '';
|
||||
@@ -77,15 +105,17 @@
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const body: RotateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-rotate'),
|
||||
expected_revision: credential.current_revision,
|
||||
private_key: rotatePrivateKey,
|
||||
passphrase: rotatePassphrase || null
|
||||
};
|
||||
const rotated = await request<RepositorySshCredential>(
|
||||
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
|
||||
'POST',
|
||||
{
|
||||
operation_id: operationId('credential-rotate'),
|
||||
expected_revision: credential.current_revision,
|
||||
private_key: rotatePrivateKey,
|
||||
passphrase: rotatePassphrase || null
|
||||
}
|
||||
body,
|
||||
parseRepositorySshCredential
|
||||
);
|
||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||
rotateCredentialId = null;
|
||||
@@ -104,10 +134,16 @@
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', {
|
||||
const body: DeleteRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-delete'),
|
||||
expected_revision: credential.current_revision
|
||||
});
|
||||
};
|
||||
await request(
|
||||
`/credentials/${encodeURIComponent(credential.credential_id)}`,
|
||||
'DELETE',
|
||||
body,
|
||||
null
|
||||
);
|
||||
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
||||
message = `Credential ${credential.credential_id} deleted.`;
|
||||
} catch (error) {
|
||||
@@ -121,14 +157,20 @@
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', {
|
||||
const body: PutRepositorySshHostTrustRequest = {
|
||||
operation_id: operationId('host-trust-create'),
|
||||
host_trust_id: hostTrustId,
|
||||
hostname,
|
||||
port,
|
||||
host_key: hostKey,
|
||||
expected_revision: hostExpectedRevision
|
||||
});
|
||||
};
|
||||
const created = await request<RepositorySshHostTrust>(
|
||||
'/host-trusts',
|
||||
'POST',
|
||||
body,
|
||||
parseRepositorySshHostTrust
|
||||
);
|
||||
hostTrusts = hostExpectedRevision === null
|
||||
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
|
||||
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
|
||||
@@ -158,10 +200,16 @@
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', {
|
||||
const body: DeleteRepositorySshHostTrustRequest = {
|
||||
operation_id: operationId('host-trust-delete'),
|
||||
expected_revision: hostTrust.current_revision
|
||||
});
|
||||
};
|
||||
await request(
|
||||
`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`,
|
||||
'DELETE',
|
||||
body,
|
||||
null
|
||||
);
|
||||
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
||||
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
|
||||
} catch (error) {
|
||||
@@ -182,6 +230,18 @@
|
||||
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
||||
{#if message}<p class="status-message">{message}</p>{/if}
|
||||
|
||||
<div class="settings-runtime-list">
|
||||
<h3>Active access projection</h3>
|
||||
<p>Config revision {accessProjection.config_revision} · <code>{accessProjection.projection_digest}</code></p>
|
||||
{#if accessProjection.bindings.length === 0}<p>No repository access bindings are active.</p>{/if}
|
||||
{#each accessProjection.bindings as binding (binding.repository_id)}
|
||||
<div class="card">
|
||||
<strong>{binding.repository_id}</strong>
|
||||
<p>{binding.access} · credential <code>{binding.credential_id}</code> · host trust <code>{binding.host_trust_id}</code></p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="settings-runtime-list">
|
||||
<h3>SSH credentials</h3>
|
||||
{#if credentials.length === 0}<p>No credentials configured.</p>{/if}
|
||||
|
||||
@@ -1,50 +1,36 @@
|
||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
parseRepositoryAccessProjection,
|
||||
parseRepositorySshCredentials,
|
||||
parseRepositorySshHostTrusts,
|
||||
} from "$lib/workspace/api/repository-access";
|
||||
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
|
||||
import type { PageLoad } from "./$types";
|
||||
import { loadJson } from "$lib/workspace/api/http";
|
||||
|
||||
export interface RepositorySshCredential {
|
||||
credential_id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
public_key_algorithm: string;
|
||||
public_key_fingerprint: string;
|
||||
current_revision: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
rotated_at: string | null;
|
||||
referenced_repositories: string[];
|
||||
}
|
||||
|
||||
export interface RepositorySshHostTrust {
|
||||
host_trust_id: string;
|
||||
workspace_id: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
key_algorithm: string;
|
||||
host_key: string;
|
||||
fingerprint: string;
|
||||
current_revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
referenced_repositories: string[];
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const base = `/api/w/${
|
||||
encodeURIComponent(params.workspaceId)
|
||||
}/settings/repository-access`;
|
||||
const [credentialResult, hostTrustResult] = await Promise.all([
|
||||
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`),
|
||||
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
|
||||
const workspaceId = params.workspaceId;
|
||||
const accessProjection = await loadRepositoryAccessJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/settings/repository-access"),
|
||||
parseRepositoryAccessProjection,
|
||||
);
|
||||
const [credentials, hostTrusts] = await Promise.all([
|
||||
loadRepositoryAccessJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/settings/repository-access/credentials"),
|
||||
parseRepositorySshCredentials,
|
||||
),
|
||||
loadRepositoryAccessJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/settings/repository-access/host-trusts"),
|
||||
parseRepositorySshHostTrusts,
|
||||
),
|
||||
]);
|
||||
if (!credentialResult.data || !hostTrustResult.data) {
|
||||
throw new Error(
|
||||
credentialResult.error ?? hostTrustResult.error ??
|
||||
"Repository access settings unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
credentials: credentialResult.data,
|
||||
hostTrusts: hostTrustResult.data,
|
||||
workspaceId,
|
||||
credentials,
|
||||
hostTrusts,
|
||||
accessProjection,
|
||||
};
|
||||
};
|
||||
|
||||
+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,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { parseRepositoryListApiResult } from "$lib/workspace/api/workspace-model";
|
||||
import {
|
||||
canonicalResourceReference,
|
||||
resourceKey,
|
||||
} from "$lib/workspace/resource-links";
|
||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||
import type {
|
||||
RepositoryListResponse,
|
||||
TicketDetail,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
import type { TicketDetail } from "$lib/workspace/sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
@@ -17,9 +15,9 @@ export const load = (async ({ fetch, params }) => {
|
||||
params.workspaceId,
|
||||
`/tickets/${encodeURIComponent(reference)}`,
|
||||
);
|
||||
const [ticket, repositories, orchestrator] = await Promise.all([
|
||||
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
|
||||
loadJson<TicketDetail>(fetch, ticketPath),
|
||||
loadJson<RepositoryListResponse>(
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/repositories"),
|
||||
),
|
||||
@@ -42,6 +40,8 @@ export const load = (async ({ fetch, params }) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
const repositories = parseRepositoryListApiResult(repositoriesRaw);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
ticketId: ticket.data?.id ?? reference,
|
||||
|
||||
@@ -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,133 @@
|
||||
import {
|
||||
parseRepositoryAccessProjection,
|
||||
parseRepositorySshCredentials,
|
||||
parseRepositorySshHostTrusts,
|
||||
RepositoryAccessSchemaError,
|
||||
} from "../../src/lib/workspace/api/repository-access.ts";
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSchemaError(body: () => unknown, path: string): void {
|
||||
try {
|
||||
body();
|
||||
} catch (error) {
|
||||
if (!(error instanceof RepositoryAccessSchemaError)) {
|
||||
throw error;
|
||||
}
|
||||
if (!error.message.includes(path)) {
|
||||
throw new Error(
|
||||
`expected schema error path ${path}, got ${error.message}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected RepositoryAccessSchemaError for ${path}`);
|
||||
}
|
||||
|
||||
const credential = {
|
||||
credential_id: "deploy-key",
|
||||
workspace_id: "workspace-1",
|
||||
name: "Deploy key",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
current_revision: 2,
|
||||
status: "active",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
referenced_repositories: ["main"],
|
||||
};
|
||||
|
||||
const hostTrust = {
|
||||
host_trust_id: "gitea",
|
||||
workspace_id: "workspace-1",
|
||||
hostname: "gitea.example.test",
|
||||
port: 22,
|
||||
key_algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA",
|
||||
fingerprint: "SHA256:host",
|
||||
current_revision: 3,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
referenced_repositories: ["main"],
|
||||
};
|
||||
|
||||
Deno.test("Repository Access parsers accept generated response contracts", () => {
|
||||
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
|
||||
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
|
||||
assertEquals(
|
||||
parseRepositoryAccessProjection({
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_id: "main",
|
||||
credential_id: "deploy-key",
|
||||
host_trust_id: "gitea",
|
||||
access: "read_only",
|
||||
}],
|
||||
}),
|
||||
{
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_id: "main",
|
||||
credential_id: "deploy-key",
|
||||
host_trust_id: "gitea",
|
||||
access: "read_only",
|
||||
}],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access parsers reject malformed list responses", () => {
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshCredentials({ credentials: [credential] }),
|
||||
"credentials",
|
||||
);
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshHostTrusts({ host_trusts: [hostTrust] }),
|
||||
"host_trusts",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access parsers reject missing and wrong-typed fields", () => {
|
||||
const { current_revision: _revision, ...missingRevision } = credential;
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshCredentials([missingRevision]),
|
||||
"credentials[0].current_revision",
|
||||
);
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshHostTrusts([{ ...hostTrust, port: "22" }]),
|
||||
"host_trusts[0].port",
|
||||
);
|
||||
assertSchemaError(
|
||||
() =>
|
||||
parseRepositoryAccessProjection({
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_id: "main",
|
||||
credential_id: "deploy-key",
|
||||
host_trust_id: "gitea",
|
||||
access: "admin",
|
||||
}],
|
||||
}),
|
||||
"access_projection.bindings[0].access",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access parsers reject unknown response fields", () => {
|
||||
assertSchemaError(
|
||||
() =>
|
||||
parseRepositorySshCredentials([{ ...credential, private_key: "secret" }]),
|
||||
"credentials[0].private_key",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { loadRepositoryAccessJson } from "../../src/lib/workspace/api/repository-access-loader.ts";
|
||||
import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts";
|
||||
|
||||
type HttpFailure = { status?: number; body?: { message?: string } };
|
||||
|
||||
async function captureHttpFailure(
|
||||
run: () => Promise<unknown>,
|
||||
expectedStatus: number,
|
||||
expectedMessage: string,
|
||||
): Promise<HttpFailure> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
const failure = error as HttpFailure;
|
||||
if (failure.status !== expectedStatus) {
|
||||
throw new Error(
|
||||
`expected bounded ${expectedStatus}, got ${String(failure.status)}`,
|
||||
);
|
||||
}
|
||||
if (failure.body?.message !== expectedMessage) {
|
||||
throw new Error(
|
||||
`unexpected bounded error: ${JSON.stringify(failure.body)}`,
|
||||
);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
throw new Error(`expected bounded ${expectedStatus} error`);
|
||||
}
|
||||
|
||||
for (const status of [401, 403]) {
|
||||
Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => {
|
||||
let requests = 0;
|
||||
await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessJson(
|
||||
() => {
|
||||
requests += 1;
|
||||
return Promise.resolve(new Response(null, { status }));
|
||||
},
|
||||
"/api/w/workspace-1/settings/repository-access",
|
||||
(value) => value,
|
||||
),
|
||||
403,
|
||||
"Repository Access is unavailable for this account.",
|
||||
);
|
||||
if (requests !== 1) {
|
||||
throw new Error(`expected one bounded request, got ${requests}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Deno.test("Repository Access loader maps invalid JSON to safe bounded 502", async () => {
|
||||
const upstreamSecret = "private-key-must-not-leak";
|
||||
const failure = await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessJson(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response(upstreamSecret, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||
(value) => value,
|
||||
),
|
||||
502,
|
||||
"Repository Access returned an invalid JSON response.",
|
||||
);
|
||||
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
|
||||
throw new Error("invalid JSON error exposed upstream response content");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502", async () => {
|
||||
const failure = await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessJson(
|
||||
() => Promise.resolve(Response.json({ stale: true })),
|
||||
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||
() => {
|
||||
throw new RepositoryAccessSchemaError("credentials", "an array");
|
||||
},
|
||||
),
|
||||
502,
|
||||
"Repository Access response schema mismatch at credentials: expected an array",
|
||||
);
|
||||
if (!failure.body?.message?.includes("credentials")) {
|
||||
throw new Error("schema mismatch error omitted the failing response path");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository Access loader never exposes failed upstream response bodies", async () => {
|
||||
const upstreamSecret = "secret-ref-must-not-leak";
|
||||
const failure = await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessJson(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json(
|
||||
{ message: upstreamSecret, secret_ref: upstreamSecret },
|
||||
{ status: 500 },
|
||||
),
|
||||
),
|
||||
"/api/w/workspace-1/settings/repository-access/host-trusts",
|
||||
(value) => value,
|
||||
),
|
||||
502,
|
||||
"Repository Access request failed with status 500.",
|
||||
);
|
||||
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
|
||||
throw new Error("bounded upstream error exposed response content");
|
||||
}
|
||||
});
|
||||
@@ -13,6 +13,58 @@ const source = await Deno.readTextFile(
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const loaderSource = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
test("Repository Access Web code consumes workspace-api generated DTOs", () => {
|
||||
assert(
|
||||
source.includes("$lib/generated/repository-access-api"),
|
||||
"mutation code should import generated request and response contracts",
|
||||
);
|
||||
assert(
|
||||
loaderSource.includes("parseRepositorySshCredentials") &&
|
||||
loaderSource.includes("parseRepositorySshHostTrusts") &&
|
||||
loaderSource.includes("parseRepositoryAccessProjection"),
|
||||
"loader should validate unknown JSON before exposing generated DTOs to Svelte",
|
||||
);
|
||||
assert(
|
||||
loaderSource.indexOf('"/settings/repository-access"') <
|
||||
loaderSource.indexOf("Promise.all"),
|
||||
"loader should check Repository Access permission before starting list preloads",
|
||||
);
|
||||
for (
|
||||
const duplicate of [
|
||||
"interface RepositorySshCredential",
|
||||
"interface RepositorySshHostTrust",
|
||||
"interface RepositoryAccessProjection",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
!loaderSource.includes(duplicate) && !source.includes(duplicate),
|
||||
`Web code must not redeclare ${duplicate}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Repository Access renders the shared access projection fields", () => {
|
||||
for (
|
||||
const field of [
|
||||
"accessProjection.config_revision",
|
||||
"accessProjection.projection_digest",
|
||||
"accessProjection.bindings",
|
||||
"binding.repository_id",
|
||||
"binding.credential_id",
|
||||
"binding.host_trust_id",
|
||||
"binding.access",
|
||||
]
|
||||
) {
|
||||
assert(source.includes(field), `missing access projection field ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||
const createStart = source.indexOf("async function createCredential()");
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import {
|
||||
parseRepositoryListApiResult,
|
||||
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<string, unknown>;
|
||||
const items = stale.items as Array<Record<string, unknown>>;
|
||||
items[0].repository_id = items[0].id;
|
||||
delete items[0].id;
|
||||
assertThrows(
|
||||
() => parseRepositoryListResponse(stale),
|
||||
".repository_id is not part",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("repository API result converts stale payloads into bounded page errors", () => {
|
||||
const result = parseRepositoryListApiResult({
|
||||
data: {
|
||||
workspace_id: "w-a",
|
||||
items: { main: repositoryList.items[0] },
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
if (result.data !== null) {
|
||||
throw new Error("stale payload must not reach the page");
|
||||
}
|
||||
if (!result.error?.includes("items must be an array")) {
|
||||
throw new Error(`unexpected bounded error: ${result.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user