Merge branch 'develop' into hare/develop
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts 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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+259
-43
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user