chore: merge current develop into T-588

This commit is contained in:
2026-09-06 03:45:30 +09:00
18 changed files with 2041 additions and 635 deletions
+1 -1
View File
@@ -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 src/lib/workspace/console/composer-delivery.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 src/lib/workspace/console/composer-delivery.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",
);
});