feat: add versioned runtime connection ping
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,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.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 test/sidebar/worker-actions.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",
|
||||
"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 tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.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 tests/runtime-connection.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 test/sidebar/worker-actions.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"
|
||||
},
|
||||
|
||||
@@ -220,3 +220,27 @@ export type RepositoryLogResponse = {
|
||||
items: Array<GitCommitSummary>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||
|
||||
export type RuntimeConnectionTestFailureKind =
|
||||
| "authentication"
|
||||
| "authorization"
|
||||
| "network_unreachable"
|
||||
| "timeout"
|
||||
| "tls_or_transport"
|
||||
| "malformed_response"
|
||||
| "protocol_version_mismatch"
|
||||
| "runtime_identity_mismatch"
|
||||
| "configuration";
|
||||
|
||||
export type RuntimeConnectionTestResponse = {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
checked_at: string;
|
||||
status: RuntimeConnectionTestStatus;
|
||||
failure_kind: RuntimeConnectionTestFailureKind | null;
|
||||
expected_protocol_version: number;
|
||||
actual_protocol_version: number | null;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type {
|
||||
Diagnostic,
|
||||
RuntimeConnectionTestFailureKind,
|
||||
RuntimeConnectionTestResponse,
|
||||
} from "$lib/generated/workspace-api";
|
||||
|
||||
const RESPONSE_KEYS = [
|
||||
"workspace_id",
|
||||
"runtime_id",
|
||||
"checked_at",
|
||||
"status",
|
||||
"failure_kind",
|
||||
"expected_protocol_version",
|
||||
"actual_protocol_version",
|
||||
"diagnostics",
|
||||
] as const;
|
||||
const DIAGNOSTIC_KEYS = ["code", "severity", "message"] as const;
|
||||
const FAILURE_KINDS = new Set<RuntimeConnectionTestFailureKind>([
|
||||
"authentication",
|
||||
"authorization",
|
||||
"network_unreachable",
|
||||
"timeout",
|
||||
"tls_or_transport",
|
||||
"malformed_response",
|
||||
"protocol_version_mismatch",
|
||||
"runtime_identity_mismatch",
|
||||
"configuration",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(
|
||||
record: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
): boolean {
|
||||
const actual = Object.keys(record).sort();
|
||||
const wanted = [...expected].sort();
|
||||
return actual.length === wanted.length &&
|
||||
actual.every((key, index) => key === wanted[index]);
|
||||
}
|
||||
|
||||
function isBoundedString(value: unknown, max = 1024): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= max;
|
||||
}
|
||||
|
||||
function isProtocolVersion(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function parseDiagnostic(value: unknown): Diagnostic | null {
|
||||
if (!isRecord(value) || !hasExactKeys(value, DIAGNOSTIC_KEYS)) return null;
|
||||
if (!isBoundedString(value.code, 128) || !isBoundedString(value.message)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
value.severity !== "info" && value.severity !== "warning" &&
|
||||
value.severity !== "error"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: value.code,
|
||||
severity: value.severity,
|
||||
message: value.message,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeConnectionTestResponse(
|
||||
value: unknown,
|
||||
): RuntimeConnectionTestResponse | null {
|
||||
if (!isRecord(value) || !hasExactKeys(value, RESPONSE_KEYS)) return null;
|
||||
if (
|
||||
!isBoundedString(value.workspace_id, 256) ||
|
||||
!isBoundedString(value.runtime_id, 256) ||
|
||||
!isBoundedString(value.checked_at, 128) ||
|
||||
Number.isNaN(Date.parse(value.checked_at)) ||
|
||||
(value.status !== "compatible" && value.status !== "failed") ||
|
||||
!isProtocolVersion(value.expected_protocol_version) ||
|
||||
(value.actual_protocol_version !== null &&
|
||||
!isProtocolVersion(value.actual_protocol_version)) ||
|
||||
!Array.isArray(value.diagnostics) ||
|
||||
value.diagnostics.length > 16
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const failureKind = value.failure_kind;
|
||||
if (
|
||||
failureKind !== null &&
|
||||
!FAILURE_KINDS.has(failureKind as RuntimeConnectionTestFailureKind)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const diagnostics = value.diagnostics.map(parseDiagnostic);
|
||||
if (diagnostics.some((diagnostic) => diagnostic === null)) return null;
|
||||
if (
|
||||
(value.status === "compatible" &&
|
||||
(failureKind !== null ||
|
||||
value.actual_protocol_version !== value.expected_protocol_version ||
|
||||
diagnostics.length !== 0)) ||
|
||||
(value.status === "failed" && failureKind === null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
workspace_id: value.workspace_id,
|
||||
runtime_id: value.runtime_id,
|
||||
checked_at: value.checked_at,
|
||||
status: value.status,
|
||||
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
|
||||
expected_protocol_version: value.expected_protocol_version,
|
||||
actual_protocol_version: value.actual_protocol_version,
|
||||
diagnostics: diagnostics as Diagnostic[],
|
||||
};
|
||||
}
|
||||
|
||||
export async function testRuntimeConnection(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<RuntimeConnectionTestResponse> {
|
||||
const response = await fetchImpl(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/runtimes/${
|
||||
encodeURIComponent(runtimeId)
|
||||
}/connection-tests`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Connection test failed (${response.status})`);
|
||||
}
|
||||
const parsed = parseRuntimeConnectionTestResponse(await response.json());
|
||||
if (!parsed) {
|
||||
throw new Error("Connection test returned an invalid response");
|
||||
}
|
||||
if (parsed.workspace_id !== workspaceId || parsed.runtime_id !== runtimeId) {
|
||||
throw new Error(
|
||||
"Connection test response did not match the selected Runtime",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -655,6 +655,9 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const runtimeConnectionApi = await Deno.readTextFile(
|
||||
new URL("../api/runtime-connection.ts", import.meta.url),
|
||||
);
|
||||
const workdirsPage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte",
|
||||
@@ -678,9 +681,11 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
|
||||
runtimesPage.includes("Add remote Runtime") &&
|
||||
runtimesPage.includes("Open workdirs") &&
|
||||
runtimesPage.includes("settings-runtime-table") &&
|
||||
runtimesPage.includes(
|
||||
"/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests",
|
||||
) &&
|
||||
runtimesPage.includes("testRuntimeConnection") &&
|
||||
runtimesPage.includes("data.workspaceId") &&
|
||||
runtimesPage.includes("runtime.runtime_id") &&
|
||||
runtimeConnectionApi.includes("/runtimes/${") &&
|
||||
runtimeConnectionApi.includes("}/connection-tests") &&
|
||||
runtimesPage.includes(
|
||||
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs",
|
||||
),
|
||||
|
||||
@@ -339,6 +339,9 @@
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.settings-test-result.failed {
|
||||
border-inline-start: 3px solid var(--danger);
|
||||
}
|
||||
.settings-page {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { RuntimeConnectionTestResponse } from '$lib/generated/workspace-api';
|
||||
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import type { Diagnostic, Runtime } from '$lib/workspace/sidebar/types';
|
||||
import type { Runtime } from '$lib/workspace/sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type ConnectionTest = {
|
||||
runtime_id: string;
|
||||
checked_at: string;
|
||||
state: string;
|
||||
protocol_version?: string | null;
|
||||
compatibility_basis: string;
|
||||
capabilities: string[];
|
||||
health_result: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let runtimeId = $state('');
|
||||
let displayName = $state('');
|
||||
@@ -22,12 +13,31 @@
|
||||
let showAddRuntime = $state(false);
|
||||
let busyRuntimeId = $state<string | null>(null);
|
||||
let requestError = $state<string | null>(null);
|
||||
let testResults = $state<Record<string, ConnectionTest>>({});
|
||||
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
||||
|
||||
function runtimePlatform(runtime: Runtime): string {
|
||||
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
||||
}
|
||||
|
||||
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
|
||||
if (result.status === 'compatible') {
|
||||
return `Compatible · protocol v${result.actual_protocol_version}`;
|
||||
}
|
||||
switch (result.failure_kind) {
|
||||
case 'authentication': return 'Authentication failed';
|
||||
case 'authorization': return 'Permission or Workspace scope rejected';
|
||||
case 'network_unreachable': return 'Runtime unreachable';
|
||||
case 'timeout': return 'Connection timed out';
|
||||
case 'tls_or_transport': return 'TLS or transport failed';
|
||||
case 'malformed_response': return 'Runtime returned an invalid ping response';
|
||||
case 'protocol_version_mismatch':
|
||||
return `Incompatible protocol · expected v${result.expected_protocol_version}, received v${result.actual_protocol_version ?? 'unknown'}`;
|
||||
case 'runtime_identity_mismatch': return 'Runtime identity mismatch';
|
||||
case 'configuration': return 'Runtime connection test is not configured';
|
||||
default: return 'Connection test failed';
|
||||
}
|
||||
}
|
||||
|
||||
function managementLabel(runtime: Runtime): string {
|
||||
if (runtime.management?.built_in) return 'Built-in';
|
||||
if (runtime.management?.config_managed) return 'Managed remote';
|
||||
@@ -92,15 +102,7 @@
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(
|
||||
data.workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests`,
|
||||
),
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
const result = await response.json() as ConnectionTest;
|
||||
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
|
||||
testResults = { ...testResults, [runtime.runtime_id]: result };
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
@@ -229,10 +231,12 @@
|
||||
{/if}
|
||||
{#if testResults[runtime.runtime_id]}
|
||||
{@const result = testResults[runtime.runtime_id]}
|
||||
<div class="settings-test-result">
|
||||
<strong>Connection test: {result.state}</strong>
|
||||
<span>{result.health_result}</span>
|
||||
<small>{result.compatibility_basis} · {result.checked_at}</small>
|
||||
<div class:failed={result.status === 'failed'} class="settings-test-result">
|
||||
<strong>Connection test: {connectionTestSummary(result)}</strong>
|
||||
{#if result.diagnostics[0]}
|
||||
<span>{result.diagnostics[0].message}</span>
|
||||
{/if}
|
||||
<small>Checked {new Date(result.checked_at).toLocaleString()}</small>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import {
|
||||
parseRuntimeConnectionTestResponse,
|
||||
testRuntimeConnection,
|
||||
} from "../src/lib/workspace/api/runtime-connection.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 compatibleResponse(): Record<string, unknown> {
|
||||
return {
|
||||
workspace_id: "workspace-a",
|
||||
runtime_id: "runtime-a",
|
||||
checked_at: "2026-09-01T12:00:00Z",
|
||||
status: "compatible",
|
||||
failure_kind: null,
|
||||
expected_protocol_version: 1,
|
||||
actual_protocol_version: 1,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("runtime connection response accepts the exact compatible contract", () => {
|
||||
assertEquals(
|
||||
parseRuntimeConnectionTestResponse(compatibleResponse()),
|
||||
compatibleResponse(),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("runtime connection response rejects unknown fields and incoherent compatibility", () => {
|
||||
assertEquals(
|
||||
parseRuntimeConnectionTestResponse({
|
||||
...compatibleResponse(),
|
||||
capabilities: ["shell"],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assertEquals(
|
||||
parseRuntimeConnectionTestResponse({
|
||||
...compatibleResponse(),
|
||||
actual_protocol_version: 2,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assertEquals(
|
||||
parseRuntimeConnectionTestResponse({
|
||||
...compatibleResponse(),
|
||||
failure_kind: "timeout",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => {
|
||||
const failed = {
|
||||
...compatibleResponse(),
|
||||
status: "failed",
|
||||
failure_kind: "future_failure",
|
||||
actual_protocol_version: null,
|
||||
diagnostics: [],
|
||||
};
|
||||
assertEquals(parseRuntimeConnectionTestResponse(failed), null);
|
||||
assertEquals(
|
||||
parseRuntimeConnectionTestResponse({
|
||||
...failed,
|
||||
failure_kind: "timeout",
|
||||
diagnostics: Array.from({ length: 17 }, () => ({
|
||||
code: "timeout",
|
||||
severity: "error",
|
||||
message: "Timed out",
|
||||
})),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("runtime connection request rejects a mismatched response identity", async () => {
|
||||
const fetchImpl = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ ...compatibleResponse(), runtime_id: "runtime-b" }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
)) as typeof fetch;
|
||||
let message = "";
|
||||
try {
|
||||
await testRuntimeConnection("workspace-a", "runtime-a", fetchImpl);
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
assertEquals(
|
||||
message,
|
||||
"Connection test response did not match the selected Runtime",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user