Merge branch 'develop' into hare/develop

This commit is contained in:
2026-09-06 13:39:37 +09:00
107 changed files with 19952 additions and 5235 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 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 tests/runtime-management.test.ts tests/runtime-management-source.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"
},
+57 -6
View File
@@ -10,6 +10,37 @@ export type CompletionKind = "file";
export type WorkerStatus = "idle" | "running" | "paused" | "stopped";
export type WorkerCommandEnvelope = {
/**
* Caller-owned sequence. A controller accepts command ids in strictly
* increasing order for one execution generation.
*/
command_id: number, expected_execution_generation: number, expected_worker_state_revision: number, };
export type WorkerCommandKind = "resume" | "cancel" | "pause" | "compact" | "shutdown";
export type WorkerCommandDisposition = "accepted" | "stale_execution_generation" | "stale_worker_state_revision" | "stale_command_id" | "conflict" | "invalid_state";
export type WorkerCommandAcknowledgement = { command_id: number, command: WorkerCommandKind, disposition: WorkerCommandDisposition,
/**
* The complete authoritative state observed after command admission.
*/
state: WorkerStateSnapshot, };
export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling";
export type WorkerMaintenanceState = "compacting";
export type WorkerBusyState = { "kind": "run", "state": WorkerRunState } | { "kind": "maintenance", "state": WorkerMaintenanceState };
export type WorkerState = { "kind": "idle" } | { "kind": "busy", "state": WorkerBusyState };
export type WorkerStateSnapshot = { execution_generation: number, revision: number,
/**
* Highest lifecycle command id observed by this controller generation.
*/
last_command_id: number, state: WorkerState, };
export type TurnResult = "finished" | "paused";
export type InvokeKind = "user_send" | "notify" | "worker_event" | "system_reminder" | "wakeup";
@@ -103,7 +134,13 @@ entry_id: string,
*/
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
export type SessionSnapshot = { entries: Array<SessionSnapshotEntry>, };
export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, };
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, head_id: string | null, submissions: Array<PendingSubmissionSummary>, };
export type SubmissionDisposition = "started" | "queued";
export type SessionSnapshot = { pending_submissions: PendingSubmissionsSnapshot, entries: Array<SessionSnapshotEntry>, };
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
@@ -196,7 +233,16 @@ resource_key?: string | null,
/**
* Producer-owned monotonic revision for this Worker subject.
*/
subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null,
subject_revision: number,
/**
* Latest revisioned foreground state observed from the Worker. This remains
* absent until an authoritative Worker snapshot/event has been applied.
*/
worker_state?: WorkerStateSnapshot | null,
/**
* Runtime catalog lifecycle compatibility projection; not foreground-state authority.
*/
state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null,
/**
* Workspace-facing Repository key. Runtime producers leave this unset and
* Workspace Server projections replace `repository_id` with this field.
@@ -225,9 +271,9 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
export type Method = { "method": "run", "params": { input: Array<Segment>, } } | { "method": "notify", "params": { message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Event = { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
/**
* The attempt that just failed. 1 origin.
*/
@@ -241,7 +287,12 @@ summary: string,
* Full tool output. Absent when the tool chose to return
* summary-only, or when the result was pruned.
*/
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting, status: WorkerStatus,
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting,
/**
* Full revisioned live execution state. `Stopped` remains Runtime
* catalog authority and is deliberately not represented here.
*/
state: WorkerStateSnapshot,
/**
* Unfinished model output that has already streamed in the current
* run but is not yet represented by committed snapshot entries.
@@ -251,4 +302,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded.
*/
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
@@ -47,6 +47,72 @@ export type WorkspaceAuthConfig = {
export type WorkspacePermissionSummary = {
manage_repositories: boolean;
manage_secrets: boolean;
manage_runtimes: boolean;
delete_workspace: boolean;
};
export type WorkspaceDeletionState =
| "queued"
| "running"
| "blocked"
| "failed"
| "succeeded";
export type WorkspaceDeletionBlockerKind =
| "last_accessible_workspace"
| "revision_conflict"
| "dirty_workdir"
| "worker_removal_blocked"
| "workdir_removal_blocked"
| "retention_hold"
| "cleanup_unavailable";
export type WorkspaceDeletionBlocker = {
kind: WorkspaceDeletionBlockerKind;
resource_kind: string | null;
resource_key: string | null;
message: string;
};
export type WorkspaceDeletionResourceCounts = {
workers: number;
workdirs: number;
repositories: number;
runtime_bindings: number;
secrets: number;
artifacts: number;
};
export type WorkspaceDeletionPreflightResponse = {
workspace_id: string;
display_name: string;
/**
* Opaque persisted Workspace metadata revision used as a CAS fence.
*/
expected_revision: string;
can_delete: boolean;
resources: WorkspaceDeletionResourceCounts;
blockers: Array<WorkspaceDeletionBlocker>;
};
export type WorkspaceDeletionRequest = {
operation_id: string;
expected_revision: string;
confirmation: string;
};
export type WorkspaceDeletionOperationResponse = {
operation_id: string;
workspace_id: string;
display_name: string;
state: WorkspaceDeletionState;
resources: WorkspaceDeletionResourceCounts;
child_operation_ids: Array<string>;
blockers: Array<WorkspaceDeletionBlocker>;
failure_category: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
};
export type DiagnosticSeverity = "info" | "warning" | "error";
@@ -220,3 +286,129 @@ export type RepositoryLogResponse = {
items: Array<GitCommitSummary>;
diagnostics: Array<Diagnostic>;
};
export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http";
export type RuntimeSourceStatus = "active" | "reserved";
export type RuntimeIdentityAuthority =
| "runtime_registry_projection"
| "server_runtime_configuration";
export type RuntimeSourceSummary = {
kind: RuntimeSourceKind;
status: RuntimeSourceStatus;
identity_authority: RuntimeIdentityAuthority;
note: string;
};
export type RuntimeSummary = {
runtime_id: string;
label: string;
kind: string;
status: string;
source: RuntimeSourceSummary;
host_ids: Array<string>;
worker_creation_available: boolean;
os: string;
arch: string;
diagnostics: Array<Diagnostic>;
};
export type RuntimeManagementSummary = {
built_in: boolean;
config_managed: boolean;
removable: boolean;
endpoint_configured: boolean;
token_ref_configured: boolean;
};
export type WorkspaceRuntimeResource = {
management: RuntimeManagementSummary;
runtime_id: string;
label: string;
kind: string;
status: string;
source: RuntimeSourceSummary;
host_ids: Array<string>;
worker_creation_available: boolean;
os: string;
arch: string;
diagnostics: Array<Diagnostic>;
};
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
export type RuntimeTrustKeyState = {
status: RuntimeTrustKeyStatus;
fingerprint?: string | null;
revision?: number | null;
created_at?: string | null;
updated_at?: string | null;
revoked_at?: string | null;
};
export type RuntimeTrustAuditAction =
| "created"
| "replaced"
| "reactivated"
| "revoked";
export type RuntimeTrustAuditEntry = {
action: RuntimeTrustAuditAction;
actor_account_id: string;
old_fingerprint?: string | null;
new_fingerprint?: string | null;
revision: number;
at: string;
};
export type WorkspaceRuntimeDetail = {
workspace_id: string;
runtime: WorkspaceRuntimeResource;
endpoint?: string | null;
trust_key: RuntimeTrustKeyState;
recent_audit: Array<RuntimeTrustAuditEntry>;
};
export type RuntimeTrustKeyRevealResponse = { public_key: string };
export type PutRuntimeTrustKeyRequest = {
public_key: string;
expected_revision: number | null;
};
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
export type RuntimeTrustConflictResponse = {
error: RuntimeTrustConflictKind;
message: string;
current_revision?: number;
current_fingerprint?: string | null;
};
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;
}
@@ -0,0 +1,816 @@
import type {
Diagnostic,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeIdentityAuthority,
RuntimeManagementSummary,
RuntimeSourceKind,
RuntimeSourceStatus,
RuntimeSourceSummary,
RuntimeTrustAuditAction,
RuntimeTrustAuditEntry,
RuntimeTrustConflictKind,
RuntimeTrustConflictResponse,
RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyState,
RuntimeTrustKeyStatus,
WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
} from "$lib/generated/workspace-api.ts";
import type { ListResponse } from "$lib/workspace/sidebar/types";
import { workspaceApiPath } from "./http.ts";
export type WorkspaceRuntimeList = ListResponse<WorkspaceRuntimeResource>;
const LIMITS = {
runtimeItems: 200,
auditEntries: 20,
hostIds: 128,
diagnostics: 64,
idBytes: 256,
labelBytes: 512,
kindBytes: 128,
statusBytes: 128,
noteBytes: 2_048,
endpointBytes: 4_096,
publicKeyBytes: 16 * 1_024,
fingerprintBytes: 512,
timestampBytes: 128,
diagnosticCodeBytes: 128,
diagnosticMessageBytes: 2_048,
conflictMessageBytes: 1_024,
responseBytes: 512 * 1_024,
} as const;
const SOURCE_KINDS = new Set<RuntimeSourceKind>([
"embedded_worker_runtime",
"remote_http",
]);
const SOURCE_STATUSES = new Set<RuntimeSourceStatus>(["active", "reserved"]);
const IDENTITY_AUTHORITIES = new Set<RuntimeIdentityAuthority>([
"runtime_registry_projection",
"server_runtime_configuration",
]);
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
const TRUST_STATUSES = new Set<RuntimeTrustKeyStatus>([
"unconfigured",
"active",
"revoked",
]);
const AUDIT_ACTIONS = new Set<RuntimeTrustAuditAction>([
"created",
"replaced",
"reactivated",
"revoked",
]);
const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
"stale_revision",
"fingerprint_in_use",
]);
const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>;
export class RuntimeManagementValidationError extends Error {
constructor(message: string) {
super(message.slice(0, 256));
this.name = "RuntimeManagementValidationError";
}
}
export class RuntimeTrustConflictError extends Error {
readonly conflict: RuntimeTrustConflictResponse;
constructor(conflict: RuntimeTrustConflictResponse) {
super(conflict.message);
this.name = "RuntimeTrustConflictError";
this.conflict = conflict;
}
}
export class RuntimeTrustRequestError extends Error {
readonly field: "public_key" | null;
constructor(message: string, field: "public_key" | null = null) {
super(message.slice(0, 256));
this.name = "RuntimeTrustRequestError";
this.field = field;
}
}
export type RuntimeTrustRouteOperation = Readonly<{
runtimeId: string;
generation: number;
}>;
export class RuntimeTrustRouteFence {
#runtimeId: string | null = null;
#generation = 0;
enter(runtimeId: string): number {
if (this.#runtimeId !== runtimeId) {
this.#runtimeId = runtimeId;
this.#generation += 1;
}
return this.#generation;
}
capture(runtimeId: string): RuntimeTrustRouteOperation {
return { runtimeId, generation: this.enter(runtimeId) };
}
isCurrent(operation: RuntimeTrustRouteOperation, runtimeId: string): boolean {
return operation.runtimeId === runtimeId &&
operation.generation === this.#generation &&
this.#runtimeId === runtimeId;
}
}
function fail(path: string, message: string): never {
throw new RuntimeManagementValidationError(`${path} ${message}`);
}
function object(value: unknown, path: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return fail(path, "must be an object");
}
return value as JsonObject;
}
function exactKeys(
value: JsonObject,
required: readonly string[],
optional: readonly string[],
path: string,
): void {
const allowed = new Set([...required, ...optional]);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
fail(`${path}.${key}`, "is not part of the wire contract");
}
}
for (const key of required) {
if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required");
}
}
function array(value: unknown, path: string, max: number): unknown[] {
if (!Array.isArray(value)) return fail(path, "must be an array");
if (value.length > max) {
return fail(path, `must contain at most ${max} items`);
}
return value;
}
function boundedString(
value: unknown,
path: string,
maxBytes: number,
allowEmpty = false,
): string {
if (typeof value !== "string") return fail(path, "must be a string");
if (!allowEmpty && value.length === 0) return fail(path, "must not be empty");
if (encoder.encode(value).byteLength > maxBytes) {
return fail(path, `must be at most ${maxBytes} UTF-8 bytes`);
}
return value;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") return fail(path, "must be a boolean");
return value;
}
function safeInteger(value: unknown, path: string, minimum = 0): number {
if (
typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum
) {
return fail(path, `must be a safe integer of at least ${minimum}`);
}
return value;
}
function safeRevision(value: unknown, path: string): number {
return safeInteger(value, path, 1);
}
function optionalNullableString(
value: unknown,
path: string,
maxBytes: number,
allowEmpty = false,
): string | null | undefined {
if (value === undefined || value === null) return value;
return boundedString(value, path, maxBytes, allowEmpty);
}
function optionalRevision(
value: unknown,
path: string,
): number | undefined {
if (value === undefined || value === null) return undefined;
return safeRevision(value, path);
}
function optionalNullableRevision(
value: unknown,
path: string,
): number | null | undefined {
if (value === undefined || value === null) return value;
return safeRevision(value, path);
}
function timestamp(value: unknown, path: string): string {
const result = boundedString(value, path, LIMITS.timestampBytes);
if (
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
.test(result)
) {
return fail(path, "must be an RFC 3339 timestamp");
}
return result;
}
function optionalNullableTimestamp(
value: unknown,
path: string,
): string | null | undefined {
if (value === undefined || value === null) return value;
return timestamp(value, path);
}
function enumValue<T extends string>(
value: unknown,
path: string,
variants: ReadonlySet<T>,
): T {
const result = boundedString(value, path, LIMITS.kindBytes);
if (!variants.has(result as T)) {
return fail(path, "contains an unknown enum value");
}
return result as T;
}
function diagnostic(value: unknown, path: string): Diagnostic {
const item = object(value, path);
exactKeys(item, ["code", "severity", "message"], [], path);
const severity = enumValue(
item.severity,
`${path}.severity`,
DIAGNOSTIC_SEVERITIES,
) as Diagnostic["severity"];
return {
code: boundedString(item.code, `${path}.code`, LIMITS.diagnosticCodeBytes),
severity,
message: boundedString(
item.message,
`${path}.message`,
LIMITS.diagnosticMessageBytes,
true,
),
};
}
function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
const item = object(value, path);
exactKeys(item, ["kind", "status", "identity_authority", "note"], [], path);
return {
kind: enumValue(item.kind, `${path}.kind`, SOURCE_KINDS),
status: enumValue(item.status, `${path}.status`, SOURCE_STATUSES),
identity_authority: enumValue(
item.identity_authority,
`${path}.identity_authority`,
IDENTITY_AUTHORITIES,
),
note: boundedString(item.note, `${path}.note`, LIMITS.noteBytes, true),
};
}
function runtimeManagement(
value: unknown,
path: string,
): RuntimeManagementSummary {
const item = object(value, path);
exactKeys(
item,
[
"built_in",
"config_managed",
"removable",
"endpoint_configured",
"token_ref_configured",
],
[],
path,
);
return {
built_in: boolean(item.built_in, `${path}.built_in`),
config_managed: boolean(item.config_managed, `${path}.config_managed`),
removable: boolean(item.removable, `${path}.removable`),
endpoint_configured: boolean(
item.endpoint_configured,
`${path}.endpoint_configured`,
),
token_ref_configured: boolean(
item.token_ref_configured,
`${path}.token_ref_configured`,
),
};
}
function runtimeResource(
value: unknown,
path: string,
): WorkspaceRuntimeResource {
const item = object(value, path);
exactKeys(
item,
[
"management",
"runtime_id",
"label",
"kind",
"status",
"source",
"host_ids",
"worker_creation_available",
"os",
"arch",
"diagnostics",
],
[],
path,
);
const hostIds = array(item.host_ids, `${path}.host_ids`, LIMITS.hostIds).map(
(entry, index) =>
boundedString(
entry,
`${path}.host_ids[${index}]`,
LIMITS.idBytes,
),
);
if (new Set(hostIds).size !== hostIds.length) {
fail(`${path}.host_ids`, "must not contain duplicate IDs");
}
return {
management: runtimeManagement(item.management, `${path}.management`),
runtime_id: boundedString(
item.runtime_id,
`${path}.runtime_id`,
LIMITS.idBytes,
),
label: boundedString(item.label, `${path}.label`, LIMITS.labelBytes),
kind: boundedString(item.kind, `${path}.kind`, LIMITS.kindBytes),
status: boundedString(item.status, `${path}.status`, LIMITS.statusBytes),
source: runtimeSource(item.source, `${path}.source`),
host_ids: hostIds,
worker_creation_available: boolean(
item.worker_creation_available,
`${path}.worker_creation_available`,
),
os: boundedString(item.os, `${path}.os`, LIMITS.kindBytes, true),
arch: boundedString(item.arch, `${path}.arch`, LIMITS.kindBytes, true),
diagnostics: array(
item.diagnostics,
`${path}.diagnostics`,
LIMITS.diagnostics,
).map((entry, index) => diagnostic(entry, `${path}.diagnostics[${index}]`)),
};
}
function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
const item = object(value, path);
exactKeys(
item,
["status"],
["fingerprint", "revision", "created_at", "updated_at", "revoked_at"],
path,
);
const result: RuntimeTrustKeyState = {
status: enumValue(item.status, `${path}.status`, TRUST_STATUSES),
fingerprint: optionalNullableString(
item.fingerprint,
`${path}.fingerprint`,
LIMITS.fingerprintBytes,
),
revision: optionalNullableRevision(item.revision, `${path}.revision`),
created_at: optionalNullableTimestamp(
item.created_at,
`${path}.created_at`,
),
updated_at: optionalNullableTimestamp(
item.updated_at,
`${path}.updated_at`,
),
revoked_at: optionalNullableTimestamp(
item.revoked_at,
`${path}.revoked_at`,
),
};
const hasBinding = result.status !== "unconfigured";
if (
hasBinding &&
(result.fingerprint == null || result.revision == null ||
result.created_at == null || result.updated_at == null)
) {
fail(
path,
"must include fingerprint, revision, created_at, and updated_at",
);
}
if (
!hasBinding &&
Object.entries(result).some(([key, entry]) =>
key !== "status" && entry != null
)
) {
fail(path, "must not include binding values while unconfigured");
}
if (result.status === "revoked" && result.revoked_at == null) {
fail(`${path}.revoked_at`, "is required for a revoked key");
}
if (result.status === "active" && result.revoked_at != null) {
fail(`${path}.revoked_at`, "must be absent for an active key");
}
return result;
}
function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry {
const item = object(value, path);
exactKeys(
item,
["action", "actor_account_id", "revision", "at"],
["old_fingerprint", "new_fingerprint"],
path,
);
return {
action: enumValue(item.action, `${path}.action`, AUDIT_ACTIONS),
actor_account_id: boundedString(
item.actor_account_id,
`${path}.actor_account_id`,
LIMITS.idBytes,
),
old_fingerprint: optionalNullableString(
item.old_fingerprint,
`${path}.old_fingerprint`,
LIMITS.fingerprintBytes,
),
new_fingerprint: optionalNullableString(
item.new_fingerprint,
`${path}.new_fingerprint`,
LIMITS.fingerprintBytes,
),
revision: safeRevision(item.revision, `${path}.revision`),
at: timestamp(item.at, `${path}.at`),
};
}
export function parseWorkspaceRuntimeList(
value: unknown,
): WorkspaceRuntimeList {
const response = object(value, "Runtime list response");
exactKeys(
response,
["workspace_id", "limit", "items", "source", "diagnostics"],
[],
"Runtime list response",
);
const limit = safeInteger(response.limit, "Runtime list response.limit", 0);
if (limit > LIMITS.runtimeItems) {
fail(
"Runtime list response.limit",
`must not exceed ${LIMITS.runtimeItems}`,
);
}
const items = array(
response.items,
"Runtime list response.items",
LIMITS.runtimeItems,
).map((entry, index) =>
runtimeResource(entry, `Runtime list response.items[${index}]`)
);
if (items.length > limit) {
fail("Runtime list response.items", "must not exceed the declared limit");
}
return {
workspace_id: boundedString(
response.workspace_id,
"Runtime list response.workspace_id",
LIMITS.idBytes,
),
limit,
items,
source: boundedString(
response.source,
"Runtime list response.source",
LIMITS.kindBytes,
),
diagnostics: array(
response.diagnostics,
"Runtime list response.diagnostics",
LIMITS.diagnostics,
).map((entry, index) =>
diagnostic(entry, `Runtime list response.diagnostics[${index}]`)
),
};
}
export function parseWorkspaceRuntimeDetail(
value: unknown,
): WorkspaceRuntimeDetail {
const response = object(value, "Runtime detail response");
exactKeys(
response,
["workspace_id", "runtime", "trust_key", "recent_audit"],
["endpoint"],
"Runtime detail response",
);
return {
workspace_id: boundedString(
response.workspace_id,
"Runtime detail response.workspace_id",
LIMITS.idBytes,
),
runtime: runtimeResource(
response.runtime,
"Runtime detail response.runtime",
),
endpoint: optionalNullableString(
response.endpoint,
"Runtime detail response.endpoint",
LIMITS.endpointBytes,
),
trust_key: trustKey(
response.trust_key,
"Runtime detail response.trust_key",
),
recent_audit: array(
response.recent_audit,
"Runtime detail response.recent_audit",
LIMITS.auditEntries,
).map((entry, index) =>
auditEntry(entry, `Runtime detail response.recent_audit[${index}]`)
),
};
}
export function parseRuntimeTrustKeyRevealResponse(
value: unknown,
): RuntimeTrustKeyRevealResponse {
const response = object(value, "Runtime trust key reveal response");
exactKeys(
response,
["public_key"],
[],
"Runtime trust key reveal response",
);
return {
public_key: boundedString(
response.public_key,
"Runtime trust key reveal response.public_key",
LIMITS.publicKeyBytes,
),
};
}
export function parseRuntimeTrustConflict(
value: unknown,
): RuntimeTrustConflictResponse {
const response = object(value, "Runtime trust conflict");
exactKeys(
response,
["error", "message"],
["current_revision", "current_fingerprint"],
"Runtime trust conflict",
);
return {
error: enumValue(
response.error,
"Runtime trust conflict.error",
CONFLICT_KINDS,
),
message: boundedString(
response.message,
"Runtime trust conflict.message",
LIMITS.conflictMessageBytes,
),
current_revision: optionalRevision(
response.current_revision,
"Runtime trust conflict.current_revision",
),
current_fingerprint: optionalNullableString(
response.current_fingerprint,
"Runtime trust conflict.current_fingerprint",
LIMITS.fingerprintBytes,
),
};
}
function revisionForJson(revision: number | null): number | null {
if (revision === null) return null;
if (!Number.isSafeInteger(revision) || revision < 1) {
throw new RuntimeTrustRequestError(
"Runtime trust revision is not a safe integer",
);
}
return revision;
}
async function readBoundedJson(response: Response): Promise<unknown> {
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const parsed = Number(contentLength);
if (Number.isFinite(parsed) && parsed > LIMITS.responseBytes) {
throw new RuntimeTrustRequestError(
"Runtime trust response exceeds its byte limit",
);
}
}
const text = await response.text();
if (encoder.encode(text).byteLength > LIMITS.responseBytes) {
throw new RuntimeTrustRequestError(
"Runtime trust response exceeds its byte limit",
);
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new RuntimeTrustRequestError(
"Runtime trust response is not valid JSON",
);
}
}
function requestErrorFrom(
value: unknown,
status: number,
): RuntimeTrustRequestError {
try {
const response = object(value, "Runtime trust error");
exactKeys(
response,
["error", "message", "diagnostics"],
[],
"Runtime trust error",
);
const diagnostics = array(
response.diagnostics,
"Runtime trust error.diagnostics",
LIMITS.diagnostics,
).map((entry, index) =>
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
);
const message = boundedString(
response.message,
"Runtime trust error.message",
LIMITS.conflictMessageBytes,
);
const field = diagnostics.some((entry) =>
entry.code.startsWith("runtime_public_key_")
)
? "public_key"
: null;
return new RuntimeTrustRequestError(message, field);
} catch {
return new RuntimeTrustRequestError(
`Runtime trust request failed (${status})`,
);
}
}
async function finishMutation(
response: Response,
workspaceId: string,
runtimeId: string,
): Promise<WorkspaceRuntimeDetail> {
const payload = await readBoundedJson(response);
if (response.status === 409) {
try {
throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload));
} catch (error) {
if (error instanceof RuntimeTrustConflictError) throw error;
throw new RuntimeTrustRequestError(
"Runtime trust conflict response was invalid",
);
}
}
if (!response.ok) throw requestErrorFrom(payload, response.status);
let detail: WorkspaceRuntimeDetail;
try {
detail = parseWorkspaceRuntimeDetail(payload);
} catch {
throw new RuntimeTrustRequestError("Runtime trust response was invalid");
}
if (
detail.workspace_id !== workspaceId ||
detail.runtime.runtime_id !== runtimeId
) {
throw new RuntimeTrustRequestError(
"Runtime trust response did not match the selected Runtime",
);
}
return detail;
}
export async function revealRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
): Promise<RuntimeTrustKeyRevealResponse> {
const response = await fetch(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
);
const payload = await readBoundedJson(response);
if (!response.ok) throw requestErrorFrom(payload, response.status);
return parseRuntimeTrustKeyRevealResponse(payload);
}
export async function previewRuntimePublicKeyFingerprint(
publicKey: string,
): Promise<string> {
const normalized = publicKey.trim();
const prefix = "yoi-ed25519-pub:v1:";
if (!normalized.startsWith(prefix)) {
throw new RuntimeTrustRequestError(
`Public key must start with ${prefix}`,
);
}
const encoded = normalized.slice(prefix.length);
if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
throw new RuntimeTrustRequestError("Public key encoding is invalid");
}
const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") +
"=".repeat((4 - (encoded.length % 4)) % 4);
let decoded: string;
try {
decoded = atob(padded);
} catch {
throw new RuntimeTrustRequestError("Public key encoding is invalid");
}
if (decoded.length !== 32) {
throw new RuntimeTrustRequestError("Public key must contain 32 bytes");
}
const bytes = Uint8Array.from(
decoded,
(character) => character.charCodeAt(0),
);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0"))
.join("");
return `sha256:${hex}`;
}
export async function putRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: PutRuntimeTrustKeyRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
public_key: request.public_key,
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
export async function revokeRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: RevokeRuntimeTrustKeyRequest,
currentFingerprint: string,
confirmation: string,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
if (!currentFingerprint || confirmation.trim() !== currentFingerprint) {
throw new RuntimeTrustRequestError(
"Enter the current fingerprint exactly before revoking Workspace trust.",
);
}
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
@@ -14,6 +14,12 @@ import type {
WorkspaceAuthConfig,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspaceDeletionBlocker,
WorkspaceDeletionBlockerKind,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionResourceCounts,
WorkspaceDeletionState,
WorkspaceExtensionPoints,
WorkspaceExtensionPointState,
WorkspacePermissionSummary,
@@ -32,6 +38,8 @@ export type {
RepositorySummary,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspacePermissionSummary,
WorkspaceResponse,
WorkspaceSummary,
@@ -367,13 +375,27 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig {
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
const item = object(value, path);
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
exactKeys(
item,
[
"manage_repositories",
"manage_secrets",
"manage_runtimes",
"delete_workspace",
],
path,
);
return {
manage_repositories: boolean(
item.manage_repositories,
`${path}.manage_repositories`,
),
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`),
delete_workspace: boolean(
item.delete_workspace,
`${path}.delete_workspace`,
),
};
}
@@ -561,6 +583,256 @@ export function parseRepositoryDetailResponse(
};
}
const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128;
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024;
const WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS = 4096;
const WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES = 512;
function deletionBoundedString(
value: unknown,
path: string,
maxBytes: number,
): string {
const candidate = string(value, path);
if (new TextEncoder().encode(candidate).length > maxBytes) {
throw new Error(`${path} is too long`);
}
return candidate;
}
function deletionBoundedArray(
value: unknown,
path: string,
maxItems: number,
): unknown[] {
const candidate = array(value, path);
if (candidate.length > maxItems) {
throw new Error(`${path} has too many items`);
}
return candidate;
}
const deletionStates = new Set<WorkspaceDeletionState>([
"queued",
"running",
"blocked",
"failed",
"succeeded",
]);
const deletionBlockerKinds = new Set<WorkspaceDeletionBlockerKind>([
"last_accessible_workspace",
"revision_conflict",
"dirty_workdir",
"worker_removal_blocked",
"workdir_removal_blocked",
"retention_hold",
"cleanup_unavailable",
]);
function deletionState(value: unknown, path: string): WorkspaceDeletionState {
const candidate = string(value, path) as WorkspaceDeletionState;
if (!deletionStates.has(candidate)) throw new Error(`${path} is invalid`);
return candidate;
}
function deletionBlocker(
value: unknown,
path: string,
): WorkspaceDeletionBlocker {
const item = object(value, path);
exactKeys(item, ["kind", "resource_kind", "resource_key", "message"], path);
const kind = string(
item.kind,
`${path}.kind`,
) as WorkspaceDeletionBlockerKind;
if (!deletionBlockerKinds.has(kind)) {
throw new Error(`${path}.kind is invalid`);
}
const resourceKind = optionalNullableString(
item.resource_kind,
`${path}.resource_kind`,
);
const resourceKey = optionalNullableString(
item.resource_key,
`${path}.resource_key`,
);
return {
kind,
resource_kind: resourceKind === undefined || resourceKind === null
? null
: deletionBoundedString(
resourceKind,
`${path}.resource_kind`,
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES,
),
resource_key: resourceKey === undefined || resourceKey === null
? null
: deletionBoundedString(
resourceKey,
`${path}.resource_key`,
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES,
),
message: deletionBoundedString(
item.message,
`${path}.message`,
WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES,
),
};
}
function deletionResourceCounts(
value: unknown,
path: string,
): WorkspaceDeletionResourceCounts {
const item = object(value, path);
exactKeys(item, [
"workers",
"workdirs",
"repositories",
"runtime_bindings",
"secrets",
"artifacts",
], path);
return {
workers: integer(item.workers, `${path}.workers`),
workdirs: integer(item.workdirs, `${path}.workdirs`),
repositories: integer(item.repositories, `${path}.repositories`),
runtime_bindings: integer(
item.runtime_bindings,
`${path}.runtime_bindings`,
),
secrets: integer(item.secrets, `${path}.secrets`),
artifacts: integer(item.artifacts, `${path}.artifacts`),
};
}
export function parseWorkspaceDeletionPreflightResponse(
value: unknown,
): WorkspaceDeletionPreflightResponse {
const item = object(value, "Workspace deletion preflight");
exactKeys(item, [
"workspace_id",
"display_name",
"expected_revision",
"can_delete",
"resources",
"blockers",
], "Workspace deletion preflight");
return {
workspace_id: string(
item.workspace_id,
"Workspace deletion preflight.workspace_id",
),
display_name: string(
item.display_name,
"Workspace deletion preflight.display_name",
),
expected_revision: deletionBoundedString(
item.expected_revision,
"Workspace deletion preflight.expected_revision",
WORKSPACE_DELETION_MAX_REVISION_BYTES,
),
can_delete: boolean(
item.can_delete,
"Workspace deletion preflight.can_delete",
),
resources: deletionResourceCounts(
item.resources,
"Workspace deletion preflight.resources",
),
blockers: deletionBoundedArray(
item.blockers,
"Workspace deletion preflight.blockers",
WORKSPACE_DELETION_MAX_BLOCKERS,
).map(
(entry, index) =>
deletionBlocker(
entry,
`Workspace deletion preflight.blockers[${index}]`,
),
),
};
}
export function parseWorkspaceDeletionOperationResponse(
value: unknown,
): WorkspaceDeletionOperationResponse {
const item = object(value, "Workspace deletion operation");
exactKeys(item, [
"operation_id",
"workspace_id",
"display_name",
"state",
"resources",
"child_operation_ids",
"blockers",
"failure_category",
"created_at",
"updated_at",
"completed_at",
], "Workspace deletion operation");
return {
operation_id: deletionBoundedString(
item.operation_id,
"Workspace deletion operation.operation_id",
WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES,
),
workspace_id: string(
item.workspace_id,
"Workspace deletion operation.workspace_id",
),
display_name: string(
item.display_name,
"Workspace deletion operation.display_name",
),
state: deletionState(item.state, "Workspace deletion operation.state"),
resources: deletionResourceCounts(
item.resources,
"Workspace deletion operation.resources",
),
child_operation_ids: deletionBoundedArray(
item.child_operation_ids,
"Workspace deletion operation.child_operation_ids",
WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS,
).map((entry, index) =>
deletionBoundedString(
entry,
`Workspace deletion operation.child_operation_ids[${index}]`,
WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES,
)
),
blockers: deletionBoundedArray(
item.blockers,
"Workspace deletion operation.blockers",
WORKSPACE_DELETION_MAX_BLOCKERS,
).map(
(entry, index) =>
deletionBlocker(
entry,
`Workspace deletion operation.blockers[${index}]`,
),
),
failure_category: optionalNullableString(
item.failure_category,
"Workspace deletion operation.failure_category",
) ?? null,
created_at: string(
item.created_at,
"Workspace deletion operation.created_at",
),
updated_at: string(
item.updated_at,
"Workspace deletion operation.updated_at",
),
completed_at: optionalNullableString(
item.completed_at,
"Workspace deletion operation.completed_at",
) ?? null,
};
}
export function parseRepositoryLogResponse(
value: unknown,
): RepositoryLogResponse {
@@ -0,0 +1,136 @@
declare const Deno: {
test(name: string, fn: () => void): void;
};
import {
canDeliverComposerDraft,
sendComposerDelivery,
} from "./composer-delivery.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (actual !== expected) {
throw new Error(`Expected ${String(expected)}, got ${String(actual)}`);
}
}
const base = {
protocolOpen: true,
sending: false,
hasText: true,
hasAttachments: false,
};
Deno.test("running Composer enables Queue Submit and Notify but not immediate Submit", () => {
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "queue",
workerState: "running",
}),
true,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "notify",
workerState: "running",
}),
true,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "submit",
workerState: "running",
}),
false,
);
});
Deno.test("running Queue Submit and Notify dispatch their protocol methods", () => {
const sent: string[] = [];
assertEquals(
sendComposerDelivery(
{ ...base, delivery: "queue", workerState: "running" },
"submit",
(method) => sent.push(method),
),
true,
);
assertEquals(
sendComposerDelivery(
{ ...base, delivery: "notify", workerState: "running" },
"notify",
(method) => sent.push(method),
),
true,
);
assertEquals(sent.join(","), "submit,notify");
});
Deno.test("idle Composer enables only immediate Submit", () => {
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "submit",
workerState: "idle",
}),
true,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "queue",
workerState: "idle",
}),
false,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "notify",
workerState: "idle",
}),
false,
);
});
Deno.test("running delivery remains fenced by protocol, send state, and payload kind", () => {
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "queue",
workerState: "running",
protocolOpen: false,
}),
false,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "notify",
workerState: "running",
sending: true,
}),
false,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "notify",
workerState: "running",
hasAttachments: true,
}),
false,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "queue",
workerState: "running",
hasText: false,
hasAttachments: true,
}),
true,
);
});
@@ -0,0 +1,39 @@
export type ComposerDelivery = "submit" | "queue" | "notify";
export type ComposerDeliveryState = {
delivery: ComposerDelivery;
workerState: string;
protocolOpen: boolean;
sending: boolean;
hasText: boolean;
hasAttachments: boolean;
};
/**
* Resolve whether the current Composer draft can use one delivery action.
* Immediate Submit is idle-only; Queue and Notify are running-only.
*/
export function canDeliverComposerDraft(state: ComposerDeliveryState): boolean {
if (!state.protocolOpen || state.sending) return false;
const hasInput = state.hasText || state.hasAttachments;
switch (state.delivery) {
case "submit":
return state.workerState === "idle" && hasInput;
case "queue":
return state.workerState === "running" && hasInput;
case "notify":
return state.workerState === "running" && state.hasText &&
!state.hasAttachments;
}
}
export function sendComposerDelivery<T>(
state: ComposerDeliveryState,
method: T,
send: (method: T) => void,
): boolean {
if (!canDeliverComposerDraft(state)) return false;
send(method);
return true;
}
@@ -1,4 +1,4 @@
import type { Event } from "$lib/generated/protocol";
import type { Event, WorkerStateSnapshot, WorkerStatus } from "$lib/generated/protocol";
import {
type ConsoleEventInput,
type ConsoleLine,
@@ -19,6 +19,23 @@ declare const Deno: {
test(name: string, fn: () => void): void;
};
function workerState(status: WorkerStatus): WorkerStateSnapshot {
return {
execution_generation: 1,
revision: status === "idle" ? 0 : 1,
last_command_id: 0,
state: status === "idle"
? { kind: "idle" }
: {
kind: "busy",
state: {
kind: "run",
state: status === "paused" ? "paused" : "running",
},
},
};
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
@@ -131,7 +148,7 @@ function snapshotEvent(cwd: string, entries: unknown[] = []): Event {
context_window: 100,
context_tokens: 20,
},
status: "idle",
state: workerState("idle"),
in_flight: { blocks: [] },
},
};
@@ -201,6 +218,66 @@ Deno.test("console routing projects live errors but not completion replies", ()
);
});
Deno.test("Worker state events and acknowledgements apply monotonically", () => {
const projector = createConsoleProjector();
const running: WorkerStateSnapshot = {
execution_generation: 4,
revision: 3,
last_command_id: 2,
state: { kind: "busy", state: { kind: "run", state: "running" } },
};
const paused: WorkerStateSnapshot = {
...running,
revision: 4,
last_command_id: 3,
state: { kind: "busy", state: { kind: "run", state: "paused" } },
};
let projection = projector.append([
{
eventId: "running",
event: { event: "worker_state", data: { snapshot: running } },
},
{
eventId: "stale",
event: {
event: "worker_state",
data: { snapshot: { ...running, revision: 2, state: { kind: "idle" } } },
},
},
{
eventId: "pause-ack",
event: {
event: "command_acknowledged",
data: {
acknowledgement: {
command_id: 3,
command: "pause",
disposition: "accepted",
state: paused,
},
},
},
},
]);
assertEquals(projection.workerState, paused);
assertEquals(projection.status, "paused");
projection = projector.append([{
eventId: "conflict",
event: {
event: "worker_state",
data: { snapshot: { ...paused, state: { kind: "idle" } } },
},
}]);
assertEquals(projection.workerState, paused);
assert(
projection.lines.some((line) =>
line.eventId === "conflict:worker-state-conflict" && line.error
),
"conflicting equal-version snapshots must fail closed",
);
});
Deno.test("snapshot replaces a live error with one durable run_errored row", () => {
const projector = createConsoleProjector();
let projection = projector.append([
@@ -213,7 +290,7 @@ Deno.test("snapshot replaces a live error with one durable run_errored row", ()
},
{
eventId: "idle-after-error",
event: { event: "status", data: { status: "idle" } } satisfies Event,
event: { event: "worker_state", data: { snapshot: workerState("idle") } } satisfies Event,
},
]);
@@ -653,7 +730,7 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
Deno.test("snapshot restores bounded in-flight Bash command output", () => {
const snapshot = snapshotEvent("/repo");
if (snapshot.event !== "snapshot") throw new Error("snapshot fixture expected");
snapshot.data.status = "running";
snapshot.data.state = workerState("running");
snapshot.data.in_flight = {
blocks: [{
kind: "tool_call",
@@ -1403,7 +1480,7 @@ Deno.test("projectConsole hides lifecycle events and renders system items", () =
const projection = projectConsole([
{
eventId: "30",
event: { event: "status", data: { status: "running" } } satisfies Event,
event: { event: "worker_state", data: { snapshot: workerState("running") } } satisfies Event,
},
{
eventId: "31",
@@ -1527,7 +1604,7 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
context_window: 100,
context_tokens: 20,
},
status: "running",
state: workerState("running"),
in_flight: {
blocks: [
{ kind: "text", text: "partial" },
@@ -1578,7 +1655,7 @@ Deno.test("projectConsole restores system items from snapshot entries", () => {
context_window: 100,
context_tokens: 20,
},
status: "idle",
state: workerState("idle"),
},
} satisfies Event,
}]);
@@ -1922,7 +1999,7 @@ Deno.test("console Worker views expose only direct Internal Workers", () => {
kind: "sub_worker",
},
revision: 1,
event: { event: "status", data: { status: "running" } },
event: { event: "worker_state", data: { snapshot: workerState("running") } },
},
},
},
@@ -1941,7 +2018,7 @@ Deno.test("console Worker views expose only direct Internal Workers", () => {
kind: "sub_worker",
},
revision: 1,
event: { event: "status", data: { status: "idle" } },
event: { event: "worker_state", data: { snapshot: workerState("idle") } },
},
},
}]);
@@ -2033,7 +2110,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
kind: "sub_worker",
},
revision: 1,
event: { event: "status", data: { status: "running" } },
event: { event: "worker_state", data: { snapshot: workerState("running") } },
},
},
}]);
@@ -2150,6 +2227,12 @@ Deno.test("snapshot restores TaskStore state from system history", () => {
const event = snapshotEvent("/repo");
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
event.data.session = {
pending_submissions: {
revision: 0,
notification_count: 0,
head_id: null,
submissions: [],
},
entries: [{
entry_id: "task-reminder-1",
timestamp: 1,
@@ -10,6 +10,9 @@ import type {
InternalWorkerRef,
InternalWorkerSnapshot,
Segment,
WorkerState,
WorkerStateSnapshot,
WorkerStatus,
} from "$lib/generated/protocol";
import { stringify as stringifyYaml } from "yaml";
import { workspaceRoute } from "$lib/workspace/api/http";
@@ -169,6 +172,7 @@ export type ConsoleProjection = {
tasks: ConsoleTask[];
taskNextId: number;
status: string | null;
workerState: WorkerStateSnapshot | null;
usage: string | null;
runActivity: RunActivityStats;
cwd: string | null;
@@ -251,12 +255,22 @@ export function isConsoleProjectionEvent(event: ProtocolEvent): boolean {
return event.event !== "completions";
}
function workerStatusFromState(snapshot: WorkerStateSnapshot): WorkerStatus {
if (snapshot.state.kind === "idle") return "idle";
if (
snapshot.state.state.kind === "run" &&
snapshot.state.state.state === "paused"
) return "paused";
return "running";
}
export function emptyConsoleProjection(): ConsoleProjection {
return {
lines: [],
tasks: [],
taskNextId: 1,
status: null,
workerState: null,
usage: null,
runActivity: emptyRunActivityStats(),
cwd: null,
@@ -783,6 +797,60 @@ function refreshCompactionActivity(
return changed ? { ...projection, lines } : projection;
}
function workerStateEqual(left: WorkerState, right: WorkerState): boolean {
if (left.kind !== right.kind) return false;
if (left.kind === "idle" || right.kind === "idle") return true;
return left.state.kind === right.state.kind &&
left.state.state === right.state.state;
}
function workerStateSnapshotEqual(
left: WorkerStateSnapshot,
right: WorkerStateSnapshot,
): boolean {
return left.execution_generation === right.execution_generation &&
left.revision === right.revision &&
left.last_command_id === right.last_command_id &&
workerStateEqual(left.state, right.state);
}
function applyWorkerStateSnapshot(
projection: ConsoleProjection,
incoming: WorkerStateSnapshot,
eventId: string,
): void {
const current = projection.workerState;
if (!current) {
projection.workerState = incoming;
projection.status = workerStatusFromState(incoming);
return;
}
const generationOrder = incoming.execution_generation -
current.execution_generation;
const revisionOrder = incoming.revision - current.revision;
if (generationOrder > 0 || (generationOrder === 0 && revisionOrder > 0)) {
projection.workerState = incoming;
projection.status = workerStatusFromState(incoming);
return;
}
if (generationOrder < 0 || (generationOrder === 0 && revisionOrder < 0)) {
return;
}
if (!workerStateSnapshotEqual(current, incoming)) {
projection.lines.push(
line(
`${eventId}:worker-state-conflict`,
"error",
"error · internal",
`worker state stream rejected: conflicting snapshots at generation ${incoming.execution_generation} revision ${incoming.revision}`,
undefined,
false,
true,
),
);
}
}
export function applyProtocolEvent(
projection: ConsoleProjection,
envelope: ConsoleEventInput,
@@ -793,6 +861,7 @@ export function applyProtocolEvent(
tasks: [...projection.tasks],
taskNextId: projection.taskNextId,
status: projection.status,
workerState: projection.workerState,
usage: projection.usage,
runActivity: applyRunActivityEvent(
projection.runActivity,
@@ -903,7 +972,6 @@ export function applyProtocolEvent(
);
break;
case "snapshot": {
next.status = event.data.status;
next.cwd = event.data.greeting.cwd;
const snapshot = snapshotProjectionFromSession(
envelope.eventId,
@@ -953,6 +1021,7 @@ export function applyProtocolEvent(
};
}
}
applyWorkerStateSnapshot(next, event.data.state, envelope.eventId);
break;
}
case "internal_worker": {
@@ -1000,8 +1069,15 @@ export function applyProtocolEvent(
if (existingIndex >= 0) next.internalWorkers.splice(existingIndex, 1);
break;
}
case "status":
next.status = event.data.status;
case "worker_state":
applyWorkerStateSnapshot(next, event.data.snapshot, envelope.eventId);
break;
case "command_acknowledged":
applyWorkerStateSnapshot(
next,
event.data.acknowledgement.state,
envelope.eventId,
);
break;
case "command":
applyCommandEvent(next, envelope.eventId, event.data.event);
@@ -1939,6 +2015,7 @@ function snapshotProjectionFromSession(
tasks: [],
taskNextId: 1,
status: null,
workerState: null,
usage: null,
runActivity: emptyRunActivityStats(),
cwd,
@@ -75,7 +75,12 @@ Deno.test("new invoke and running snapshot reset run activity", () => {
data: {
entries: [],
greeting: { text: "", profile: "" },
status: "idle",
state: {
execution_generation: 1,
revision: 0,
last_command_id: 0,
state: { kind: "idle" },
},
in_flight: {},
internal_workers: [],
},
@@ -25,7 +25,9 @@ export function applyRunActivityEvent(
case "invoke_start":
return { ...emptyRunActivityStats(), startedAtMs: observedAtMs };
case "snapshot":
return event.data.status === "running"
return event.data.state.state.kind === "busy" &&
!(event.data.state.state.state.kind === "run" &&
event.data.state.state.state.state === "paused")
? { ...emptyRunActivityStats(), startedAtMs: observedAtMs }
: emptyRunActivityStats();
case "turn_start":
@@ -620,7 +620,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
consolePage.includes("switchComposerTarget(target)") &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
consolePage.includes('sendWorkerControl("cancel")'),
"Paste chips should use shared threshold classification, atomic keyboard behavior, accessible labels, typed restore, and per-Worker draft authority",
);
});
@@ -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",
),
@@ -782,7 +787,10 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
consolePage.includes(
'const composerEditable = $derived(protocolState === "open" && !sending);',
) &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
consolePage.includes('sendWorkerControl("cancel")') &&
consolePage.includes("lifecycleMethod(command)") &&
consolePage.includes("expected_worker_state_revision") &&
consolePage.includes("expected_execution_generation") &&
consolePage.includes("onsubmit={handleComposerSubmit}") &&
consolePage.includes("disabled={!composerEditable}") &&
consolePage.includes("class:stop={workerRunning}") &&
@@ -1059,3 +1067,46 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
"Worker view selection should expose only direct SubWorker session identities with main fallback",
);
});
Deno.test("Web Console uses Notify while running and exposes durable pending controls", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
for (
const token of [
'method: "submit"',
'method: "notify"',
"notification_request_id: crypto.randomUUID()",
"submission_request_id: crypto.randomUUID()",
'payload.event === "pending_submissions_changed"',
'method: "cancel_pending_submission"',
'method: "clear_pending_submissions"',
'method: "continue_pending"',
"handleQueueSubmit",
"handleNotifySubmit",
'submitDraft(composerInputElement?.snapshot() ?? draft, "queue")',
"disabled={!canQueueDraft}",
"disabled={!canNotifyDraft}",
">Queue Submit</button>",
">Notify</button>",
]
) {
assert(
consolePage.includes(token),
`missing durable pending control token: ${token}`,
);
}
const userCase = consolePage.slice(
consolePage.indexOf('case "user":'),
consolePage.indexOf('case "compact":'),
);
assert(
!userCase.includes("workerRunning"),
"ordinary text must remain Submit instead of being implicitly converted to Notify",
);
});
@@ -0,0 +1,70 @@
import type {
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
} from "$lib/generated/workspace-api";
import { loadJson } from "$lib/workspace/api/http";
import {
parseWorkspaceDeletionOperationResponse,
parseWorkspaceDeletionPreflightResponse,
} from "$lib/workspace/api/workspace-model";
const deletionResponsePolicy = {
maxResponseBytes: 2 * 1024 * 1024,
diagnosticLabel: "Workspace deletion",
} as const;
async function deletionJson<T>(
path: string,
init: RequestInit | undefined,
parse: (value: unknown) => T,
): Promise<T> {
const result = await loadJson(
fetch,
path,
init,
parse,
deletionResponsePolicy,
);
if (result.error !== null || result.data === null) {
throw new Error(
result.error ?? "Workspace deletion response is unavailable",
);
}
return result.data;
}
export async function preflightWorkspaceDeletion(
workspaceId: string,
): Promise<WorkspaceDeletionPreflightResponse> {
return await deletionJson(
`/api/workspaces/${encodeURIComponent(workspaceId)}/deletion`,
undefined,
parseWorkspaceDeletionPreflightResponse,
);
}
export async function startWorkspaceDeletion(
workspaceId: string,
request: WorkspaceDeletionRequest,
): Promise<WorkspaceDeletionOperationResponse> {
return await deletionJson(
`/api/workspaces/${encodeURIComponent(workspaceId)}/deletion`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
parseWorkspaceDeletionOperationResponse,
);
}
export async function getWorkspaceDeletion(
operationId: string,
): Promise<WorkspaceDeletionOperationResponse> {
return await deletionJson(
`/api/workspace-deletions/${encodeURIComponent(operationId)}`,
undefined,
parseWorkspaceDeletionOperationResponse,
);
}
@@ -19,6 +19,7 @@ import type {
Event as PodProtocolEvent,
Method as PodProtocolMethod,
Segment as PodProtocolSegment,
WorkerStateSnapshot,
} from "$lib/generated/protocol";
import type {
GitCommitSummary as SharedGitCommitSummary,
@@ -99,6 +100,7 @@ export type Worker = {
tags: string[];
workspace: { visibility: string; identity: string };
state: string;
worker_state?: WorkerStateSnapshot | null;
pinned?: boolean;
retention_state?: string;
last_seen_at?: string | null;
@@ -0,0 +1,12 @@
import type { WorkerStateSnapshot } from "$lib/generated/protocol";
export function liveWorkerState(worker: {
state: string;
worker_state?: WorkerStateSnapshot | null;
}): string {
const state = worker.worker_state?.state;
if (!state) return worker.state === "stopped" ? "stopped" : "unknown";
if (state.kind === "idle") return "idle";
if (state.state.kind === "maintenance") return "running";
return state.state.state === "paused" ? "paused" : "running";
}
@@ -5,6 +5,7 @@ function assertEquals(actual: unknown, expected: unknown): void {
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
import { liveWorkerState } from './worker-state';
import {
applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection,
@@ -33,6 +34,22 @@ function worker(
};
}
Deno.test('Worker list state uses the authoritative live snapshot separately from lifecycle', () => {
const active = worker('runtime-a', 'worker-1', 1);
active.worker_state = {
execution_generation: 4,
revision: 2,
last_command_id: 1,
state: { kind: 'busy', state: { kind: 'run', state: 'paused' } },
};
assertEquals(liveWorkerState(active), 'paused');
const unavailable = worker('runtime-a', 'worker-2', 1);
assertEquals(liveWorkerState(unavailable), 'unknown');
unavailable.state = 'stopped';
assertEquals(liveWorkerState(unavailable), 'stopped');
});
Deno.test('workspace Worker snapshot keeps equal local ids from different Runtimes', () => {
const projection = createWorkspaceWorkersProjection();
const frame: SubscriptionFrame = {
@@ -5,6 +5,7 @@ import {
applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection,
} from './worker-subscription-model';
import { liveWorkerState } from './worker-state';
import { compareWorkersForSidebar } from './workers';
import type { Worker } from './types';
@@ -22,6 +23,10 @@ export type WorkspaceWorkersState = {
const stores = new Map<string, Readable<WorkspaceWorkersState>>();
export function disposeWorkspaceWorkersStore(workspaceId: string): void {
stores.delete(workspaceId);
}
export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWorkersState> {
const cached = stores.get(workspaceId);
if (cached) return cached;
@@ -86,7 +91,8 @@ function projectWorker(worker: SubscriptionWorker): SidebarWorker {
profile: worker.profile ?? null,
tags: [],
workspace: { visibility: 'workspace', identity: 'runtime_subscription_worker' },
state: worker.state,
state: liveWorkerState(worker),
worker_state: worker.worker_state,
pinned: false,
retention_state: 'transient',
implementation: {
@@ -339,6 +339,227 @@
background: rgba(255, 255, 255, 0.04);
padding: 0.75rem;
}
.settings-test-result.failed {
border-inline-start: 3px solid var(--danger);
}
.runtime-detail-page {
display: grid;
gap: var(--space-5);
}
.runtime-detail-section {
display: grid;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
.runtime-detail-section h2,
.runtime-detail-section p {
margin: 0;
}
.runtime-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: var(--space-3) var(--space-5);
margin: 0;
}
.runtime-detail-grid div {
min-width: 0;
}
.runtime-detail-grid dt {
margin-bottom: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.runtime-detail-grid dd {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.runtime-public-key-actions,
.runtime-revoke-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.runtime-public-key-actions {
justify-content: flex-start;
}
.runtime-public-key-actions button,
.runtime-revoke-row button,
.runtime-trust-form button {
border: 0;
border-radius: 0.6rem;
padding: 0.5rem 0.75rem;
background: var(--accent);
color: var(--bg);
font-weight: 700;
cursor: pointer;
}
.runtime-public-key-actions button.secondary {
border: 1px solid var(--line);
background: transparent;
color: var(--text-strong);
}
.runtime-public-key-actions button:disabled,
.runtime-revoke-row button:disabled,
.runtime-trust-form button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.runtime-public-key,
.runtime-trust-form textarea,
.runtime-trust-form input,
.runtime-revoke-row input {
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg-raised);
color: var(--text-strong);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.runtime-public-key {
max-height: 14rem;
margin: 0;
padding: var(--space-3);
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.runtime-trust-form {
display: grid;
gap: var(--space-2);
max-width: 56rem;
}
.runtime-trust-form label,
.runtime-revoke-row label {
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 700;
}
.runtime-trust-form textarea,
.runtime-trust-form input,
.runtime-revoke-row input {
width: 100%;
padding: 0.65rem 0.75rem;
}
.runtime-trust-form textarea {
resize: vertical;
}
.runtime-trust-form small,
.runtime-revoke-row small {
color: var(--text-muted);
}
.runtime-trust-comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
padding: var(--space-3) 0;
border-block: 1px solid var(--line);
}
.runtime-trust-comparison div {
min-width: 0;
}
.runtime-trust-comparison dt {
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.runtime-trust-comparison dd {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
}
.runtime-trust-form .field-error,
.runtime-detail-page .section-state.error {
color: var(--danger);
}
.runtime-detail-page .section-state.success {
color: var(--success);
}
.runtime-revoke-row {
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.runtime-revoke-row div {
display: grid;
gap: var(--space-1);
}
.runtime-revoke-row p {
color: var(--text-muted);
}
.runtime-revoke-row button.danger {
background: var(--danger);
}
.runtime-audit-table-wrap {
overflow-x: auto;
}
.runtime-audit-table {
width: 100%;
min-width: 48rem;
border-collapse: collapse;
}
.runtime-audit-table th,
.runtime-audit-table td {
padding: 0.7rem 0.5rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.runtime-audit-table th {
color: var(--text-muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.runtime-audit-table code {
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.runtime-revoke-row {
align-items: stretch;
}
}
.settings-page {
display: grid;
gap: var(--space-5);
@@ -12,6 +12,7 @@
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '$lib/workspace/styles/workspace-pages.css';
import '$lib/workspace/styles/tickets.css';
@@ -32,7 +33,10 @@
$effect(() => {
const workspaceId = data.workspace?.workspace_id;
if (!workspaceId) return;
return () => disposeWorkspaceMultiplexer(workspaceId);
return () => {
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
};
});
</script>
@@ -5,6 +5,11 @@
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
import {
canDeliverComposerDraft,
sendComposerDelivery,
type ComposerDelivery,
} from "$lib/workspace/console/composer-delivery";
import {
buildComposerSegmentsRequest,
type WorkerConsoleInputRequest,
@@ -31,7 +36,13 @@
type ConsoleViewMode,
type ConsoleViewScroll,
} from "$lib/workspace/console/model";
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
import type {
Event as ProtocolEvent,
Method as ProtocolMethod,
PendingSubmissionsSnapshot,
RewindTarget,
Segment,
} from "$lib/generated/protocol";
import {
MAX_FILES_PER_SUBMISSION,
uploadAttachment,
@@ -41,11 +52,7 @@
import { pushWorkspaceAlert } from "$lib/workspace/alerts/store";
import { workspaceApiPath } from "$lib/workspace/api/http";
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
import type {
Diagnostic,
Worker,
PodProtocolEvent,
} from "$lib/workspace/sidebar/types";
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
type Props = {
data: {
@@ -152,6 +159,13 @@
"connecting",
);
let protocolSubscription: WorkspaceMultiplexerSubscription | null = null;
let pendingSubmissions = $state<PendingSubmissionsSnapshot>({
revision: 0,
notification_count: 0,
head_id: null,
submissions: [],
});
let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []);
let pendingCompletionRequest: {
resolve: (entries: ComposerCompletionEntry[]) => void;
reject: (error: Error) => void;
@@ -189,7 +203,6 @@
);
let pendingObservationEvents: ConsoleEventInput[] = [];
let protocolEventSequence = 0;
let pendingObservedStates: Array<string | null> = [];
let pendingStreamDiagnostics: Diagnostic[] = [];
let observationFlushHandle: number | null = null;
let nextReloadToken = 0;
@@ -231,16 +244,47 @@
const diagnostics = $derived(
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
);
const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading");
const workerState = $derived(
liveWorkerState ?? (worker?.state === "stopped" ? "stopped" : "loading"),
);
const workerRunning = $derived(workerState === "running");
const workerPaused = $derived(workerState === "paused");
const inputReady = $derived(workerState === "idle");
const composerEditable = $derived(protocolState === "open" && !sending);
const canSubmitDraft = $derived(inputReady && composerEditable);
const canSend = $derived(canSubmitDraft && draft.content.trim().length > 0);
const draftHasText = $derived(draft.content.trim().length > 0);
const draftHasAttachments = $derived(attachments.length > 0);
const canSubmitDraft = $derived(
canDeliverComposerDraft({
delivery: "submit",
workerState,
protocolOpen: protocolState === "open",
sending,
hasText: draftHasText,
hasAttachments: draftHasAttachments,
}),
);
const canQueueDraft = $derived(
canDeliverComposerDraft({
delivery: "queue",
workerState,
protocolOpen: protocolState === "open",
sending,
hasText: draftHasText,
hasAttachments: draftHasAttachments,
}),
);
const canNotifyDraft = $derived(
canDeliverComposerDraft({
delivery: "notify",
workerState,
protocolOpen: protocolState === "open",
sending,
hasText: draftHasText,
hasAttachments: draftHasAttachments,
}),
);
const canStopFromComposer = $derived(workerRunning && composerEditable);
const composerSubmitDisabled = $derived(
workerRunning ? !canStopFromComposer : !canSend,
workerRunning ? !canStopFromComposer : !canSubmitDraft,
);
async function getJson<T>(path: string): Promise<T> {
@@ -296,7 +340,6 @@
observationFlushHandle = null;
}
pendingObservationEvents = [];
pendingObservedStates = [];
pendingStreamDiagnostics = [];
}
@@ -312,18 +355,15 @@
function flushObservationBatch() {
observationFlushHandle = null;
const eventBatch = pendingObservationEvents;
const stateBatch = pendingObservedStates;
const diagnosticBatch = pendingStreamDiagnostics;
pendingObservationEvents = [];
pendingObservedStates = [];
pendingStreamDiagnostics = [];
if (eventBatch.length > 0) {
const latestState = stateBatch.findLast((state) => state !== null);
if (latestState) {
liveWorkerState = latestState;
}
consoleProjection = consoleProjector.append(eventBatch);
liveWorkerState = consoleProjection.status === "shutdown"
? "shutdown"
: workerStateFromSnapshot(consoleProjection.workerState);
advanceEventObservedAtVersion();
}
@@ -334,6 +374,13 @@
function handleIncomingProtocolEvent(payload: ProtocolEvent) {
handleProtocolCommandEvent(payload);
if (payload.event === "snapshot") {
pendingSubmissions = payload.data.session.pending_submissions;
} else if (payload.event === "segment_rotated") {
pendingSubmissions = payload.data.session.pending_submissions;
} else if (payload.event === "pending_submissions_changed") {
pendingSubmissions = payload.data.pending;
}
if (payload.event === "error") {
queueObservationDiagnostic({
code: payload.data.code,
@@ -353,7 +400,6 @@
event: payload,
observedAtMs,
});
pendingObservedStates.push(workerStateFromProtocolEvent(payload));
scheduleObservationFlush();
}
@@ -487,9 +533,42 @@
}
}
let nextWorkerCommandId = 1;
function lifecycleMethod(
command: "pause" | "cancel" | "resume" | "compact",
): ProtocolMethod | null {
const state = consoleProjection.workerState;
if (!state) {
sendError = "Worker state snapshot is not available; reconnect before sending control.";
return null;
}
const commandId = Math.max(
nextWorkerCommandId,
state.last_command_id + 1,
);
nextWorkerCommandId = commandId + 1;
const envelope = {
command_id: commandId,
expected_execution_generation: state.execution_generation,
expected_worker_state_revision: state.revision,
};
switch (command) {
case "pause":
return { method: "pause", params: { command: envelope } };
case "cancel":
return { method: "cancel", params: { command: envelope } };
case "resume":
return { method: "resume", params: { command: envelope } };
case "compact":
return { method: "compact", params: { command: envelope } };
}
}
function sendWorkerControl(command: "pause" | "cancel" | "resume") {
const label = command[0].toUpperCase() + command.slice(1);
sendControl({ method: command }, label);
const method = lifecycleMethod(command);
if (method) sendControl(method, label);
}
function isEditableTarget(target: EventTarget | null): boolean {
@@ -556,8 +635,9 @@
switch (request.kind) {
case "user":
return {
method: "run",
method: "submit",
params: {
submission_request_id: crypto.randomUUID(),
input: request.segments ?? [
{ kind: "text", content: request.content },
],
@@ -566,10 +646,17 @@
case "notify":
return {
method: "notify",
params: { message: request.content, auto_run: true },
params: {
notification_request_id: crypto.randomUUID(),
message: request.content,
auto_run: true,
},
};
case "compact":
return { method: "compact" };
case "compact": {
const method = lifecycleMethod("compact");
if (!method) throw new Error("Worker state snapshot is not available");
return method;
}
case "list_rewind_targets":
return { method: "list_rewind_targets" };
case "register_peer":
@@ -632,12 +719,20 @@
function handleComposerSubmit() {
if (workerRunning) {
sendControl({ method: "cancel" }, "Stop");
sendWorkerControl("cancel");
return;
}
void submitDraft(composerInputElement?.snapshot() ?? draft);
}
function handleQueueSubmit() {
void submitDraft(composerInputElement?.snapshot() ?? draft, "queue");
}
function handleNotifySubmit() {
void submitDraft(composerInputElement?.snapshot() ?? draft, "notify");
}
function attachmentPath(): string {
return `/api/w/${encodeURIComponent(workspaceId)}/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}`;
}
@@ -737,7 +832,15 @@
if (event.dataTransfer?.files) addAttachmentFiles(event.dataTransfer.files);
}
async function submitDraft(value: ComposerDraftSnapshot) {
async function submitDraft(
value: ComposerDraftSnapshot,
delivery: ComposerDelivery = "submit",
) {
if (delivery === "notify" && attachments.length > 0) {
composerNotice = null;
sendError = "Notify accepts text only; remove attachments or queue a Submit.";
return;
}
const incompleteAttachment = attachments.find((attachment) =>
attachment.state !== "uploaded" || !attachment.reference
);
@@ -767,19 +870,38 @@
composerInputElement?.clear();
return;
}
if (sending || !inputReady) {
const deliveryState = {
delivery,
workerState,
protocolOpen: protocolState === "open",
sending,
hasText: value.content.trim().length > 0,
hasAttachments: attachments.length > 0,
};
if (!canDeliverComposerDraft(deliveryState)) {
return;
}
let request: WorkerConsoleInputRequest = command.request;
if (delivery === "notify") {
if (request.kind !== "user") {
composerNotice = null;
sendError = "Notify accepts ordinary text, not a Composer command.";
return;
}
request = { kind: "notify", content: request.content };
}
sending = true;
sendError = null;
try {
const method = composerRequestToProtocolMethod(command.request);
sendProtocolMethod(method);
const method = composerRequestToProtocolMethod(request);
if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) {
return;
}
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
attachments = [];
if (method.method === "run" || method.method === "notify") {
if (method.method === "submit" || method.method === "notify") {
liveWorkerState = "running";
}
composerNotice = "Sent through Worker protocol.";
@@ -795,18 +917,16 @@
handleComposerSubmit();
}
function workerStateFromProtocolEvent(
event: PodProtocolEvent,
function workerStateFromSnapshot(
snapshot: ConsoleProjection["workerState"],
): string | null {
switch (event.event) {
case "snapshot":
case "status":
return event.data.status;
case "shutdown":
return "shutdown";
default:
return null;
}
if (!snapshot) return null;
return snapshot.state.kind === "idle"
? "idle"
: snapshot.state.state.kind === "run" &&
snapshot.state.state.state === "paused"
? "paused"
: "running";
}
function connectProtocolTransport(
@@ -1526,7 +1646,10 @@
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "compact" }, "Compact")}
onclick={() => {
const method = lifecycleMethod("compact");
if (method) sendControl(method, "Compact");
}}
>
Compact
</button>
@@ -1722,6 +1845,62 @@
</aside>
{/if}
{#if pendingSubmissionItems.length > 0 || pendingSubmissions.notification_count > 0}
<details class="pending-submissions">
<summary>
Pending activations ({pendingSubmissionItems.length} submissions · {pendingSubmissions.notification_count} notifications)
</summary>
<ol>
{#each pendingSubmissionItems as submission (submission.submission_id)}
<li>
<code>{submission.submission_id}</code>
<span>{submission.segment_count} segments · {submission.byte_len} bytes</span>
<button
type="button"
onclick={() =>
sendControl(
{
method: "cancel_pending_submission",
params: {
submission_id: submission.submission_id,
expected_revision: pendingSubmissions.revision,
},
},
"Pending submission cancellation",
)}
>Cancel</button>
</li>
{/each}
</ol>
<button
type="button"
disabled={workerRunning || pendingSubmissions.head_id === null}
onclick={() =>
sendControl(
{
method: "continue_pending",
params: {
expected_revision: pendingSubmissions.revision,
expected_head_id: pendingSubmissions.head_id ?? "",
},
},
"Pending activation continue",
)}
>Continue next</button>
<button
type="button"
onclick={() =>
sendControl(
{
method: "clear_pending_submissions",
params: { expected_revision: pendingSubmissions.revision },
},
"Pending submissions clear",
)}
>Clear all</button>
</details>
{/if}
{#if workerRunning}
<WorkerRunStatus
startedAtMs={consoleProjection.runActivity.startedAtMs}
@@ -1854,6 +2033,18 @@
</div>
</div>
<div class="composer-actions">
{#if workerRunning}
<button
type="button"
disabled={!canQueueDraft}
onclick={handleQueueSubmit}
>Queue Submit</button>
<button
type="button"
disabled={!canNotifyDraft}
onclick={handleNotifySubmit}
>Notify</button>
{/if}
{#if composerNotice}
<span class="composer-notice">{composerNotice}</span>
{/if}
@@ -2035,6 +2226,31 @@
display: none;
}
.pending-submissions {
margin: 0 var(--space-3);
color: var(--muted);
font-size: 0.75rem;
}
.pending-submissions ol {
display: grid;
gap: var(--space-1);
margin: var(--space-2) 0;
padding-left: var(--space-5);
}
.pending-submissions li {
display: flex;
gap: var(--space-2);
align-items: center;
}
.pending-submissions code {
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
}
.console-log {
display: grid;
align-content: start;
@@ -1,20 +1,13 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type {
RuntimeConnectionTestResponse,
WorkspaceRuntimeResource,
} 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 { 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,13 +15,32 @@
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 {
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
}
function managementLabel(runtime: Runtime): string {
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: WorkspaceRuntimeResource): string {
if (runtime.management?.built_in) return 'Built-in';
if (runtime.management?.config_managed) return 'Managed remote';
return 'Observed';
@@ -68,39 +80,11 @@
}
}
async function deleteRuntime(runtime: Runtime): Promise<void> {
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
requestError = null;
busyRuntimeId = runtime.runtime_id;
try {
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtime.runtime_id)}`),
{ method: 'DELETE' },
);
if (!response.ok) throw new Error(await responseError(response));
const nextResults = { ...testResults };
delete nextResults[runtime.runtime_id];
testResults = nextResults;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function testRuntime(runtime: Runtime): Promise<void> {
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);
@@ -121,12 +105,14 @@
<h1 id="runtimes-heading">Runtimes</h1>
<p>Register and inspect the execution backends available to this Workspace.</p>
</div>
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
{showAddRuntime ? 'Close' : 'Add Runtime'}
</button>
{#if data.workspace.permissions.manage_runtimes}
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
{showAddRuntime ? 'Close' : 'Add Runtime'}
</button>
{/if}
</header>
{#if showAddRuntime}
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
<form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2>
<div class="settings-form-grid">
@@ -180,7 +166,11 @@
{#each data.runtimes.items as runtime}
<tr class:inactive={runtime.status !== 'active'}>
<td>
<strong>{runtime.label}</strong>
<strong>
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
{runtime.label}
</a>
</strong>
<small><code>{runtime.runtime_id}</code></small>
</td>
<td>{runtime.kind}</td>
@@ -201,15 +191,8 @@
onclick={() => testRuntime(runtime)}
>Test</button>
{/if}
{#if runtime.management?.removable}
<button
class="danger"
type="button"
disabled={busyRuntimeId !== null}
onclick={() => deleteRuntime(runtime)}
>Delete</button>
{:else}
<span class="settings-muted-action">Not removable</span>
{#if !runtime.management?.config_managed}
<span class="settings-muted-action">Test unavailable</span>
{/if}
</div>
</td>
@@ -229,10 +212,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>
@@ -1,11 +1,19 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ListResponse, Runtime } from "$lib/workspace/sidebar/types";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const runtimes = await loadJson<ListResponse<Runtime>>(
const runtimes = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/runtimes"),
undefined,
(value) => {
const response = parseWorkspaceRuntimeList(value);
if (response.workspace_id !== params.workspaceId) {
throw new Error("Runtime list Workspace did not match the route");
}
return response;
},
);
return {
@@ -0,0 +1,471 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type {
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api';
import {
previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
RuntimeTrustRequestError,
type RuntimeTrustRouteOperation,
} from '$lib/workspace/api/runtime-management';
import type { PageProps } from './$types';
type TrustAction = 'create' | 'replace' | 'reactivate';
let { data }: PageProps = $props();
let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null);
let publicKey = $state('');
let fingerprintConfirmation = $state('');
let revokeFingerprintConfirmation = $state('');
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null);
let fieldError = $state<string | null>(null);
let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let replacementFingerprint = $state<string | null>(null);
let replacementFingerprintError = $state<string | null>(null);
let fingerprintGeneration = 0;
const routeFence = new RuntimeTrustRouteFence();
let routeGeneration = 0;
$effect(() => {
const nextGeneration = routeFence.enter(data.runtimeId);
if (nextGeneration === routeGeneration) return;
routeGeneration = nextGeneration;
fingerprintGeneration += 1;
showPublicKey = false;
revealedPublicKey = null;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
busyAction = null;
fieldError = null;
requestError = null;
successMessage = null;
replacementFingerprint = null;
replacementFingerprintError = null;
});
$effect(() => {
const key = publicKey.trim();
const generation = ++fingerprintGeneration;
replacementFingerprint = null;
replacementFingerprintError = null;
if (!key) return;
void previewRuntimePublicKeyFingerprint(key).then(
(fingerprint) => {
if (generation === fingerprintGeneration) replacementFingerprint = fingerprint;
},
(error) => {
if (generation === fingerprintGeneration) {
replacementFingerprintError = error instanceof Error
? error.message
: String(error);
}
},
);
});
function trustAction(status: RuntimeTrustKeyStatus): TrustAction {
if (status === 'unconfigured') return 'create';
if (status === 'revoked') return 'reactivate';
return 'replace';
}
function actionLabel(action: TrustAction): string {
switch (action) {
case 'create': return 'Create Workspace trust';
case 'replace': return 'Replace trusted key';
case 'reactivate': return 'Reactivate with this key';
}
}
function formatTimestamp(value: string | null | undefined): string {
if (!value) return '—';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
async function reloadAuthority(): Promise<void> {
await invalidateAll();
}
function isCurrentRoute(operation: RuntimeTrustRouteOperation): boolean {
return routeFence.isCurrent(operation, data.runtimeId);
}
async function saveTrustKey(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
fieldError = null;
requestError = null;
successMessage = null;
const key = publicKey.trim();
if (!key) {
fieldError = 'Enter the Runtime public key.';
return;
}
if (utf8Bytes(key) > 16 * 1024) {
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
return;
}
if (replacementFingerprintError) {
fieldError = replacementFingerprintError;
return;
}
if (!replacementFingerprint) {
fieldError = 'Wait for the replacement fingerprint preview before saving.';
return;
}
const trust = data.runtimeDetail.trust_key;
const action = trustAction(trust.status);
if (action !== 'create') {
if (!trust.fingerprint) {
requestError = 'The authoritative fingerprint is unavailable. Reload before changing trust.';
return;
}
if (fingerprintConfirmation.trim() !== trust.fingerprint) {
fieldError = 'Enter the current fingerprint exactly to confirm this change.';
return;
}
}
const request: PutRuntimeTrustKeyRequest = {
public_key: key,
expected_revision: trust.revision ?? null,
};
const operation = routeFence.capture(data.runtimeId);
busyAction = 'save';
try {
await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request);
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = action === 'create'
? 'Workspace trust was created.'
: action === 'replace'
? 'The trusted Runtime key was replaced.'
: 'Workspace trust was reactivated.';
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
fingerprintConfirmation = '';
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
} else if (error instanceof RuntimeTrustRequestError && error.field === 'public_key') {
fieldError = error.message;
} else {
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
}
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function revokeTrust(): Promise<void> {
if (busyAction !== null || !data.runtimeDetail) return;
const trust = data.runtimeDetail.trust_key;
if (trust.revision == null || trust.status !== 'active') {
requestError = 'Only active Workspace trust can be revoked.';
return;
}
if (
!trust.fingerprint ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
) {
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
return;
}
fieldError = null;
requestError = null;
successMessage = null;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'revoke';
const request: RevokeRuntimeTrustKeyRequest = {
expected_revision: trust.revision,
};
try {
await revokeRuntimeTrustKey(
data.workspaceId,
operation.runtimeId,
request,
trust.fingerprint,
revokeFingerprintConfirmation,
);
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = 'Workspace trust was revoked.';
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
} else {
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
}
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function togglePublicKeyReveal(): Promise<void> {
if (showPublicKey) {
showPublicKey = false;
revealedPublicKey = null;
return;
}
if (busyAction !== null) return;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'reveal';
requestError = null;
successMessage = null;
try {
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
revealedPublicKey = response.public_key;
showPublicKey = true;
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error ? error.message : 'Public key reveal failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function copyPublicKey(): Promise<void> {
if (busyAction !== null) return;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'copy';
requestError = null;
successMessage = null;
try {
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
await navigator.clipboard.writeText(response.public_key);
if (!isCurrentRoute(operation)) return;
successMessage = 'Public key copied.';
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error
? error.message
: 'The browser could not copy the public key.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
</script>
<svelte:head>
<title>{data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace</title>
<meta name="description" content="Runtime identity and Workspace trust settings" />
</svelte:head>
<section class="runtime-detail-page" aria-labelledby="runtime-detail-heading">
<header class="page-header-row">
<div>
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`}>Runtimes</a>
<h1 id="runtime-detail-heading">{data.runtimeDetail?.runtime.label ?? data.runtimeId}</h1>
<p><code>{data.runtimeId}</code></p>
</div>
<a class="button-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(data.runtimeId)}/workdirs`}>
Workdirs
</a>
</header>
{#if data.runtimeDetailError}
<p class="section-state error">{data.runtimeDetailError}</p>
{:else if !data.runtimeDetail}
<p class="section-state">Loading Runtime…</p>
{:else}
{@const detail = data.runtimeDetail}
{@const runtime = detail.runtime}
{@const trust = detail.trust_key}
{@const currentAction = trustAction(trust.status)}
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
<h2 id="runtime-identity-heading">Identity and binding</h2>
<dl class="runtime-detail-grid">
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
<div><dt>Binding status</dt><dd>{trust.status}</dd></div>
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
</dl>
{#if runtime.diagnostics.length > 0}
<ul class="settings-diagnostics-list">
{#each runtime.diagnostics as diagnostic}
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
<h2 id="runtime-trust-heading">Workspace trust</h2>
{#if trust.status !== 'unconfigured'}
<div class="runtime-public-key-actions">
<button
type="button"
class="secondary"
disabled={busyAction !== null}
onclick={togglePublicKeyReveal}
>
{busyAction === 'reveal' ? 'Loading…' : showPublicKey ? 'Hide public key' : 'Reveal public key'}
</button>
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
</button>
</div>
{#if showPublicKey && revealedPublicKey}
<pre class="runtime-public-key"><code>{revealedPublicKey}</code></pre>
{/if}
{/if}
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
<label for="runtime-public-key-input">Runtime public key</label>
<textarea
id="runtime-public-key-input"
bind:value={publicKey}
rows="5"
autocomplete="off"
spellcheck="false"
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
aria-invalid={fieldError ? 'true' : undefined}
placeholder="yoi-ed25519-pub:v1:…"
></textarea>
<dl class="runtime-trust-comparison">
<div>
<dt>Current fingerprint</dt>
<dd><code>{trust.fingerprint ?? 'Not configured'}</code></dd>
</div>
<div>
<dt>Replacement fingerprint</dt>
<dd><code>{replacementFingerprint ?? 'Enter a valid public key'}</code></dd>
</div>
</dl>
{#if replacementFingerprintError}
<p class="field-error">{replacementFingerprintError}</p>
{/if}
{#if currentAction !== 'create'}
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
<input
id="runtime-fingerprint-confirmation"
bind:value={fingerprintConfirmation}
autocomplete="off"
spellcheck="false"
placeholder={trust.fingerprint ?? ''}
/>
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly.</small>
{/if}
{#if fieldError}
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
{/if}
<div class="settings-action-row">
<button type="submit" disabled={busyAction !== null}>
{busyAction === 'save' ? 'Saving…' : actionLabel(currentAction)}
</button>
</div>
</form>
<div class="runtime-revoke-row">
<div>
<strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
<label>
Confirm current fingerprint
<input
bind:value={revokeFingerprintConfirmation}
autocomplete="off"
spellcheck="false"
disabled={trust.status !== 'active' || busyAction !== null}
/>
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly before revocation.</small>
</label>
</div>
<button
type="button"
class="danger"
disabled={
busyAction !== null ||
trust.status !== 'active' ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
}
onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div>
{#if requestError}
<p class="section-state error" role="alert">{requestError}</p>
{/if}
{#if successMessage}
<p class="section-state success" role="status">{successMessage}</p>
{/if}
</section>
{/if}
<section class="runtime-detail-section" aria-labelledby="runtime-audit-heading">
<h2 id="runtime-audit-heading">Recent trust audit</h2>
{#if detail.recent_audit.length === 0}
<p class="section-state">No trust changes are recorded.</p>
{:else}
<div class="runtime-audit-table-wrap">
<table class="runtime-audit-table">
<thead>
<tr><th>Action</th><th>Revision</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
</thead>
<tbody>
{#each detail.recent_audit as entry}
<tr>
<td>{entry.action}</td>
<td>{entry.revision.toString()}</td>
<td><code>{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}</code></td>
<td><code>{entry.actor_account_id}</code></td>
<td>{formatTimestamp(entry.at)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{/if}
</section>
@@ -0,0 +1,31 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeDetail } from "$lib/workspace/api/runtime-management";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const detail = await loadJson(
fetch,
workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(params.runtimeId)}`,
),
undefined,
(value) => {
const response = parseWorkspaceRuntimeDetail(value);
if (
response.workspace_id !== params.workspaceId ||
response.runtime.runtime_id !== params.runtimeId
) {
throw new Error("Runtime detail did not match the route");
}
return response;
},
);
return {
workspaceId: params.workspaceId,
runtimeId: params.runtimeId,
runtimeDetail: detail.data,
runtimeDetailError: detail.error,
};
};
@@ -1,8 +1,20 @@
<script lang="ts">
import type {
Diagnostic,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
WorkspaceMetadataSettingsResponse,
} from '$lib/generated/workspace-api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import {
getWorkspaceDeletion,
preflightWorkspaceDeletion,
startWorkspaceDeletion,
} from '$lib/workspace/settings/workspace-deletion-api';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadata,
@@ -19,6 +31,17 @@
let submitting = $state(false);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
let deletionOpen = $state(false);
let deletionLoading = $state(false);
let deletionSubmitting = $state(false);
let deletionConfirmation = $state('');
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
let deletionError = $state<string | null>(null);
function deletionStorageKey(): string {
return `yoi:workspace-deletion:${workspaceId}`;
}
$effect(() => {
if (!workspaceId) {
@@ -69,6 +92,100 @@
submitting = false;
}
}
async function openDeletionConfirmation() {
deletionOpen = true;
deletionLoading = true;
deletionError = null;
deletionOperation = null;
deletionRequest = null;
sessionStorage.removeItem(deletionStorageKey());
deletionConfirmation = '';
try {
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
} finally {
deletionLoading = false;
}
}
async function trackDeletion(operationId: string) {
let operation = await getWorkspaceDeletion(operationId);
deletionOperation = operation;
while (operation.state === 'queued' || operation.state === 'running') {
await new Promise((resolve) => setTimeout(resolve, 500));
operation = await getWorkspaceDeletion(operation.operation_id);
deletionOperation = operation;
}
if (operation.state === 'succeeded') {
sessionStorage.removeItem(deletionStorageKey());
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
await goto('/');
}
}
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
if (typeof value !== 'object' || value === null) return null;
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
) return null;
return {
operation_id: record.operation_id,
expected_revision: record.expected_revision,
confirmation: record.confirmation,
};
} catch {
return null;
}
}
onMount(() => {
if (!data.workspace?.permissions.delete_workspace) return;
const request = storedDeletionRequest();
if (!request) return;
deletionRequest = request;
deletionConfirmation = request.confirmation;
deletionOpen = true;
deletionSubmitting = true;
void trackDeletion(request.operation_id)
.catch((err) => {
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
})
.finally(() => {
deletionSubmitting = false;
});
});
async function deleteWorkspace() {
if (!deletionPreflight && !deletionRequest) return;
deletionSubmitting = true;
deletionError = null;
try {
const request = deletionRequest ?? {
operation_id: crypto.randomUUID(),
expected_revision: deletionPreflight!.expected_revision,
confirmation: deletionConfirmation,
};
deletionRequest = request;
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
const operation = await startWorkspaceDeletion(workspaceId, request);
deletionOperation = operation;
await trackDeletion(operation.operation_id);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
} finally {
deletionSubmitting = false;
}
}
</script>
<svelte:head>
@@ -113,3 +230,67 @@
{/if}
<DiagnosticsList {diagnostics} />
</section>
{#if data.workspace?.permissions.delete_workspace}
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
<div>
<h2 id="workspace-danger-title">Danger zone</h2>
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
</div>
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
</section>
{/if}
{#if deletionOpen}
<div class="modal-backdrop" role="presentation">
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
{#if deletionLoading}
<p>Loading deletion impact…</p>
{:else if deletionPreflight}
<p>This operation cannot be undone. It will remove:</p>
<ul>
<li>{deletionPreflight.resources.workers} Workers</li>
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
<li>{deletionPreflight.resources.repositories} repositories</li>
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
<li>{deletionPreflight.resources.secrets} secret records</li>
<li>{deletionPreflight.resources.artifacts} artifacts</li>
</ul>
{#each deletionPreflight.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
<label>
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
<input bind:value={deletionConfirmation} autocomplete="off" />
</label>
{/if}
{#if deletionOperation}
<p class="status-message">Deletion state: {deletionOperation.state}</p>
{#each deletionOperation.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
{/if}
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
<div class="dialog-actions">
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
<button
class="danger-button"
type="button"
onclick={() => void deleteWorkspace()}
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
</div>
</div>
</div>
{/if}
<style>
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
</style>
@@ -4,6 +4,7 @@
import { workerHref } from '$lib/workspace/resource-links';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
import { liveWorkerState } from '$lib/workspace/sidebar/worker-state';
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
@@ -136,7 +137,7 @@
}
function workerStatus(worker: Worker): string {
return worker.state;
return liveWorkerState(worker);
}
function workerProfile(worker: Worker): string {
@@ -1,5 +1,6 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { liveWorkerState } from '$lib/workspace/sidebar/worker-state';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
@@ -24,7 +25,7 @@
>Open console</a>
</header>
<dl class="resource-meta">
<dt>Status</dt><dd>{data.worker.state}</dd>
<dt>Status</dt><dd>{liveWorkerState(data.worker)}</dd>
<dt>Profile</dt><dd>{data.worker.profile}</dd>
<dt>Internal ID</dt><dd><code>{data.worker.worker_id}</code></dd>
</dl>
@@ -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",
);
});
@@ -0,0 +1,157 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
readTextFile(path: URL): Promise<string>;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Runtime Settings routes validate unknown JSON through the shared Runtime parser", async () => {
const [listLoader, detailLoader] = await Promise.all([
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/+page.ts",
import.meta.url,
),
),
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts",
import.meta.url,
),
),
]);
assert(
listLoader.includes("parseWorkspaceRuntimeList(value)"),
"Runtime list loader should validate unknown JSON",
);
assert(
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
"Runtime detail loader should validate unknown JSON",
);
for (const source of [listLoader, detailLoader]) {
assert(
!source.includes("loadJson<"),
"Runtime loaders must not cast response JSON to a handwritten DTO",
);
}
});
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte",
import.meta.url,
),
);
assert(
page.includes(
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}",
),
"Runtime name should link to canonical detail",
);
assert(
page.includes("testRuntime(runtime)"),
"connection Test should remain available",
);
assert(page.includes("Add Runtime"), "Add Runtime should remain available");
assert(
page.includes("data.workspace.permissions.manage_runtimes"),
"Add Runtime should be hidden from non-owners",
);
assert(
!page.includes("deleteRuntime"),
"inline Runtime delete logic must be removed",
);
assert(
!page.includes(">Delete</button>"),
"inline Runtime delete control must be removed",
);
});
Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
import.meta.url,
),
);
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
const reveal = page.indexOf("Reveal public key");
const mutation = page.indexOf('id="runtime-public-key-input"');
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
assert(
page.includes("Current fingerprint"),
"current fingerprint must be explicit",
);
assert(
page.includes("Replacement fingerprint"),
"replacement fingerprint must be previewed before confirmation",
);
assert(
page.includes("!runtime.management.built_in"),
"Runtime trust controls should be hidden for the built-in Runtime",
);
assert(
ownerGate < reveal && ownerGate < mutation,
"owner gate should wrap key controls",
);
for (
const token of [
"Create Workspace trust",
"Replace trusted key",
"Reactivate with this key",
"Confirm current fingerprint",
"Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
"RuntimeTrustConflictError",
"RuntimeTrustRouteFence",
"routeFence.enter(data.runtimeId)",
"showPublicKey = false",
"revealedPublicKey = null",
"publicKey = ''",
"fingerprintConfirmation = ''",
"revokeFingerprintConfirmation = ''",
"requestError = null",
"successMessage = null",
"isCurrentRoute(operation)",
"revealRuntimeTrustKey",
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
"await reloadAuthority()",
"busyAction !== null",
"Workdirs",
"Recent trust audit",
]
) {
assert(page.includes(token), `Runtime detail should include ${token}`);
}
});
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
const [page, css] = await Promise.all([
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
import.meta.url,
),
),
Deno.readTextFile(
new URL("../src/lib/workspace/styles/settings.css", import.meta.url),
),
]);
assert(
!page.includes('class="card"') && !page.includes("settings-card"),
"Runtime detail should not add card nesting",
);
assert(
css.includes(".runtime-detail-section") &&
css.includes("border-top: 1px solid var(--line)"),
"Runtime detail hierarchy should use flat section separators",
);
});
@@ -0,0 +1,287 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList,
previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
} from "../src/lib/workspace/api/runtime-management.ts";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertThrows(operation: () => unknown, expected: string): void {
try {
operation();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes(expected)) return;
throw new Error(
`expected error containing ${expected}, received ${message}`,
);
}
throw new Error("expected operation to throw");
}
function runtime() {
return {
management: {
built_in: false,
config_managed: true,
removable: false,
endpoint_configured: true,
token_ref_configured: false,
},
runtime_id: "arcadia",
label: "Arcadia",
kind: "remote",
status: "started",
source: {
kind: "remote_http",
status: "active",
identity_authority: "server_runtime_configuration",
note: "Configured by Server authority",
},
host_ids: ["host-a"],
worker_creation_available: true,
os: "linux",
arch: "x86_64",
diagnostics: [],
};
}
function detail() {
return {
workspace_id: "workspace-a",
runtime: runtime(),
endpoint: "https://runtime.example.test",
trust_key: {
status: "active",
fingerprint: "SHA256:current",
revision: 3,
created_at: "2026-09-01T12:00:00Z",
updated_at: "2026-09-01T13:00:00Z",
revoked_at: null,
},
recent_audit: [{
action: "created",
actor_account_id: "account-a",
old_fingerprint: null,
new_fingerprint: "SHA256:current",
revision: 3,
at: "2026-09-01T13:00:00Z",
}],
};
}
Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes", () => {
const list = parseWorkspaceRuntimeList({
workspace_id: "workspace-a",
limit: 200,
items: [runtime()],
source: "workspace-control-plane",
diagnostics: [],
});
assert(
list.items[0]?.runtime_id === "arcadia",
"Runtime ID was not preserved",
);
const parsed = parseWorkspaceRuntimeDetail(detail());
assert(
parsed.trust_key.revision === 3,
"revision was not preserved as a safe integer",
);
assert(
parsed.recent_audit[0]?.revision === 3,
"audit revision was not normalized",
);
});
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
assertThrows(
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
"head_tree is not part",
);
const futureSource = structuredClone(detail());
futureSource.runtime.source.kind = "future_transport";
assertThrows(
() => parseWorkspaceRuntimeDetail(futureSource),
"contains an unknown enum value",
);
assertThrows(
() =>
parseRuntimeTrustConflict({
error: "future_conflict",
message: "conflict",
current_revision: 4,
current_fingerprint: "SHA256:new",
}),
"contains an unknown enum value",
);
});
Deno.test("Runtime validators reject unsafe revisions and bounded collection overflow", () => {
const unsafeRevision = structuredClone(detail());
unsafeRevision.trust_key.revision = Number.MAX_SAFE_INTEGER + 1;
assertThrows(
() => parseWorkspaceRuntimeDetail(unsafeRevision),
"must be a safe integer",
);
const tooMuchAudit = structuredClone(detail());
tooMuchAudit.recent_audit = Array.from(
{ length: 21 },
() => structuredClone(detail().recent_audit[0]),
);
assertThrows(
() => parseWorkspaceRuntimeDetail(tooMuchAudit),
"must contain at most 20 items",
);
const tooManyItems = Array.from({ length: 201 }, () => runtime());
assertThrows(
() =>
parseWorkspaceRuntimeList({
workspace_id: "workspace-a",
limit: 200,
items: tooManyItems,
source: "workspace-control-plane",
diagnostics: [],
}),
"must contain at most 200 items",
);
});
Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => {
assertThrows(
() =>
parseRuntimeTrustKeyRevealResponse({
public_key: "x".repeat(16 * 1024 + 1),
}),
"must be at most 16384 UTF-8 bytes",
);
const activeWithoutFingerprint = structuredClone(detail()) as Record<
string,
unknown
>;
(activeWithoutFingerprint.trust_key as Record<string, unknown>).fingerprint =
null;
assertThrows(
() => parseWorkspaceRuntimeDetail(activeWithoutFingerprint),
"must include fingerprint",
);
});
Deno.test("mismatched revoke fingerprint never sends a request", async () => {
let requests = 0;
const fetchImpl: typeof fetch = () => {
requests += 1;
return Promise.reject(new Error("request must not be sent"));
};
let rejected = false;
try {
await revokeRuntimeTrustKey(
"workspace-a",
"runtime-a",
{ expected_revision: 3 },
"sha256:current",
"sha256:different",
fetchImpl,
);
} catch (error) {
rejected = error instanceof Error &&
error.message.includes("current fingerprint exactly");
}
assert(rejected, "mismatched fingerprint should be rejected locally");
assert(requests === 0, "mismatched fingerprint sent a revoke request");
});
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
const fence = new RuntimeTrustRouteFence();
fence.enter("runtime-a");
const operation = fence.capture("runtime-a");
let renderedKey: string | null = null;
let resolveReveal!: (key: string) => void;
const delayedReveal = new Promise<string>((resolve) => {
resolveReveal = resolve;
}).then((key) => {
if (fence.isCurrent(operation, "runtime-b")) renderedKey = key;
});
fence.enter("runtime-b");
resolveReveal("runtime-a-public-key");
await delayedReveal;
assert(
renderedKey === null,
"Runtime A key rendered after navigating to Runtime B",
);
});
Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => {
const fingerprint = await previewRuntimePublicKeyFingerprint(
"yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
);
assert(
fingerprint ===
"sha256:66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925",
"fingerprint preview drifted from the Server SHA-256 contract",
);
});
Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => {
let sentBody: unknown = null;
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {
sentBody = JSON.parse(String(init?.body)) as unknown;
return Promise.resolve(
new Response(
JSON.stringify({
error: "stale_revision",
message: "Runtime trust changed",
current_revision: 4,
current_fingerprint: "SHA256:new",
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
try {
await putRuntimeTrustKey(
"workspace-a",
"arcadia",
{ public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 },
fetchImpl,
);
throw new Error("expected mutation to reject");
} catch (error) {
assert(
error instanceof RuntimeTrustConflictError,
"expected typed conflict",
);
assert(
error.conflict.current_revision === 4,
"authoritative revision was lost",
);
}
assert(
JSON.stringify(sentBody) ===
JSON.stringify({
public_key: "ssh-ed25519 AAAA-new",
expected_revision: 3,
}),
"request should serialize the generated bigint revision as a safe JSON integer",
);
});
+104
View File
@@ -6,6 +6,8 @@ declare const Deno: {
import {
parseRepositoryListApiResult,
parseRepositoryListResponse,
parseWorkspaceDeletionOperationResponse,
parseWorkspaceDeletionPreflightResponse,
parseWorkspaceResponse,
} from "../src/lib/workspace/api/workspace-model.ts";
@@ -109,6 +111,108 @@ Deno.test("workspace response requires the permission projection", () => {
);
});
Deno.test("Workspace deletion DTOs fail closed and preserve durable operation state", () => {
const preflight = parseWorkspaceDeletionPreflightResponse({
workspace_id: "workspace-a",
display_name: "Alpha",
expected_revision: "2026-01-01T00:00:00Z",
can_delete: true,
resources: {
workers: 2,
workdirs: 1,
repositories: 1,
runtime_bindings: 1,
secrets: 0,
artifacts: 3,
},
blockers: [],
});
if (preflight.resources.workers !== 2) {
throw new Error("worker count was not preserved");
}
const operation = parseWorkspaceDeletionOperationResponse({
operation_id: "delete-alpha",
workspace_id: "workspace-a",
display_name: "Alpha",
state: "blocked",
resources: preflight.resources,
child_operation_ids: ["worker-remove:arcadia/7"],
blockers: [{
kind: "dirty_workdir",
resource_kind: "workdir",
resource_key: "WD-1",
message: "Workdir is dirty",
}],
failure_category: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:01:00Z",
completed_at: null,
});
if (operation.state !== "blocked") {
throw new Error("operation state was not preserved");
}
assertThrows(
() =>
parseWorkspaceDeletionPreflightResponse({
...preflight,
unexpected: true,
}),
"unexpected is not part",
);
assertThrows(
() =>
parseWorkspaceDeletionOperationResponse({
...operation,
state: "unknown",
}),
".state is invalid",
);
assertThrows(
() =>
parseWorkspaceDeletionOperationResponse({
...operation,
operation_id: "x".repeat(129),
}),
".operation_id is too long",
);
assertThrows(
() =>
parseWorkspaceDeletionOperationResponse({
...operation,
blockers: Array.from({ length: 1025 }, () => operation.blockers[0]),
}),
".blockers has too many items",
);
});
Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
const source = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/workspace/+page.svelte",
import.meta.url,
),
);
for (
const token of [
"permissions.delete_workspace",
"preflightWorkspaceDeletion",
"startWorkspaceDeletion",
"deletionConfirmation",
"disposeWorkspaceMultiplexer(workspaceId)",
"disposeWorkspaceWorkersStore(workspaceId)",
"sessionStorage.setItem(deletionStorageKey",
"storedDeletionRequest()",
"trackDeletion(request.operation_id)",
]
) {
if (!source.includes(token)) {
throw new Error(`Workspace deletion UI should include ${token}`);
}
}
});
Deno.test("Repository settings consume the validated shared wire shape", async () => {
const [loadSource, pageSource] = await Promise.all([
Deno.readTextFile(