chore: merge develop into hare/develop

This commit is contained in:
2026-10-06 20:45:19 +09:00
248 changed files with 66394 additions and 4659 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 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@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.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/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.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/route-ownership.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-workdir-meta.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.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/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.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/route-ownership.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-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"build": "deno run -A npm:vite@8.3.2 build",
"preview": "deno run -A npm:vite@8.3.2 preview",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -269,6 +269,107 @@ export type ConfigCommitRequest = {
entrypoints: Array<string>;
};
export type WorkspaceConfigAccess = "read_only" | "read_write";
export type WorkspaceConfigNodeKind = "directory" | "file" | "missing";
export type WorkspaceConfigFailureClassification = "not_committed" | "unknown";
export type WorkspaceConfigAttachRequest = {
alias: string | null;
access: WorkspaceConfigAccess | null;
};
export type WorkspaceConfigAttachment = {
workspace_id: string;
connection_id: string;
alias: string;
working_directory_id: string;
access: WorkspaceConfigAccess;
name: string;
purpose: string;
content_path: string;
already_attached: boolean;
};
export type WorkspaceConfigCurrentResponse = WorkspaceConfigAttachment | null;
export type WorkspaceConfigObserveRequest = {
connection_id: string;
paths: Array<string>;
depth: number;
};
export type WorkspaceConfigNode = {
path: string;
kind: WorkspaceConfigNodeKind;
validator: string;
digest: string | null;
content_type: ConfigContentType | null;
operations: Array<string>;
};
export type WorkspaceConfigObserveResponse = {
connection_id: string;
validator: string;
revision: number;
digest: string;
/**
* Canonical commit entrypoints from this exact metadata snapshot.
*/
entrypoints: Array<string>;
nodes: Array<WorkspaceConfigNode>;
};
export type WorkspaceConfigReadRequest = {
connection_id: string;
path: string;
validator: string;
};
export type WorkspaceConfigReadResponse = {
path: string;
content: string;
content_type: ConfigContentType;
digest: string;
validator: string;
};
export type WorkspaceConfigCommitRequest = {
connection_id: string;
validator: string;
request: ConfigCommitRequest;
};
export type WorkspaceConfigCommitResponse = {
validator: string;
revision: number;
digest: string;
};
export type WorkspaceConfigApiError = {
status: number;
code: string;
message: string;
classification: WorkspaceConfigFailureClassification;
};
export type WorkspaceConfigGrantCreateRequest = {
runtime_id: string;
worker_id: string;
access: WorkspaceConfigAccess;
};
export type WorkspaceConfigGrantResponse = {
grant_id: string;
workspace_id: string;
runtime_id: string;
worker_id: string;
working_directory_id: string;
access: WorkspaceConfigAccess;
revoked: boolean;
};
export type ProfileSettingsResponse = {
workspace_id: string;
registry_revision: string;
@@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = {
maxIdentifierBytes: 512,
} as const;
export const SUBJEKTIV_API_LIMITS = {
maxBehaviorBytes: 16384,
} as const;
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
@@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number,
export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectCreateRequest = { role: string, };
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
File diff suppressed because one or more lines are too long
+60 -6
View File
@@ -6,7 +6,52 @@ export type AlertLevel = "warn" | "error";
export type AlertSource = "worker" | "engine" | "compactor" | "agents_md";
export type CompletionKind = "file";
export type CompletionKind = "file" | "feature" | "feature_argument";
export type CompletionContext = { invocation: FeatureInvocationIdentity, argument?: string | null, };
export type FeatureInvocationIdentity = string;
export type FeatureInvocationSyntax = "parenthesized";
export type InvocationArgumentType = { "kind": "string" } | { "kind": "integer" } | { "kind": "boolean" } | { "kind": "enum", values: Array<string>, } | { "kind": "worker_file" } | { "kind": "client_file" };
export type InvocationCompletion = { "kind": "none" } | { "kind": "static", values: Array<string>, } | { "kind": "worker_file" } | { "kind": "client_file" } | { "kind": "provider", provider: string, };
export type InvocationArgumentDescriptor = { name: string,
/**
* Zero-based positional index. `None` means named-only. A positional
* argument may also be supplied by name; this makes chip editing stable.
*/
position?: number | null, required: boolean, value_type: InvocationArgumentType, completion: InvocationCompletion, description?: string | null, };
export type InvocationClientAdapter = "attachment";
export type FeatureInvocationDescriptor = { identity: FeatureInvocationIdentity, name: string, aliases: Array<string>, display_name: string, description: string, syntax: FeatureInvocationSyntax, arguments: Array<InvocationArgumentDescriptor>, client_adapter?: InvocationClientAdapter | null, };
export type InvocationValue = { "kind": "string", "value": string } | { "kind": "integer", "value": number } | { "kind": "boolean", "value": boolean };
export type InvocationArgumentValue = { name: string, value: InvocationValue, };
export type FeatureInvocation = {
/**
* Stable client-generated id retained across queueing and transport retry.
*/
invocation_id: string, identity: FeatureInvocationIdentity,
/**
* Selected public name, retained for exact draft/history restoration. Host
* resolution is always by `identity`.
*/
name: string, arguments: Array<InvocationArgumentValue>, };
export type FeatureInvocationStatus = "succeeded" | "failed" | "outcome_unknown";
export type FeatureInvocationResult = { invocation_id: string, identity: FeatureInvocationIdentity, status: FeatureInvocationStatus, message: string,
/**
* Bounded model-visible context produced by the handler. Credentials and
* executable implementation details must not be included.
*/
context?: string | null, };
export type WorkerStatus = "idle" | "running" | "paused" | "stopped";
@@ -108,7 +153,7 @@ recursive: boolean,
*/
symlink_policy: SymlinkPolicy, };
export type CompletionEntry = { value: string, is_dir: boolean, };
export type CompletionEntry = { value: string, is_dir: boolean, description?: string | null, usage?: string | null, invocation?: FeatureInvocationDescriptor | null, };
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, };
@@ -270,7 +315,7 @@ export type PasteArtifactRef = { artifact_id: string, created_at_ms: number, med
*/
availability: PasteArtifactAvailability, byte_len: number, char_count: number, line_count: number, sha256: string, source_entry_id: string, };
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "uploaded_file", file: UploadedFileRef, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "uploaded_file", file: UploadedFileRef, } | { "kind": "file_ref", path: string, } | { "kind": "feature_invoke", invocation: FeatureInvocation, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
/**
@@ -314,7 +359,11 @@ alias: string, repository_key?: string | null, working_directory_id: Subscriptio
export type SubscriptionWorkerJob = { job_id: string, attempt_id: string, purpose: string, };
export type SubscriptionWorker = { worker_id: SubscriptionWorkerId,
export type SubscriptionWorker = {
/**
* Opaque Runtime-owned Restore observation identity, never a display-state hash.
*/
restore_observation_token?: string | null, worker_id: SubscriptionWorkerId,
/**
* Set by the Workspace Server when projecting a Runtime-owned Worker to clients.
* Runtime producers leave this unset because the connection identifies the Runtime.
@@ -367,7 +416,12 @@ 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": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "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 Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "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,
/**
* Client query generation. Echoed verbatim so identical-prefix ABA
* responses cannot cross edits, target switches, or permission changes.
*/
request_id?: string | null, context?: CompletionContext | null, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
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": "notification_accepted", "data": { notification_request_id: string, } } | { "event": "notification_rejected", "data": { notification_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": {
/**
@@ -406,4 +460,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": "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": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "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, prefix: string, request_id?: string | null, context?: CompletionContext | null, 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": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -71,7 +71,7 @@ export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyS
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
@@ -79,12 +79,14 @@ export type WorkerWorkspaceSummary = { identity: string; visibility: string; wor
export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; };
export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryMaterializerKind = "logical_workspace_config" | "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectorySource = { access: WorkspaceConfigAccess; content_path: string; kind: "workspace_config"; purpose: string; } | { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type WorkspaceConfigAccess = "read_only" | "read_write";
+46 -8
View File
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -19,6 +19,12 @@ export type CleanupWorkdirFileStatus = "pending" | "present" | "active" | "clean
export type CleanupWorkerCandidate = { action: CleanupTargetKind; blocking_reason?: string | null; estimated_reclaim_bytes?: number | null; linked_workdir_ids: Array<string>; pinned: boolean; reason: string; retention_state: string; running_linked: boolean; runtime_id: string; runtime_worker_id: string; target_id: string; worker_id: string; };
export type CompletionContext = { argument?: string | null; invocation: FeatureInvocationIdentity; };
export type CompletionEntry = { description?: string | null; invocation?: FeatureInvocationDescriptor | null; is_dir?: boolean; usage?: string | null; value: string; };
export type CompletionKind = "file" | "feature" | "feature_argument";
export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_revision?: number | null; public_bundle: RuntimePublicIdentityBundle; };
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
@@ -29,6 +35,32 @@ export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?:
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type FeatureInvocation = { arguments: Array<InvocationArgumentValue>; identity: FeatureInvocationIdentity; invocation_id: string; name: string; };
export type FeatureInvocationDescriptor = { aliases?: Array<string>; arguments?: Array<InvocationArgumentDescriptor>; client_adapter?: InvocationClientAdapter | null; description: string; display_name: string; identity: FeatureInvocationIdentity; name: string; syntax: FeatureInvocationSyntax; };
export type FeatureInvocationIdentity = string;
export type FeatureInvocationSyntax = "parenthesized";
export type InvocationArgumentDescriptor = { completion?: InvocationCompletion; description?: string | null; name: string; position?: number | null; required?: boolean; value_type: InvocationArgumentType; };
export type InvocationArgumentType = { kind: "string"; } | { kind: "integer"; } | { kind: "boolean"; } | { kind: "enum"; values: Array<string>; } | { kind: "worker_file"; } | { kind: "client_file"; };
export type InvocationArgumentValue = { name: string; value: InvocationValue; };
export type InvocationClientAdapter = "attachment";
export type InvocationCompletion = { kind: "none"; } | { kind: "static"; values: Array<string>; } | { kind: "worker_file"; } | { kind: "client_file"; } | { kind: "provider"; provider: string; };
export type InvocationValue = { kind: "string"; value: string; } | { kind: "integer"; value: number; } | { kind: "boolean"; value: boolean; };
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
export type PasteArtifactMediaType = "text_plain_utf8";
export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; };
export type RemoveRuntimeRequest = { expected_binding_revision: number; operation_id: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
@@ -93,11 +125,11 @@ export type RuntimeVerificationEvidenceSummary = { binding_revision: number; las
export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed";
export type RuntimeWorkerCompletionsRequest = { kind: unknown; prefix?: string; };
export type RuntimeWorkerCompletionsRequest = { context?: CompletionContext | null; kind: CompletionKind; prefix?: string; request_id?: string | null; };
export type RuntimeWorkerCompletionsResult = { diagnostics?: Array<Diagnostic>; entries: Array<unknown>; kind: unknown; prefix: string; runtime_id: string; worker_id: string; };
export type RuntimeWorkerCompletionsResult = { context?: CompletionContext | null; diagnostics?: Array<Diagnostic>; entries: Array<CompletionEntry>; kind: CompletionKind; prefix: string; request_id?: string | null; runtime_id: string; worker_id: string; };
export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array<unknown> | null; };
export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array<Segment> | null; };
export type RuntimeWorkerInputResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; };
@@ -107,7 +139,7 @@ export type RuntimeWorkerLifecycleResult = { diagnostics?: Array<Diagnostic>; ru
export type RuntimeWorkerListResponse = { diagnostics?: Array<Diagnostic>; items: Array<WorkerSummary>; limit: number; source: string; workspace_id: string; };
export type RuntimeWorkerShowResponse = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; updated_at: string; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type RuntimeWorkerShowResponse = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; updated_at: string; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type RuntimeWorkerSpawnAcceptanceEvidence = { detail: string; kind: string; };
@@ -127,6 +159,8 @@ export type RuntimeWorkerSummary = { diagnostics?: Array<Diagnostic>; display_na
export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
export type UpdateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; };
@@ -147,6 +181,8 @@ export type WorkerMaintenanceState = "compacting";
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
export type WorkerRestoreRequest = { expected_observation_token: string; request_id: string; };
export type WorkerRestoreResponse = { result: WorkerRestoreResult; runtime_id: string; worker_id: string; workspace_id: string; };
export type WorkerRestoreResult = { diagnostics?: Array<Diagnostic>; state: WorkerRestoreState; worker?: WorkerSummary | null; };
@@ -161,7 +197,7 @@ export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyS
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
@@ -169,16 +205,18 @@ export type WorkerWorkspaceSummary = { identity: string; visibility: string; wor
export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; };
export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryMaterializerKind = "logical_workspace_config" | "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectorySource = { access: WorkspaceConfigAccess; content_path: string; kind: "workspace_config"; purpose: string; } | { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type WorkspaceConfigAccess = "read_only" | "read_write";
export type WorkspaceRuntimeBindingState = "configured" | "verified" | "revoked";
export type WorkspaceRuntimeBindingSummary = { connection_state: RuntimeConnectionDisplayState; revision: number; state: WorkspaceRuntimeBindingState; verification?: RuntimeVerificationEvidenceSummary | null; workspace_key_generation?: number | null; workspace_key_id?: string | null; };
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -27,7 +27,7 @@ export type WorkingDirectoryDetailResponse = { diagnostics: Array<Diagnostic>; i
export type WorkingDirectoryListResponse = { diagnostics: Array<Diagnostic>; items: Array<WorkingDirectorySummary>; workspace_id: string; };
export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryMaterializerKind = "logical_workspace_config" | "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; };
@@ -37,8 +37,10 @@ export type WorkingDirectoryRemovalRequest = { reason: string; };
export type WorkingDirectoryRemovalResponse = { disposition: WorkingDirectoryRemovalDisposition; failure_category?: string | null; retryable: boolean; working_directory_id: string; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectorySource = { access: WorkspaceConfigAccess; content_path: string; kind: "workspace_config"; purpose: string; } | { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type WorkspaceConfigAccess = "read_only" | "read_write";
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -21,6 +21,14 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type FeatureInvocation = { arguments: Array<InvocationArgumentValue>; identity: FeatureInvocationIdentity; invocation_id: string; name: string; };
export type FeatureInvocationIdentity = string;
export type InvocationArgumentValue = { name: string; value: InvocationValue; };
export type InvocationValue = { kind: "string"; value: string; } | { kind: "integer"; value: number; } | { kind: "boolean"; value: boolean; };
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
export type PasteArtifactMediaType = "text_plain_utf8";
@@ -35,7 +43,7 @@ export type RuntimeWorkingDirectoryCleanupTarget = { kind: string; repository_id
export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; repository_id: string; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type SubjektivWorkerConnectionRequest = { subject_id: string; };
@@ -69,7 +77,7 @@ export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyS
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
@@ -77,16 +85,18 @@ export type WorkerWorkspaceSummary = { identity: string; visibility: string; wor
export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; };
export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryMaterializerKind = "logical_workspace_config" | "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; };
export type WorkingDirectoryRepositoryOption = { default_selector?: string | null; repository_key: string; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectorySource = { access: WorkspaceConfigAccess; content_path: string; kind: "workspace_config"; purpose: string; } | { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type WorkspaceConfigAccess = "read_only" | "read_write";
export type WorkspaceWorkerFeatureConnectionsRequest = { subjektiv?: SubjektivWorkerConnectionRequest | null; };
@@ -10,9 +10,13 @@ import type {
RuntimeCleanupPlanResponse,
RuntimeWorkerLifecycleResult,
WorkerOperationState,
WorkerRestoreResponse,
WorkerRestoreState,
WorkerRetentionResponse,
} from "#lib/generated/runtime-api.ts";
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
const MAX_STRING_BYTES = 4_096;
const MAX_CANDIDATES = 1_000;
const MAX_RESULTS = 2_000;
@@ -68,14 +72,20 @@ function exactKeys(
): 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");
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 boundedString(value: unknown, path: string, allowEmpty = false): string {
function boundedString(
value: unknown,
path: string,
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 > MAX_STRING_BYTES) {
@@ -91,7 +101,9 @@ function boolean(value: unknown, path: string): boolean {
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`);
if (value.length > max) {
return fail(path, `must contain at most ${max} items`);
}
return value;
}
@@ -101,12 +113,18 @@ function strings(value: unknown, path: string): string[] {
);
}
function optionalNullableString(value: unknown, path: string): string | null | undefined {
function optionalNullableString(
value: unknown,
path: string,
): string | null | undefined {
if (value === undefined || value === null) return value;
return boundedString(value, path);
}
function optionalNullableSafeInteger(value: unknown, path: string): number | null | undefined {
function optionalNullableSafeInteger(
value: unknown,
path: string,
): number | null | undefined {
if (value === undefined || value === null) return value;
if (!Number.isSafeInteger(value) || (value as number) < 0) {
return fail(path, "must be a non-negative safe integer");
@@ -115,7 +133,9 @@ function optionalNullableSafeInteger(value: unknown, path: string): number | nul
}
function targetKind(value: unknown, path: string): CleanupTargetKind {
if (typeof value !== "string" || !TARGET_KINDS.has(value as CleanupTargetKind)) {
if (
typeof value !== "string" || !TARGET_KINDS.has(value as CleanupTargetKind)
) {
return fail(path, "has an unknown cleanup action");
}
return value as CleanupTargetKind;
@@ -164,13 +184,25 @@ function workerCandidate(value: unknown, path: string): CleanupWorkerCandidate {
target_id: boundedString(item.target_id, `${path}.target_id`),
action: targetKind(item.action, `${path}.action`),
worker_id: boundedString(item.worker_id, `${path}.worker_id`),
runtime_worker_id: boundedString(item.runtime_worker_id, `${path}.runtime_worker_id`),
runtime_worker_id: boundedString(
item.runtime_worker_id,
`${path}.runtime_worker_id`,
),
runtime_id: boundedString(item.runtime_id, `${path}.runtime_id`),
reason: boundedString(item.reason, `${path}.reason`, true),
blocking_reason: optionalNullableString(item.blocking_reason, `${path}.blocking_reason`),
blocking_reason: optionalNullableString(
item.blocking_reason,
`${path}.blocking_reason`,
),
pinned: boolean(item.pinned, `${path}.pinned`),
retention_state: boundedString(item.retention_state, `${path}.retention_state`),
linked_workdir_ids: strings(item.linked_workdir_ids, `${path}.linked_workdir_ids`),
retention_state: boundedString(
item.retention_state,
`${path}.retention_state`,
),
linked_workdir_ids: strings(
item.linked_workdir_ids,
`${path}.linked_workdir_ids`,
),
running_linked: boolean(item.running_linked, `${path}.running_linked`),
estimated_reclaim_bytes: optionalNullableSafeInteger(
item.estimated_reclaim_bytes,
@@ -179,7 +211,10 @@ function workerCandidate(value: unknown, path: string): CleanupWorkerCandidate {
};
}
function workdirCandidate(value: unknown, path: string): CleanupWorkdirCandidate {
function workdirCandidate(
value: unknown,
path: string,
): CleanupWorkdirCandidate {
const item = object(value, path);
exactKeys(
item,
@@ -213,10 +248,19 @@ function workdirCandidate(value: unknown, path: string): CleanupWorkdirCandidate
action: targetKind(item.action, `${path}.action`),
workdir_id: boundedString(item.workdir_id, `${path}.workdir_id`),
runtime_id: boundedString(item.runtime_id, `${path}.runtime_id`),
repository_key: boundedString(item.repository_key, `${path}.repository_key`),
repository_key: boundedString(
item.repository_key,
`${path}.repository_key`,
),
reason: boundedString(item.reason, `${path}.reason`, true),
blocking_reason: optionalNullableString(item.blocking_reason, `${path}.blocking_reason`),
linked_worker_ids: strings(item.linked_worker_ids, `${path}.linked_worker_ids`),
blocking_reason: optionalNullableString(
item.blocking_reason,
`${path}.blocking_reason`,
),
linked_worker_ids: strings(
item.linked_worker_ids,
`${path}.linked_worker_ids`,
),
linked_running_worker_ids: strings(
item.linked_running_worker_ids,
`${path}.linked_running_worker_ids`,
@@ -232,7 +276,9 @@ function workdirCandidate(value: unknown, path: string): CleanupWorkdirCandidate
};
}
export function parseWorkerRetentionResponse(value: unknown): WorkerRetentionResponse {
export function parseWorkerRetentionResponse(
value: unknown,
): WorkerRetentionResponse {
const item = object(value, "Worker retention response");
exactKeys(
item,
@@ -241,9 +287,18 @@ export function parseWorkerRetentionResponse(value: unknown): WorkerRetentionRes
"Worker retention response",
);
return {
workspace_id: boundedString(item.workspace_id, "Worker retention response.workspace_id"),
runtime_id: boundedString(item.runtime_id, "Worker retention response.runtime_id"),
worker_id: boundedString(item.worker_id, "Worker retention response.worker_id"),
workspace_id: boundedString(
item.workspace_id,
"Worker retention response.workspace_id",
),
runtime_id: boundedString(
item.runtime_id,
"Worker retention response.runtime_id",
),
worker_id: boundedString(
item.worker_id,
"Worker retention response.worker_id",
),
pinned: boolean(item.pinned, "Worker retention response.pinned"),
retention_state: boundedString(
item.retention_state,
@@ -268,7 +323,10 @@ export function parseRuntimeWorkerLifecycleResult(
[],
"Runtime Worker lifecycle result",
);
const state = boundedString(item.state, "Runtime Worker lifecycle result.state");
const state = boundedString(
item.state,
"Runtime Worker lifecycle result.state",
);
if (!WORKER_OPERATION_STATES.has(state as WorkerOperationState)) {
fail("Runtime Worker lifecycle result.state", "has an unknown value");
}
@@ -289,7 +347,58 @@ export function parseRuntimeWorkerLifecycleResult(
};
}
export function parseRuntimeCleanupPlan(value: unknown): RuntimeCleanupPlanResponse {
/** Restore is a wrapped Workspace response, not the flat Stop lifecycle result. */
export function parseWorkerRestoreResponse(
value: unknown,
): WorkerRestoreResponse {
const path = "Worker Restore response";
const item = object(value, path);
exactKeys(
item,
["workspace_id", "runtime_id", "worker_id", "result"],
[],
path,
);
const result = object(item.result, `${path}.result`);
exactKeys(result, ["state"], ["diagnostics", "worker"], `${path}.result`);
const state = boundedString(result.state, `${path}.result.state`);
if (
!["accepted", "rejected", "rolled_back", "reconciliation_required"]
.includes(state)
) {
fail(`${path}.result.state`, "has an unknown value");
}
const workspace_id = boundedString(item.workspace_id, `${path}.workspace_id`);
const runtime_id = boundedString(item.runtime_id, `${path}.runtime_id`);
const worker_id = boundedString(item.worker_id, `${path}.worker_id`);
const worker = result.worker == null
? result.worker as null | undefined
: parseWorkerSummary(result.worker);
if (
worker &&
(worker.runtime_id !== runtime_id || worker.worker_id !== worker_id ||
(worker.workspace.workspace_id != null &&
worker.workspace.workspace_id !== workspace_id))
) {
fail(`${path}.result.worker`, "does not match the response identity");
}
return {
workspace_id,
runtime_id,
worker_id,
result: {
state: state as WorkerRestoreState,
diagnostics: result.diagnostics === undefined
? []
: diagnostics(result.diagnostics, `${path}.result.diagnostics`),
worker,
},
};
}
export function parseRuntimeCleanupPlan(
value: unknown,
): RuntimeCleanupPlanResponse {
const item = object(value, "Runtime cleanup plan");
exactKeys(
item,
@@ -307,22 +416,44 @@ export function parseRuntimeCleanupPlan(value: unknown): RuntimeCleanupPlanRespo
"Runtime cleanup plan",
);
return {
workspace_id: boundedString(item.workspace_id, "Runtime cleanup plan.workspace_id"),
runtime_id: boundedString(item.runtime_id, "Runtime cleanup plan.runtime_id"),
generated_at: boundedString(item.generated_at, "Runtime cleanup plan.generated_at"),
workspace_id: boundedString(
item.workspace_id,
"Runtime cleanup plan.workspace_id",
),
runtime_id: boundedString(
item.runtime_id,
"Runtime cleanup plan.runtime_id",
),
generated_at: boundedString(
item.generated_at,
"Runtime cleanup plan.generated_at",
),
revision: boundedString(item.revision, "Runtime cleanup plan.revision"),
digest: boundedString(item.digest, "Runtime cleanup plan.digest"),
workers: array(item.workers, "Runtime cleanup plan.workers", MAX_CANDIDATES).map(
(entry, index) => workerCandidate(entry, `Runtime cleanup plan.workers[${index}]`),
workers: array(item.workers, "Runtime cleanup plan.workers", MAX_CANDIDATES)
.map(
(entry, index) =>
workerCandidate(entry, `Runtime cleanup plan.workers[${index}]`),
),
workdirs: array(
item.workdirs,
"Runtime cleanup plan.workdirs",
MAX_CANDIDATES,
).map(
(entry, index) =>
workdirCandidate(entry, `Runtime cleanup plan.workdirs[${index}]`),
),
workdirs: array(item.workdirs, "Runtime cleanup plan.workdirs", MAX_CANDIDATES).map(
(entry, index) => workdirCandidate(entry, `Runtime cleanup plan.workdirs[${index}]`),
diagnostics: diagnostics(
item.diagnostics,
"Runtime cleanup plan.diagnostics",
),
diagnostics: diagnostics(item.diagnostics, "Runtime cleanup plan.diagnostics"),
};
}
function executionResult(value: unknown, path: string): RuntimeCleanupExecutionResult {
function executionResult(
value: unknown,
path: string,
): RuntimeCleanupExecutionResult {
const item = object(value, path);
exactKeys(item, ["target_id", "action", "status", "message"], [], path);
return {
@@ -339,18 +470,42 @@ export function parseRuntimeCleanupExecution(
const item = object(value, "Runtime cleanup execution");
exactKeys(
item,
["workspace_id", "runtime_id", "executed_at", "results", "plan_after", "diagnostics"],
[
"workspace_id",
"runtime_id",
"executed_at",
"results",
"plan_after",
"diagnostics",
],
[],
"Runtime cleanup execution",
);
return {
workspace_id: boundedString(item.workspace_id, "Runtime cleanup execution.workspace_id"),
runtime_id: boundedString(item.runtime_id, "Runtime cleanup execution.runtime_id"),
executed_at: boundedString(item.executed_at, "Runtime cleanup execution.executed_at"),
results: array(item.results, "Runtime cleanup execution.results", MAX_RESULTS).map(
(entry, index) => executionResult(entry, `Runtime cleanup execution.results[${index}]`),
workspace_id: boundedString(
item.workspace_id,
"Runtime cleanup execution.workspace_id",
),
runtime_id: boundedString(
item.runtime_id,
"Runtime cleanup execution.runtime_id",
),
executed_at: boundedString(
item.executed_at,
"Runtime cleanup execution.executed_at",
),
results: array(
item.results,
"Runtime cleanup execution.results",
MAX_RESULTS,
).map(
(entry, index) =>
executionResult(entry, `Runtime cleanup execution.results[${index}]`),
),
plan_after: parseRuntimeCleanupPlan(item.plan_after),
diagnostics: diagnostics(item.diagnostics, "Runtime cleanup execution.diagnostics"),
diagnostics: diagnostics(
item.diagnostics,
"Runtime cleanup execution.diagnostics",
),
};
}
@@ -131,6 +131,7 @@ export function parseWorkingDirectorySummary(
materializer_kind: enumField(record, "materializer_kind", [
"runtime_git_clone",
"client_hosted_external",
"logical_workspace_config",
]),
status: enumField(record, "status", [
"active",
@@ -177,10 +178,27 @@ export function parseWorkingDirectorySummary(
function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
const source = exactRecord(
value,
new Set(["kind", "repository_key", "grant_id", "grant_permissions"]),
new Set(["kind", "repository_key", "grant_id", "grant_permissions", "access", "content_path", "purpose"]),
"Workdir source",
);
const kind = stringField(source, "kind");
if (kind === "workspace_config") {
if (source.repository_key !== undefined || source.grant_id !== undefined || source.grant_permissions !== undefined) {
throw new Error("Logical config source must not contain repository or External authority");
}
const access = stringField(source, "access");
if (access !== "read_only" && access !== "read_write") {
throw new Error("Logical config access has an unsupported value");
}
const content_path = stringField(source, "content_path");
if (content_path !== "/workspace-config") {
throw new Error("Logical config must reference its WIP content entrance");
}
return { kind, access, content_path, purpose: stringField(source, "purpose") };
}
if (source.access !== undefined || source.content_path !== undefined || source.purpose !== undefined) {
throw new Error("Filesystem Workdir source must not contain logical config metadata");
}
if (kind === "repository") {
if (
source.grant_id !== undefined || source.grant_permissions !== undefined
@@ -382,3 +382,20 @@ Deno.test("Worker create request parser requires the complete shared request", (
"kind is invalid",
);
});
Deno.test("Worker launch request preserves typed Feature invocations and rejects unsafe values", () => {
const invocation = { invocation_id: "invoke-1", identity: "test/run", name: "run", arguments: [
{ name: "path", value: { kind: "string", value: "資料/a b" } },
{ name: "count", value: { kind: "integer", value: 1 } },
{ name: "enabled", value: { kind: "boolean", value: true } },
] };
const request = { runtime_id: "runtime-a", display_name: "Worker A", profile: "builtin:coder", ticket_assignment: null,
initial_submit: [{ kind: "feature_invoke", invocation }], workdir_attachments: [], feature_connections: {}, control_operation_id: null };
assertEquals(parseCreateWorkspaceWorkerRequest(request).initial_submit, request.initial_submit);
assertThrows(() => parseCreateWorkspaceWorkerRequest({ ...request, initial_submit: [{ kind: "feature_invoke", invocation: {
...invocation, arguments: [{ name: "count", value: { kind: "integer", value: 9007199254740992 } }],
} }] }), Error, "safe integer");
assertThrows(() => parseCreateWorkspaceWorkerRequest({ ...request, initial_submit: [{ kind: "feature_invoke", invocation: {
...invocation, extra: true,
} }] }), Error, "unknown field extra");
});
+111 -18
View File
@@ -97,7 +97,11 @@ function optional<T>(
label: string,
parse: (item: unknown, label: string) => T,
): T | null | undefined {
return value === undefined ? undefined : value === null ? null : parse(value, label);
return value === undefined
? undefined
: value === null
? null
: parse(value, label);
}
function diagnostic(value: unknown, label: string): Diagnostic {
@@ -372,6 +376,7 @@ export function parseWorkerSummary(
"availability",
"state",
"worker_state",
"restore_observation_token",
"last_seen_at",
"pinned",
"retention_state",
@@ -406,13 +411,28 @@ export function parseWorkerSummary(
`${label}.singleton_key`,
string,
),
tags: item.tags === undefined ? [] : array(item.tags, `${label}.tags`, string),
tags: item.tags === undefined
? []
: array(item.tags, `${label}.tags`, string),
workspace: workspaceSummary(item.workspace, `${label}.workspace`),
availability,
state: string(item.state, `${label}.state`),
worker_state: workerState,
restore_observation_token: optional(
item.restore_observation_token,
`${label}.restore_observation_token`,
(value, path) => {
const token = string(value, path);
if (new TextEncoder().encode(token).byteLength > 256) {
throw new Error(`${path} exceeds its 256 UTF-8 byte limit`);
}
return token;
},
),
last_seen_at: optional(item.last_seen_at, `${label}.last_seen_at`, string),
pinned: item.pinned === undefined ? false : boolean(item.pinned, `${label}.pinned`),
pinned: item.pinned === undefined
? false
: boolean(item.pinned, `${label}.pinned`),
retention_state: item.retention_state === undefined
? ""
: string(item.retention_state, `${label}.retention_state`),
@@ -420,11 +440,13 @@ export function parseWorkerSummary(
item.implementation,
`${label}.implementation`,
),
workdir_attachments: item.workdir_attachments === undefined ? undefined : array(
item.workdir_attachments,
`${label}.workdir_attachments`,
workerWorkdirAttachment,
),
workdir_attachments: item.workdir_attachments === undefined
? undefined
: array(
item.workdir_attachments,
`${label}.workdir_attachments`,
workerWorkdirAttachment,
),
diagnostics: item.diagnostics === undefined
? []
: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
@@ -634,11 +656,13 @@ function workerSummary(
item.implementation,
`${label}.implementation`,
),
workdir_attachments: item.workdir_attachments === undefined ? undefined : array(
item.workdir_attachments,
`${label}.workdir_attachments`,
runtimeWorkdirAttachment,
),
workdir_attachments: item.workdir_attachments === undefined
? undefined
: array(
item.workdir_attachments,
`${label}.workdir_attachments`,
runtimeWorkdirAttachment,
),
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
};
}
@@ -728,11 +752,17 @@ function featureConnectionsRequest(
): WorkspaceWorkerFeatureConnectionsRequest {
const item = record(value, label);
exact(item, ["subjektiv"], label);
const subjektiv = optional(item.subjektiv, `${label}.subjektiv`, (value, itemLabel) => {
const connection = record(value, itemLabel);
exact(connection, ["subject_id"], itemLabel);
return { subject_id: string(connection.subject_id, `${itemLabel}.subject_id`) };
});
const subjektiv = optional(
item.subjektiv,
`${label}.subjektiv`,
(value, itemLabel) => {
const connection = record(value, itemLabel);
exact(connection, ["subject_id"], itemLabel);
return {
subject_id: string(connection.subject_id, `${itemLabel}.subject_id`),
};
},
);
return subjektiv === undefined ? {} : { subjektiv };
}
@@ -838,6 +868,63 @@ function uploadedFile(
};
}
function invocationValue(
value: unknown,
label: string,
): Extract<
Extract<
Segment,
{ kind: "feature_invoke" }
>["invocation"]["arguments"][number],
{ name: string }
>["value"] {
const item = record(value, label);
const kind = string(item.kind, `${label}.kind`);
exact(item, ["kind", "value"], label);
if (kind === "string") {
return { kind, value: string(item.value, `${label}.value`) };
}
if (kind === "integer") {
const parsed = number(item.value, `${label}.value`);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${label}.value must be a safe integer`);
}
if (parsed < -2147483648 || parsed > 2147483647) {
throw new Error(`${label}.value must be a signed 32-bit integer`);
}
return { kind, value: parsed };
}
if (kind === "boolean") {
return { kind, value: boolean(item.value, `${label}.value`) };
}
throw new Error(`${label}.kind is invalid`);
}
function featureInvocation(
value: unknown,
label: string,
): Extract<Segment, { kind: "feature_invoke" }>["invocation"] {
const item = record(value, label);
exact(item, ["invocation_id", "identity", "name", "arguments"], label);
return {
invocation_id: string(item.invocation_id, `${label}.invocation_id`),
identity: string(item.identity, `${label}.identity`),
name: string(item.name, `${label}.name`),
arguments: array(
item.arguments,
`${label}.arguments`,
(argument, argumentLabel) => {
const entry = record(argument, argumentLabel);
exact(entry, ["name", "value"], argumentLabel);
return {
name: string(entry.name, `${argumentLabel}.name`),
value: invocationValue(entry.value, `${argumentLabel}.value`),
};
},
),
};
}
function segment(value: unknown, label: string): Segment {
const item = record(value, label);
const kind = string(item.kind, `${label}.kind`) as Segment["kind"];
@@ -866,6 +953,12 @@ function segment(value: unknown, label: string): Segment {
case "file_ref":
exact(item, ["kind", "path"], label);
return { kind, path: string(item.path, `${label}.path`) };
case "feature_invoke":
exact(item, ["kind", "invocation"], label);
return {
kind,
invocation: featureInvocation(item.invocation, `${label}.invocation`),
};
case "flow":
exact(item, ["kind", "selector"], label);
return { kind, selector: string(item.selector, `${label}.selector`) };
@@ -2,6 +2,8 @@
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { EditorView } from "@codemirror/view";
import type { FeatureInvocationDescriptor, Segment } from "#lib/generated/protocol.ts";
import { redo, undo } from "@codemirror/commands";
import ComposerInput from "./ComposerInput.svelte";
afterEach(cleanup);
@@ -148,3 +150,362 @@ test("cursor changes, Escape, scope changes and disable discard late file result
await ui.rerender({ disabled: true });
expect(ui.queryByRole("listbox")).toBeNull();
});
test("synchronous draft and cursor ABA invalidate old Feature completion generations", async () => {
const requests: Array<ReturnType<typeof deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>>> = [];
const resolver = vi.fn(() => { const result = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>(); requests.push(result); return result.promise; });
const ui = render(ComposerInput, { historyScope: "feature-aba", resolveFeatureCompletions: resolver });
const cm = editor(ui.container);
type(cm, "/ru");
await waitFor(() => expect(requests).toHaveLength(1));
cm.dispatch({ changes: { from: 2, to: 3, insert: "x" } });
cm.dispatch({ changes: { from: 2, to: 3, insert: "u" } });
requests[0].resolve([{ value: "stale", invocation: invocationDescriptor }]);
await waitFor(() => expect(requests).toHaveLength(2));
expect(ui.queryByRole("option")).toBeNull();
cm.dispatch({ selection: { anchor: 0 } });
cm.dispatch({ selection: { anchor: 3 } });
requests[1].resolve([{ value: "stale-cursor", invocation: invocationDescriptor }]);
await waitFor(() => expect(requests).toHaveLength(3));
expect(ui.queryByRole("option")).toBeNull();
requests[2].resolve([{ value: "run", invocation: invocationDescriptor }]);
expect((await ui.findByRole("option")).textContent).toContain("/run");
});
test("restored chip descriptor edits reject same-draft cursor ABA", async () => {
const request = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>();
const resolver = vi.fn(() => request.promise);
const ui = render(ComposerInput, { historyScope: "edit-cursor-aba", resolveFeatureCompletions: resolver });
ui.component.restoreSegments([invocationSegment]);
const cm = editor(ui.container);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(resolver).toHaveBeenCalledOnce());
cm.dispatch({ selection: { anchor: 0 } });
cm.dispatch({ selection: { anchor: cm.state.doc.length } });
request.resolve([{ value: "run", invocation: invocationDescriptor }]);
await Promise.resolve();
await Promise.resolve();
expect(ui.component.snapshot().segments).toEqual([invocationSegment]);
});
test.each(["Backspace", "Delete", "range", "cut", "replace"])("pending upload %s cancels its reservation before late completion and Undo", async (operation) => {
const oncancelupload = vi.fn();
const ui = render(ComposerInput, { historyScope: `upload-delete-${operation}`, oncancelupload });
const cm = editor(ui.container);
type(cm, "before after", 7);
const reservation = ui.component.reserveUpload("report.md")!;
const from = 7, to = cm.state.selection.main.head;
if (operation === "Backspace" || operation === "Delete") {
cm.dispatch({ selection: { anchor: operation === "Backspace" ? to : from } });
await fireEvent.keyDown(cm.contentDOM, { key: operation });
} else if (operation === "replace") {
cm.dispatch({ changes: { from, to, insert: "replacement" }, userEvent: "input" });
} else {
// happy-dom emits selectionchange synchronously while CodeMirror updates a
// focused DOM range. Exercise the transaction/cut handler unfocused here;
// the Chromium suite covers focused keyboard range deletion.
cm.contentDOM.blur();
cm.dispatch({ selection: { anchor: from, head: to } });
if (operation === "cut") await fireEvent.cut(cm.contentDOM, { clipboardData: { setData: vi.fn() } });
else await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
}
expect(oncancelupload).toHaveBeenCalledExactlyOnceWith(reservation);
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "unknown" || segment.kind === "uploaded_file")).toBe(false);
undo(cm);
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "unknown" || segment.kind === "uploaded_file")).toBe(false);
redo(cm);
ui.component.clear();
expect(oncancelupload).toHaveBeenCalledOnce();
});
test.each(["clear", "restore", "unmount"])("pending upload %s boundary cancels once without acquiring late staged resources", (boundary) => {
const oncancelupload = vi.fn();
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: `upload-boundary-${boundary}`, oncancelupload, onremoveatom });
const reservation = ui.component.reserveUpload("report.md")!;
const complete = ui.component.completeUpload;
if (boundary === "clear") ui.component.clear();
else if (boundary === "restore") ui.component.restoreSegments([invocationSegment]);
else ui.unmount();
expect(oncancelupload).toHaveBeenCalledExactlyOnceWith(reservation);
expect(complete(reservation, uploadedSegment)).toBe(false);
expect(onremoveatom).not.toHaveBeenCalled();
});
test("cancelled upload cannot become sendable via late completion or Undo", async () => {
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: "upload-cancel-late", onremoveatom });
const cm = editor(ui.container);
type(cm, "before after", 7);
const reservation = ui.component.reserveUpload("report.md")!;
ui.component.cancelUpload(reservation);
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
undo(cm);
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "uploaded_file" || segment.kind === "unknown")).toBe(false);
ui.component.clear();
expect(onremoveatom).not.toHaveBeenCalled();
});
const invocationDescriptor: FeatureInvocationDescriptor = {
identity: "feature:test/run", name: "run", aliases: [], display_name: "Run",
description: "Declared invocation", syntax: "parenthesized",
arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" }, completion: { kind: "worker_file" } }],
};
const invocationSegment: Segment = {
kind: "feature_invoke", invocation: { invocation_id: "invoke-1",
identity: invocationDescriptor.identity, name: "run",
arguments: [{ name: "path", value: { kind: "string", value: "a b/資料" } }] },
};
const uploadedSegment: Segment = { kind: "uploaded_file", file: {
artifact_id: "artifact-1", file_name: "report.md", media_type: "text/markdown",
created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd",
} };
test("argument popup stays anchored to the edited outer call when its data contains a call-looking slash", async () => {
const descriptor: FeatureInvocationDescriptor = { ...invocationDescriptor, arguments: [...invocationDescriptor.arguments,
{ name: "mode", required: false, value_type: { kind: "enum", values: ["safe"] }, completion: { kind: "static", values: ["safe"] } }] };
const resolveFeatureArgumentCompletions = vi.fn(async () => [{ value: "completed" }]);
const ui = render(ComposerInput, { historyScope: "quoted-data-argument", resolveFeatureArgumentCompletions,
resolveFeatureCompletions: async () => [{ value: "run", invocation: descriptor }] });
const segment: Segment = { kind: "feature_invoke", invocation: { ...invocationSegment.invocation, arguments: [
{ name: "path", value: { kind: "string", value: "a /run(pa" } },
{ name: "mode", value: { kind: "string", value: "safe" } },
] } };
ui.component.restoreSegments([segment]);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
const cm = editor(ui.container);
await waitFor(() => expect(cm.state.doc.toString()).toBe('/run(path="a /run(pa", mode="safe")'));
cm.dispatch({ selection: { anchor: cm.state.doc.toString().lastIndexOf("(pa") + 3 } });
await waitFor(() => expect(resolveFeatureArgumentCompletions).toHaveBeenLastCalledWith(
{ invocation: descriptor.identity, argument: "path" }, "a /run(pa", expect.any(AbortSignal),
));
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(cm.state.doc.toString()).toBe('/run(path="completed", mode="safe")');
});
test("mid-cursor completion of an incomplete quote preserves following arguments and prose", async () => {
const descriptor: FeatureInvocationDescriptor = { ...invocationDescriptor, arguments: [...invocationDescriptor.arguments,
{ name: "mode", required: false, value_type: { kind: "enum", values: ["safe"] }, completion: { kind: "static", values: ["safe"] } }] };
const ui = render(ComposerInput, { historyScope: "incomplete-quote-suffix",
resolveFeatureCompletions: async () => [{ value: "run", invocation: descriptor }],
resolveFeatureArgumentCompletions: async () => [{ value: "completed" }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
const value = '/run(path="ab, mode=safe) following prose';
cm.dispatch({ changes: { from: cm.state.doc.length, insert: value.slice(5) }, selection: { anchor: value.indexOf("ab") + 2 } });
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(cm.state.doc.toString()).toBe('/run(path="completed", mode=safe) following prose');
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0]).toMatchObject({ kind: "feature_invoke", invocation: { arguments: [
{ name: "path", value: { kind: "string", value: "completed" } },
{ name: "mode", value: { kind: "string", value: "safe" } },
] } });
expect(snapshot.segments[1]).toEqual({ kind: "text", content: " following prose" });
});
test("selected malformed surrogate strings stay editable and reject Submit snapshot", async () => {
const ui = render(ComposerInput, { historyScope: "invalid-surrogate-submit",
resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
const invalid = String.raw`"\uD800")`;
cm.dispatch({ changes: { from: cm.state.doc.length, insert: invalid }, selection: { anchor: 5 + invalid.length } });
await Promise.resolve();
expect(ui.container.querySelector(".composer-typed-chip")).toBeNull();
expect(() => ui.component.snapshot()).toThrow("unpaired Unicode surrogates");
const valid = String.raw`"\uD83D\uDE00")`;
cm.dispatch({ changes: { from: 5, to: cm.state.doc.length, insert: valid }, selection: { anchor: 5 + valid.length } });
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0]).toMatchObject({ kind: "feature_invoke", invocation: { arguments: [
{ name: "path", value: { kind: "string", value: "😀" } },
] } });
});
test("selected calls chipify without promoting matching typed or pasted text", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-select",
resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a b/資料")' }, selection: { anchor: '/run("a b/資料")'.length } });
await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1));
cm.dispatch({ changes: { from: cm.state.doc.length, insert: ' /run("literal")' } });
await Promise.resolve();
const snapshot = ui.component.snapshot();
expect(snapshot.segments.map((segment) => segment.kind)).toEqual(["feature_invoke", "text"]);
expect(snapshot.segments[1]).toEqual({ kind: "text", content: ' /run("literal")' });
expect(snapshot.segments[0].kind === "feature_invoke" && snapshot.segments[0].invocation.arguments[0].value)
.toEqual({ kind: "string", value: "a b/資料" });
});
test("restored invocation chips fetch declarative descriptors and stay editable inside arguments", async () => {
const resolveFeatureCompletions = vi.fn(async () => [{ value: "run", invocation: invocationDescriptor }]);
const ui = render(ComposerInput, { historyScope: "invocation-edit", resolveFeatureCompletions });
ui.component.restoreSegments([invocationSegment]);
const cm = editor(ui.container);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(cm.state.doc.toString()).toBe('/run(path="a b/資料")'));
expect(resolveFeatureCompletions).toHaveBeenCalledWith("run", expect.any(AbortSignal));
cm.dispatch({ changes: { from: 11, to: 12, insert: "c" }, selection: { anchor: 12 } });
await Promise.resolve();
expect(ui.container.querySelector(".composer-typed-chip")).toBeNull();
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0].kind).toBe("feature_invoke");
});
// Fixture of the Workspace config Feature declaration, consumed by the same
// generic composer as other Features. Backend/WIP acceptance is tested in Rust;
// this test proves the real input component's selection and typed-submit edge.
const workspaceConfigDescriptor: FeatureInvocationDescriptor = {
identity: "builtin:workspace-config/attach", name: "workspace-config", aliases: [],
display_name: "Workspace config", description: "Select the logical Workspace config attachment",
syntax: "parenthesized", arguments: [{ name: "access", position: 0, required: false,
value_type: { kind: "enum", values: ["effective", "read_only", "read_write"] },
completion: { kind: "none" } }],
};
test("workspace-config completion prepares a typed invocation without consuming the request prose", async () => {
const onsubmit = vi.fn();
const resolveFeatureCompletions = vi.fn(async () => [
{ value: workspaceConfigDescriptor.name, invocation: workspaceConfigDescriptor },
]);
const ui = render(ComposerInput, { historyScope: "workspace-config-selected", onsubmit,
resolveFeatureCompletions });
const cm = editor(ui.container);
type(cm, "/workspace-con");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(onsubmit).not.toHaveBeenCalled();
const suffix = ') このWorkspaceのモデル設定を変更して';
cm.dispatch({ changes: { from: cm.state.doc.length, insert: suffix },
selection: { anchor: cm.state.doc.length + suffix.length } });
await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1));
const snapshot = ui.component.snapshot();
expect(snapshot.segments).toEqual([
{ kind: "feature_invoke", invocation: {
invocation_id: expect.any(String), identity: workspaceConfigDescriptor.identity,
name: workspaceConfigDescriptor.name, arguments: [],
} },
{ kind: "text", content: " このWorkspaceのモデル設定を変更して" },
]);
await fireEvent.keyDown(cm.contentDOM, { key: "Enter", ctrlKey: true });
expect(onsubmit).toHaveBeenCalledOnce();
// Sending is not string scanning: identity and arguments survive intact.
expect(ui.component.snapshot().segments).toEqual(snapshot.segments);
});
test("workspace-config text alone is not invoked and a selected intent is removable", async () => {
const ui = render(ComposerInput, { historyScope: "workspace-config-literal",
resolveFeatureCompletions: async () => [{ value: workspaceConfigDescriptor.name, invocation: workspaceConfigDescriptor }] });
const cm = editor(ui.container);
const prose = "/workspace-config このWorkspaceのモデル設定を変更して";
ui.component.restoreSegments([{ kind: "text", content: prose }]);
expect(ui.component.snapshot().segments).toEqual([{ kind: "text", content: prose }]);
ui.component.clear();
type(cm, "/workspace-con");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"read_only")' },
selection: { anchor: cm.state.doc.length + '"read_only")'.length } });
await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1));
expect(ui.component.snapshot().segments[0]).toMatchObject({ kind: "feature_invoke", invocation: {
arguments: [{ name: "access", value: { kind: "string", value: "read_only" } }],
} });
// happy-dom's focused DOM selection can reenter CodeMirror during range
// replacement. Use the existing component suite's unfocused range fixture.
cm.contentDOM.blur();
cm.dispatch({ selection: { anchor: 0, head: cm.state.doc.length } });
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(ui.component.snapshot().segments).toEqual([]);
});
test("attachment adapter uses descriptor capability rather than a hardcoded slash name", async () => {
const onclientadapter = vi.fn();
const adapter = { ...invocationDescriptor, name: "send-file", arguments: [], client_adapter: "attachment" as const };
const ui = render(ComposerInput, { historyScope: "invocation-adapter", onclientadapter,
resolveFeatureCompletions: async () => [{ value: adapter.name, invocation: adapter }] });
const cm = editor(ui.container);
type(cm, "before /send");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(onclientadapter).toHaveBeenCalledWith(adapter);
expect(cm.state.doc.toString()).toBe("before ");
});
test("uploads preserve reserved order, deletion undo, cleanup and accepted ownership", async () => {
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: "invocation-upload", onremoveatom });
const cm = editor(ui.container);
type(cm, "before ");
const first = ui.component.reserveUpload("report.md")!;
const second = ui.component.reserveUpload("second.md")!;
cm.dispatch({ changes: { from: cm.state.doc.length, insert: " after" } });
const secondSegment = { ...uploadedSegment, file: { ...uploadedSegment.file, artifact_id: "artifact-2", file_name: "second.md" } };
expect(ui.component.completeUpload(second, secondSegment)).toBe(true);
expect(ui.component.completeUpload(first, uploadedSegment)).toBe(true);
expect(ui.component.snapshot().segments).toEqual([
{ kind: "text", content: "before " }, uploadedSegment, secondSegment, { kind: "text", content: " after" },
]);
const tokenEnd = "before ".length + 3;
cm.dispatch({ selection: { anchor: tokenEnd } });
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(onremoveatom).not.toHaveBeenCalled();
undo(cm);
expect(ui.component.snapshot().segments).toContainEqual(uploadedSegment);
ui.component.clear(true);
expect(onremoveatom).not.toHaveBeenCalled();
expect(undo(cm)).toBe(false);
ui.component.insertSegment(uploadedSegment, true);
ui.component.restoreSegments([invocationSegment]);
expect(onremoveatom).toHaveBeenCalledWith(uploadedSegment);
});
test("restored opaque segments and typed clipboard labels are not lost", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-restore" });
const segments: Segment[] = [invocationSegment, uploadedSegment, { kind: "file_ref", path: "a b/c" }, { kind: "flow", selector: "legacy" }];
ui.component.restoreSegments(segments);
expect(ui.component.snapshot().segments).toEqual(segments);
const cm = editor(ui.container);
cm.dispatch({ selection: { anchor: 0, head: cm.state.doc.length } });
const setData = vi.fn();
await fireEvent.copy(cm.contentDOM, { clipboardData: { setData } });
expect(setData).toHaveBeenCalledWith("text/plain", expect.stringContaining('/run(path="a b/資料")'));
expect(setData.mock.calls[0][1]).not.toContain("\uFFF8");
});
test("restored descriptor requests cannot edit a newer draft after a scope change", async () => {
const request = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>();
const resolver = vi.fn(() => request.promise);
const ui = render(ComposerInput, { historyScope: "invocation-stale-edit", completionScope: "worker-a", resolveFeatureCompletions: resolver });
ui.component.restoreSegments([invocationSegment]);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(resolver).toHaveBeenCalledOnce());
await ui.rerender({ completionScope: "worker-b" });
ui.component.restoreSegments([{ kind: "text", content: "new draft" }]);
request.resolve([{ value: "run", invocation: invocationDescriptor }]);
await Promise.resolve();
await Promise.resolve();
expect(ui.component.snapshot().segments).toEqual([{ kind: "text", content: "new draft" }]);
});
test("pasted invocation text remains literal after descriptor selection", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-paste-literal", resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"x")' }, selection: { anchor: '/run("x")'.length } });
await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1));
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => ' /run("pasted")', items: [] } });
expect(ui.component.snapshot().segments.map((segment) => segment.kind)).toEqual(["feature_invoke", "text"]);
});
@@ -6,6 +6,7 @@
Prec,
StateEffect,
StateField,
type Extension,
} from "@codemirror/state";
import {
Decoration,
@@ -22,7 +23,11 @@
isolateHistory,
} from "@codemirror/commands";
import { onMount } from "svelte";
import type { Segment } from "#lib/generated/protocol.ts";
import type {
CompletionContext,
FeatureInvocationDescriptor,
Segment,
} from "#lib/generated/protocol.ts";
import {
handleComposerImagePaste,
measureComposerPaste,
@@ -32,11 +37,14 @@
composerDeletionRange,
composerPasteAtoms,
composerPasteToken,
composerTypedAtoms,
composerTypedToken,
pasteChipLabel,
snapshotComposerDraft,
type ComposerDraftSnapshot,
type ComposerPaste,
type ComposerTextPaste,
type ComposerTypedSegment,
} from "#lib/workspace/console/composer-draft.ts";
import {
ComposerHistory,
@@ -48,6 +56,10 @@
} from "#lib/workspace/console/composer-history.ts";
import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion";
import { shouldSubmitChatKey } from "#lib/workspace/console/chat-submit.ts";
import {
invocationInput,
selectedFeatureInvocationAt,
} from "#lib/workspace/console/composer-invocation.ts";
interface Props {
disabled?: boolean;
@@ -61,6 +73,15 @@
oncommand?: () => void;
completionScope?: string;
resolveFileCompletions?: (prefix: string, signal: AbortSignal) => Promise<ComposerCompletionEntry[]>;
resolveFeatureCompletions?: (prefix: string, signal: AbortSignal) => Promise<ComposerCompletionEntry[]>;
resolveFeatureArgumentCompletions?: (
context: CompletionContext,
prefix: string,
signal: AbortSignal,
) => Promise<ComposerCompletionEntry[]>;
onremoveatom?: (segment: Segment) => void;
oncancelupload?: (reservation: number) => void;
onclientadapter?: (descriptor: FeatureInvocationDescriptor) => void;
}
let {
@@ -75,14 +96,47 @@
oncommand,
completionScope = "",
resolveFileCompletions,
resolveFeatureCompletions,
resolveFeatureArgumentCompletions,
onremoveatom,
oncancelupload,
onclientadapter,
}: Props = $props();
let mountElement: HTMLDivElement;
let view: EditorView | null = null;
let editorExtensions: Extension[] = [];
const stagedTypedAtoms = new Map<number, Segment>();
const pendingUploads = new Set<number>();
let composerHistory = new ComposerHistory();
let restoringHistory = false;
let nextPasteId = 1;
let nextPasteKey = 1;
let nextTypedKey = 1;
const selectedFeatureInvocations = new Map<string, FeatureInvocationDescriptor>();
type SelectedInvocation = { from: number; descriptor: FeatureInvocationDescriptor };
const selectInvocation = StateEffect.define<SelectedInvocation>();
const restoreSelectedInvocations = StateEffect.define<readonly SelectedInvocation[]>();
const selectedInvocationState = StateField.define<readonly SelectedInvocation[]>({
create: () => [],
update(selected, transaction) {
const restored = transaction.effects.find((effect) => effect.is(restoreSelectedInvocations));
if (restored) return restored.value;
const document = transaction.newDoc.toString();
const retained = selected.map((item) => ({
...item,
from: transaction.changes.mapPos(item.from, 1),
})).filter((item) =>
[item.descriptor.name, ...item.descriptor.aliases].some((name) =>
document.startsWith(`/${name}(`, item.from)
)
);
for (const effect of transaction.effects) {
if (effect.is(selectInvocation)) retained.push(effect.value);
}
return retained;
},
});
const editable = new Compartment();
const completionId = $props.id();
@@ -117,7 +171,12 @@
completionContext = context;
closeCompletion();
if (disabled || !view.hasFocus || view.composing || !selection.empty) return;
const token = completionTokenAt(document, selection.head);
const token = completionTokenAt(
document,
selection.head,
[...selectedFeatureInvocations.values()],
view.state.field(selectedInvocationState),
);
if (!token) return;
completionToken = token;
if (token.kind === "command") {
@@ -125,16 +184,26 @@
if (!completionEntries.length) completionMessage = "No matching commands";
return;
}
if (!resolveFileCompletions) { closeCompletion(); return; }
const resolver = token.kind === "file"
? resolveFileCompletions && ((signal: AbortSignal) =>
resolveFileCompletions(token.prefix, signal))
: token.kind === "feature"
? resolveFeatureCompletions && ((signal: AbortSignal) =>
resolveFeatureCompletions(token.prefix, signal))
: token.context && resolveFeatureArgumentCompletions
? ((signal: AbortSignal) =>
resolveFeatureArgumentCompletions(token.context!, token.prefix, signal))
: null;
if (!resolver) { closeCompletion(); return; }
completionBusy = true;
const version = completionVersion;
const abort = new AbortController();
completionAbort = abort;
void resolveFileCompletions(token.prefix, abort.signal).then((entries) => {
void resolver(abort.signal).then((entries) => {
if (version !== completionVersion || abort.signal.aborted) return;
completionEntries = entries;
completionSelected = entries.length ? 0 : null;
if (!entries.length) completionMessage = "No matching files";
if (!entries.length) completionMessage = `No matching ${token.kind.replace("_", " ")}s`;
}).catch((error) => {
if (version !== completionVersion || abort.signal.aborted) return;
completionMessage = error instanceof Error ? error.message : String(error);
@@ -158,11 +227,27 @@
}
const entry = index === null ? undefined : completionEntries[index];
if (!entry) return;
if (token.kind === "feature" && entry.invocation) {
selectedFeatureInvocations.set(entry.invocation.identity, entry.invocation);
if (entry.invocation.client_adapter === "attachment") {
closeCompletion();
view.dispatch({
changes: { from: token.start, to: token.end, insert: "" },
selection: EditorSelection.cursor(token.start),
userEvent: "input.complete",
});
onclientadapter?.(entry.invocation);
return;
}
}
const result = applyCompletion(document, token, entry, action);
const to = document.length - (result.value.length - result.cursor);
closeCompletion();
view.dispatch({
changes: { from: token.start, to, insert: result.value.slice(token.start, result.cursor) },
effects: token.kind === "feature" && entry.invocation
? selectInvocation.of({ from: token.start, descriptor: entry.invocation })
: [],
selection: EditorSelection.cursor(result.cursor), userEvent: "input.complete",
});
view.focus();
@@ -209,6 +294,12 @@
$effect(() => {
void completionScope;
selectedFeatureInvocations.clear();
closeCompletion();
completionContext = "";
});
$effect(() => {
void disabled;
closeCompletion();
completionContext = "";
@@ -315,6 +406,189 @@
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
];
const registerTypedAtom = StateEffect.define<{
key: number;
atom: ComposerTypedSegment;
}>();
const typedRegistry = StateField.define<ReadonlyMap<number, ComposerTypedSegment>>({
create: () => new Map(),
update(registry, transaction) {
const additions = transaction.effects.filter((effect) => effect.is(registerTypedAtom));
if (!additions.length && !transaction.docChanged) return registry;
const next = new Map(registry);
for (const addition of additions) next.set(addition.value.key, addition.value.atom);
if (transaction.docChanged) {
const live = new Set(composerTypedAtoms(transaction.newDoc.toString(), next).map((atom) => atom.key));
for (const atom of composerTypedAtoms(transaction.startState.doc.toString(), registry)) {
if (atom.uploadReservation && !live.has(atom.key)) {
// Cancellation is part of the editor transaction, not a later callback.
// Undo restores text only; it cannot regain this upload reservation.
next.set(atom.key, { segment: { kind: "text", content: "" }, label: "Removed upload" });
}
}
}
return next;
},
});
class TypedChipWidget extends WidgetType {
readonly atom: ComposerTypedSegment;
constructor(atom: ComposerTypedSegment) {
super();
this.atom = atom;
}
override eq(other: TypedChipWidget): boolean {
return JSON.stringify(other.atom.segment) === JSON.stringify(this.atom.segment) &&
other.atom.label === this.atom.label;
}
override toDOM(): HTMLElement {
const chip = document.createElement("span");
chip.className = "composer-typed-chip";
chip.textContent = this.atom.label;
chip.title = this.atom.label;
chip.setAttribute("role", "note");
chip.setAttribute("aria-label", this.atom.label);
return chip;
}
override ignoreEvent(): boolean { return false; }
}
function typedDecorations(state: EditorState): DecorationSet {
return Decoration.set(
composerTypedAtoms(state.doc.toString(), state.field(typedRegistry)).map((atom) =>
Decoration.replace({
widget: new TypedChipWidget(atom),
inclusive: false,
}).range(atom.from, atom.to)
),
true,
);
}
const typedChips = [
typedRegistry,
EditorView.decorations.of((currentView) => typedDecorations(currentView.state)),
EditorView.atomicRanges.of((currentView) => typedDecorations(currentView.state)),
];
function releasePendingUploads(): void {
const cancelled = [...pendingUploads];
pendingUploads.clear();
for (const key of cancelled) oncancelupload?.(key);
}
// Completed uploads remain staged while Undo can still restore their atoms.
// Pending reservations are cancelled immediately, not retained as Undo leases.
// Release abandoned resources at a draft boundary; accepted live atoms belong to the host.
function releaseStagedAtoms(accepted = false): void {
const live = accepted && view ? new Set(composerTypedAtoms(
view.state.doc.toString(), view.state.field(typedRegistry),
).map((atom) => JSON.stringify(atom.segment))) : new Set<string>();
for (const segment of stagedTypedAtoms.values()) {
if (!live.has(JSON.stringify(segment))) onremoveatom?.(segment);
}
stagedTypedAtoms.clear();
}
let suppressInvocationChipify = false;
function typedAtomLabel(segment: Segment): string {
if (segment.kind === "feature_invoke") return invocationInput(segment.invocation);
if (segment.kind === "uploaded_file") return `Attached ${segment.file.file_name}`;
if (segment.kind === "file_ref") return `@${segment.path}`;
if (segment.kind === "paste_artifact") return "Restored paste";
if (segment.kind === "flow") return `Flow ${segment.selector}`;
return "Structured input";
}
function insertTypedSegment(
segment: Segment,
options: { from?: number; to?: number; cleanup?: boolean; label?: string; uploadReservation?: boolean } = {},
): number | null {
if (!view || view.state.readOnly) return null;
const selection = view.state.selection.main;
const from = options.from ?? selection.from;
const to = options.to ?? selection.to;
const key = nextTypedKey++;
if (options.cleanup) stagedTypedAtoms.set(key, segment);
if (options.uploadReservation) pendingUploads.add(key);
const token = composerTypedToken(key);
view.dispatch({
changes: { from, to, insert: token },
selection: EditorSelection.cursor(from + token.length),
effects: registerTypedAtom.of({
key,
atom: { segment, label: options.label ?? typedAtomLabel(segment), cleanup: options.cleanup, uploadReservation: options.uploadReservation },
}),
annotations: isolateHistory.of("full"),
userEvent: "input.complete",
});
return key;
}
function chipifySelectedInvocation(strict = false): void {
if (!view || view.composing || view.state.readOnly) return;
// Only completion-selected or explicitly edited calls become structured input.
// Descriptor discovery must never promote unrelated typed or pasted prose.
for (const selected of [...view.state.field(selectedInvocationState)].reverse()) {
try {
const range = selectedFeatureInvocationAt(
view.state.doc.toString(), selected.from, selected.descriptor,
);
if (!range) {
if (strict) throw new Error("Finish or remove the selected Feature invocation before sending.");
continue;
}
const head = view.state.selection.main.head;
if (!strict && head > range.start && head < range.end) continue;
insertTypedSegment(
{ kind: "feature_invoke", invocation: range.invocation },
{ from: range.start, to: range.end },
);
} catch (error) {
// Invalid calls stay editable until Submit reports their validation error.
if (strict) throw error;
}
}
}
let editVersion = 0;
let editorGeneration = 0;
async function editInvocation(key: number): Promise<void> {
if (!view || disabled) return;
const scope = completionScope;
const version = ++editVersion;
const document = view.state.doc.toString();
const selection = view.state.selection.main;
const generation = editorGeneration;
let atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
if (atom?.segment.kind !== "feature_invoke") return;
const invocation = atom.segment.invocation;
let descriptor = selectedFeatureInvocations.get(invocation.identity);
if (!descriptor && resolveFeatureCompletions) {
try {
const entries = await resolveFeatureCompletions(invocation.name, new AbortController().signal);
descriptor = entries.find((entry) => entry.invocation?.identity === invocation.identity)?.invocation ?? undefined;
} catch { return; }
}
if (!descriptor || !view || disabled || version !== editVersion || scope !== completionScope ||
generation !== editorGeneration || document !== view.state.doc.toString() ||
selection.from !== view.state.selection.main.from || selection.to !== view.state.selection.main.to) return;
atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
if (atom?.segment.kind !== "feature_invoke") return;
selectedFeatureInvocations.set(descriptor.identity, descriptor);
const input = invocationInput(invocation);
suppressInvocationChipify = true;
view.dispatch({
changes: { from: atom.from, to: atom.to, insert: input },
selection: EditorSelection.cursor(atom.from + input.length - 1),
effects: selectInvocation.of({ from: atom.from, descriptor }),
annotations: isolateHistory.of("full"),
userEvent: "input",
});
view.focus();
}
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
if (!state) {
return { document: "", content: "", segments: [], pastes: [], textPastes: [] };
@@ -323,6 +597,7 @@
state.doc.toString(),
state.field(pasteRegistry),
state.field(textPasteState),
state.field(typedRegistry),
);
}
@@ -339,6 +614,7 @@
}
function browseHistory(currentView: EditorView, direction: ComposerHistoryDirection): boolean {
if ([...currentView.state.field(typedRegistry).values()].some((atom) => atom.segment.kind === "unknown")) return false;
const selection = currentView.state.selection.main;
const cursorLine = currentView.state.doc.lineAt(selection.head).number;
if (!shouldBrowseComposerHistory({
@@ -435,20 +711,26 @@
from: paste.from - selection.from,
to: paste.to - selection.from,
}));
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes).content;
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes, state.field(typedRegistry)).content;
}
function deleteAdjacentPasteFromView(
function deleteAdjacentAtomFromView(
currentView: EditorView,
direction: "backward" | "forward",
): boolean {
if (currentView.state.readOnly) return false;
const selection = currentView.state.selection.main;
const pastes = composerPasteAtoms(
currentView.state.doc.toString(),
currentView.state.field(pasteRegistry),
);
const deletion = composerDeletionRange(selection, pastes, direction);
const atoms = [
...composerPasteAtoms(
currentView.state.doc.toString(),
currentView.state.field(pasteRegistry),
),
...composerTypedAtoms(
currentView.state.doc.toString(),
currentView.state.field(typedRegistry),
),
];
const deletion = composerDeletionRange(selection, atoms, direction);
if (!deletion) return false;
currentView.dispatch({
changes: deletion,
@@ -467,7 +749,7 @@
view = new EditorView({
parent: mountElement,
state: EditorState.create({
extensions: [
extensions: editorExtensions = [
history(),
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
Prec.highest(keymap.of([
@@ -494,16 +776,21 @@
{
key: "Backspace",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "backward"),
deleteAdjacentAtomFromView(currentView, "backward"),
},
{
key: "Delete",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "forward"),
deleteAdjacentAtomFromView(currentView, "forward"),
},
])),
keymap.of([...defaultKeymap, ...historyKeymap]),
selectedInvocationState,
invertedEffects.of((transaction) => transaction.docChanged
? [restoreSelectedInvocations.of(transaction.startState.field(selectedInvocationState))]
: []),
pasteChips,
typedChips,
editable.of([
EditorView.editable.of(!disabled),
EditorState.readOnly.of(disabled),
@@ -518,7 +805,28 @@
spellcheck: "true",
}),
EditorView.updateListener.of((update) => {
if (update.docChanged || update.selectionSet) queueMicrotask(() => refreshCompletion());
// StateField tombstones deleted reservations before notifying the
// adapter. The callback must not dispatch a nested editor update.
for (const key of pendingUploads) {
if (!update.state.field(typedRegistry).get(key)?.uploadReservation) {
pendingUploads.delete(key);
oncancelupload?.(key);
}
}
if (update.docChanged || update.selectionSet) {
editorGeneration++;
// Invalidate synchronously: an A→B→A edit/cursor cycle in one
// microtask must not revive an earlier completion generation.
closeCompletion();
completionContext = "";
queueMicrotask(() => refreshCompletion());
}
if (update.docChanged) {
if (suppressInvocationChipify) suppressInvocationChipify = false;
else if (!update.transactions.some((transaction) => transaction.isUserEvent("undo") || transaction.isUserEvent("redo"))) {
queueMicrotask(chipifySelectedInvocation);
}
}
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
emitChange();
@@ -528,6 +836,26 @@
focus() { queueMicrotask(() => refreshCompletion(true)); return false; },
blur() { closeCompletion(); return false; },
compositionend() { queueMicrotask(() => refreshCompletion(true)); return false; },
mousedown(event, currentView) {
const target = event.target instanceof Element
? event.target.closest(".composer-typed-chip")
: null;
if (!target || currentView.state.readOnly) return false;
let position: number;
try {
position = currentView.posAtDOM(target);
} catch {
return false;
}
const atom = composerTypedAtoms(
currentView.state.doc.toString(),
currentView.state.field(typedRegistry),
).find((candidate) => candidate.from <= position && position <= candidate.to);
if (atom?.segment.kind !== "feature_invoke") return false;
event.preventDefault();
void editInvocation(atom.key);
return true;
},
paste(event) {
return handlePasteEvent(event);
},
@@ -582,6 +910,9 @@
return () => {
closeCompletion();
releasePendingUploads();
releaseStagedAtoms();
editVersion++;
view?.destroy();
view = null;
};
@@ -598,12 +929,14 @@
});
export function snapshot(): ComposerDraftSnapshot {
chipifySelectedInvocation(true);
return currentSnapshot();
}
export function recordHistory(value: ComposerDraftSnapshot): void {
const entry: ComposerHistoryEntry = {
segments: value.segments,
// Uploaded references are Worker-owned and must not leak through workspace-wide history.
segments: value.segments.filter((segment) => segment.kind !== "uploaded_file"),
preserveExactText: value.textPastes.length > 0,
};
if (composerHistory.record(entry)) {
@@ -632,23 +965,65 @@
});
}
export function clear(): void {
export function insertSegment(segment: Segment, cleanup = false): boolean {
return insertTypedSegment(segment, { cleanup }) !== null;
}
export function reserveUpload(fileName: string): number | null {
return insertTypedSegment({ kind: "unknown" }, { label: `Uploading ${fileName}`, uploadReservation: true });
}
export function completeUpload(key: number, segment: Segment): boolean {
if (!view || !pendingUploads.has(key) ||
!view.state.field(typedRegistry).get(key)?.uploadReservation ||
!composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry)).some((atom) => atom.key === key)) return false;
pendingUploads.delete(key);
stagedTypedAtoms.set(key, segment);
view.dispatch({ effects: registerTypedAtom.of({
key, atom: { segment, label: typedAtomLabel(segment), cleanup: true },
}) });
return true;
}
export function cancelUpload(key: number): void {
if (!view) return;
const atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: "" },
selection: EditorSelection.cursor(0),
userEvent: "input",
changes: atom ? { from: atom.from, to: atom.to, insert: "" } : [],
// Undo cannot resurrect a cancelled upload as a sendable placeholder.
effects: registerTypedAtom.of({ key, atom: {
segment: { kind: "text", content: "" }, label: "Removed upload",
} }),
userEvent: "delete",
});
}
export function clear(acceptedTypedResources = false, retainStagedResources = false): void {
if (!view) return;
releasePendingUploads();
if (!retainStagedResources) releaseStagedAtoms(acceptedTypedResources);
editVersion++;
closeCompletion();
completionContext = "";
view.setState(EditorState.create({ extensions: editorExtensions }));
view.dispatch({ effects: editable.reconfigure([
EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled),
]) });
emitChange();
}
export function restoreSegments(
segments: readonly Segment[],
preserveExactText = false,
ownStagedUploads = false,
): void {
if (!view) return;
clear(false, restoringHistory);
let document = "";
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
const textEffects: StateEffect<ComposerTextPaste>[] = [];
const typedEffects: StateEffect<{ key: number; atom: ComposerTypedSegment }>[] = [];
let highestPasteId = nextPasteId - 1;
for (const segment of segments) {
if (segment.kind === "text") {
@@ -674,15 +1049,22 @@
highestPasteId = Math.max(highestPasteId, paste.id);
document += composerPasteToken(key);
pasteEffects.push(registerPaste.of({ key, paste }));
} else if (segment.kind === "file_ref") {
document += `@${segment.path}`;
} else {
const key = nextTypedKey++;
document += composerTypedToken(key);
const cleanup = ownStagedUploads && segment.kind === "uploaded_file";
if (cleanup) stagedTypedAtoms.set(key, segment);
typedEffects.push(registerTypedAtom.of({
key,
atom: { segment, label: typedAtomLabel(segment), cleanup },
}));
}
}
nextPasteId = highestPasteId + 1;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
selection: EditorSelection.cursor(document.length),
effects: [...pasteEffects, ...textEffects],
effects: [...pasteEffects, ...textEffects, ...typedEffects],
userEvent: "input.restore",
});
}
@@ -788,7 +1170,8 @@
padding: 0;
}
.composer-input :global(.composer-paste-chip) {
.composer-input :global(.composer-paste-chip),
.composer-input :global(.composer-typed-chip) {
display: inline-flex;
align-items: center;
max-width: min(26rem, 70vw);
@@ -1,3 +1,4 @@
import { invocationInput } from "./composer-invocation.ts";
import type { Segment } from "#lib/generated/protocol.ts";
export type WorkerConsoleInputKind =
@@ -88,9 +89,10 @@ export function buildComposerSegmentsRequest(
sourceSegments: readonly Segment[],
options: ComposerSegmentsRequestOptions = {},
): ComposerCommandResult {
const hasRichSegment = sourceSegments.some((segment) =>
segment.kind === "paste" || segment.kind === "uploaded_file"
);
if (sourceSegments.some((segment) => segment.kind === "unknown")) {
return { ok: false, message: "Finish or remove incomplete structured input before sending." };
}
const hasRichSegment = sourceSegments.some((segment) => segment.kind !== "text");
if (!hasRichSegment) {
const content = sourceSegments.map(segmentContent).join("");
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
@@ -154,8 +156,10 @@ function segmentContent(segment: Segment): string {
return `@${segment.path}`;
case "flow":
return segment.selector;
case "feature_invoke":
return invocationInput(segment.invocation);
case "paste_artifact":
return "";
return "[Restored paste]";
case "uploaded_file":
return `[Attached file: ${segment.file.file_name}]`;
default:
@@ -23,7 +23,7 @@ function assertEquals<T>(actual: T, expected: T): void {
}
}
Deno.test("completionTokenAt detects command and file sigils before the cursor", () => {
Deno.test("completionTokenAt detects command, file, and Feature sigils before the cursor", () => {
assertEquals(completionTokenAt("open @src/ma", "open @src/ma".length), {
sigil: "@",
kind: "file",
@@ -32,7 +32,13 @@ Deno.test("completionTokenAt detects command and file sigils before the cursor",
prefix: "src/ma",
});
assertEquals(completionTokenAt(":comp", 5)?.kind, "command");
assertEquals(completionTokenAt("run /work", 9), null);
assertEquals(completionTokenAt("run /work", 9), {
sigil: "/",
kind: "feature",
start: 4,
end: 9,
prefix: "work",
});
assertEquals(completionTokenAt("ask #plain", "ask #plain".length), null);
});
@@ -92,3 +98,59 @@ Deno.test("localCommandCompletions filters colon commands", () => {
"compact",
]);
});
Deno.test("argument completions replace whole quoted values and preserve following arguments", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
for (const value of ['/run("src/ma', '/run("src/ma", next=true)', '/run(path="src/ma", next=true)']) {
const cursor = value.indexOf("ma") + 1;
const token = completionTokenAt(value, cursor, [descriptor]);
assert(token, "argument token should exist");
assertEquals(token.prefix, "src/m");
assertEquals(applyCompletion(value, token, { value: '資料/a "b"/c' }).value,
value.startsWith('/run(path') ? '/run(path="資料/a \\"b\\"/c", next=true)' :
value.endsWith('true)') ? '/run("資料/a \\"b\\"/c", next=true)' : '/run("資料/a \\"b\\"/c"a');
}
});
Deno.test("incomplete or invalid quoted completion preserves mode and body after the cursor", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
for (const value of ['/run(path="ab, mode=safe) following prose', '/run("ab, mode=safe) following prose', String.raw`/run(path="ab\q", mode=safe) following prose`]) {
const cursor = value.indexOf("ab") + 2;
const token = completionTokenAt(value, cursor, [descriptor], [{ from: 0, descriptor }]);
assert(token, "selected argument completion should exist");
assertEquals(token.end, cursor);
const result = applyCompletion(value, token, { value: "completed/資料" });
assertEquals(result.value.slice(result.cursor), value.slice(cursor));
assert(result.value.includes("mode=safe) following prose"), "other arguments and body must remain intact");
}
});
Deno.test("quoted call-looking data completes the selected outer value without erasing other arguments", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
const value = '/run("a /run(pa", mode=safe) prose';
const token = completionTokenAt(value, value.lastIndexOf("pa") + 2, [descriptor], [{ from: 0, descriptor }]);
assert(token, "outer argument completion should exist");
assertEquals(token.context?.argument, "path");
assertEquals(token.prefix, "a /run(pa");
assertEquals(applyCompletion(value, token, { value: "replacement" }).value, '/run("replacement", mode=safe) prose');
});
Deno.test("argument name replacement preserves its value and directories drill inside quotes", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
const named = '/run(path="original")';
const token = completionTokenAt(named, 7, [descriptor]);
assert(token, "named completion should exist");
assertEquals(applyCompletion(named, token, { value: "path=" }).value, named);
const directory = '/run("sr")';
const result = applyCompletion(directory, completionTokenAt(directory, 8, [descriptor])!, { value: "src", is_dir: true });
assertEquals(result.value, '/run("src/")');
assertEquals(completionTokenAt(result.value, result.cursor, [descriptor])?.prefix, "src/");
});
@@ -1,18 +1,25 @@
import { COMMANDS } from "./composer-command.ts";
import type {
CompletionContext,
FeatureInvocationDescriptor,
} from "#lib/generated/protocol.ts";
import { activeInvocationArgument, type SelectedInvocationOccurrence } from "./composer-invocation.ts";
export type ComposerCompletionKind = "command" | "file";
export type ComposerCompletionKind = "command" | "file" | "feature" | "feature_argument";
export type ComposerCompletionToken = {
sigil: ":" | "@";
sigil: ":" | "@" | "/" | "";
kind: ComposerCompletionKind;
start: number;
end: number;
prefix: string;
context?: CompletionContext;
};
export type ComposerCompletionEntry = {
value: string;
is_dir?: boolean;
description?: string;
usage?: string;
description?: string | null;
usage?: string | null;
invocation?: FeatureInvocationDescriptor | null;
};
export type CompletionApplyResult = { value: string; cursor: number };
const ALIASES: Record<string, string> = { "?": "help", rollback: "rewind" };
@@ -28,9 +35,40 @@ export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = Object
export function completionTokenAt(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[] = [],
selectedOccurrences?: readonly SelectedInvocationOccurrence[],
): ComposerCompletionToken | null {
const boundedCursor = Math.max(0, Math.min(cursor, value.length));
const activeArgument = activeInvocationArgument(value, boundedCursor, descriptors, selectedOccurrences);
if (activeArgument) {
return {
sigil: "",
kind: "feature_argument",
start: activeArgument.start,
end: activeArgument.end,
prefix: activeArgument.prefix,
context: {
invocation: activeArgument.descriptor.identity,
argument: activeArgument.argument?.name,
},
};
}
const before = value.slice(0, boundedCursor);
const feature = /(^|\s)\/([a-z0-9_-]*)$/.exec(before);
if (feature) {
const prefix = feature[2] ?? "";
const start = before.length - prefix.length - 1;
const tail = /^[a-z0-9_-]*/.exec(value.slice(boundedCursor))?.[0] ?? "";
return {
sigil: "/",
kind: "feature",
start,
end: boundedCursor + tail.length,
prefix,
};
}
const match = /(^|\s)([:@])([^\s\uFFF9\uFFFB]*)$/.exec(before);
if (!match) return null;
const sigil = match[2] as ":" | "@";
@@ -78,19 +116,46 @@ export function applyCompletion(
entry: ComposerCompletionEntry,
action: "tab" | "accept" = "accept",
): CompletionApplyResult {
if (token.kind === "feature") {
const replacement = `/${entry.value}(`;
return replaceCompletionRange(value, token, replacement);
}
if (token.kind === "feature_argument") {
const argumentName = !token.context?.argument && entry.value.endsWith("=");
const completed = entry.is_dir && !entry.value.endsWith("/") ? `${entry.value}/` : entry.value;
const replacement = argumentName ? entry.value : JSON.stringify(completed);
const result = replaceCompletionRange(value,
argumentName && value[token.end] === "=" ? { ...token, end: token.end + 1 } : token,
replacement,
);
// Keep directory drilling inside the quoted value instead of after its closing quote.
if (entry.is_dir && !argumentName) result.cursor--;
return result;
}
const suffix = entry.is_dir
? (entry.value.endsWith("/") ? "" : "/")
: token.kind === "file" && action === "tab"
? ""
: " ";
const replacement = `${token.sigil}${entry.value}${suffix}`;
const restStart = suffix === " " && value[token.end] === " "
return replaceCompletionRange(
value,
token,
`${token.sigil}${entry.value}${suffix}`,
suffix === " ",
);
}
function replaceCompletionRange(
value: string,
token: ComposerCompletionToken,
replacement: string,
consumeSpace = false,
): CompletionApplyResult {
const restStart = consumeSpace && value[token.end] === " "
? token.end + 1
: token.end;
return {
value: `${value.slice(0, token.start)}${replacement}${
value.slice(restStart)
}`,
value: `${value.slice(0, token.start)}${replacement}${value.slice(restStart)}`,
cursor: token.start + replacement.length,
};
}
@@ -2,8 +2,12 @@ declare const Deno: {
test(name: string, fn: () => void): void;
};
import { deepStrictEqual, throws } from "node:assert/strict";
import type { Segment } from "#lib/generated/protocol.ts";
import type { ComposerDraftSnapshot } from "./composer-draft.ts";
import {
canDeliverComposerDraft,
ComposerAdmissions,
sendComposerDelivery,
} from "./composer-delivery.ts";
@@ -13,6 +17,187 @@ function assertEquals(actual: unknown, expected: unknown): void {
}
}
const upload: Segment = {
kind: "uploaded_file",
file: {
artifact_id: "staged-upload",
file_name: "資料.md",
media_type: "text/markdown",
created_at_ms: 1,
availability: "available",
byte_len: 4,
sha256: "abcd",
},
};
const typedSnapshot: ComposerDraftSnapshot = {
document: "exact typed draft",
content: 'before /run(path="a b/資料") after',
pastes: [],
textPastes: [],
segments: [
{ kind: "text", content: "before " },
{
kind: "feature_invoke",
invocation: {
invocation_id: "stable-invocation",
identity: "feature:test/run",
name: "run",
arguments: [{
name: "path",
value: { kind: "string", value: "a b/資料" },
}],
},
},
upload,
{ kind: "text", content: " after" },
],
};
Deno.test("Submit admission keeps exact typed draft and stable IDs across rejection and retry", () => {
const admissions = new ComposerAdmissions();
const method = {
method: "submit" as const,
params: {
submission_request_id: "stable-request",
input: typedSnapshot.segments,
},
};
const first = admissions.begin("target-a", method, typedSnapshot, "submit");
deepStrictEqual(first.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), true);
assertEquals(
admissions.acknowledge("target-b", {
event: "submission_accepted",
data: {
submission_request_id: "stable-request",
submission_id: "s",
disposition: "started",
},
}),
null,
);
assertEquals(
admissions.acknowledge("target-a", {
event: "submission_rejected",
data: {
submission_request_id: "other-client",
message: "unrelated",
},
}),
null,
);
const rejection = admissions.acknowledge("target-a", {
event: "submission_rejected",
data: {
submission_request_id: "stable-request",
message: "validation failed",
},
});
assertEquals(rejection?.accepted, false);
deepStrictEqual(rejection?.record.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), false);
const retry = admissions.begin(
"target-a",
{
...method,
params: { ...method.params, submission_request_id: "do-not-use-new-id" },
},
typedSnapshot,
"submit",
);
deepStrictEqual(retry.method, first.method);
const accepted = admissions.acknowledge("target-a", {
event: "submission_accepted",
data: {
submission_request_id: "stable-request",
submission_id: "s",
disposition: "queued",
},
});
assertEquals(accepted?.accepted, true);
deepStrictEqual(accepted?.record.snapshot.segments, typedSnapshot.segments);
assertEquals(admissions.get("target-a"), null);
});
Deno.test("unknown admission protects uploads and in-flight retries from draft deletion and target ABA", () => {
const admissions = new ComposerAdmissions();
const method = {
method: "submit" as const,
params: {
submission_request_id: "unknown-request",
input: typedSnapshot.segments,
},
};
admissions.begin("target-a", method, typedSnapshot, "queue");
admissions.disconnected("target-a");
assertEquals(admissions.get("target-a")?.status, "unknown");
assertEquals(admissions.protects(upload), true);
throws(
() =>
admissions.begin(
"target-a",
method,
{ ...typedSnapshot, segments: [] },
"queue",
),
/in-flight/,
);
admissions.disconnected("target-b");
deepStrictEqual(admissions.retry("target-a")?.method, method);
deepStrictEqual(admissions.get("target-a")?.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), true);
});
Deno.test("Notify admission correlates authoritative rejection/success and retries unchanged IDs", () => {
const admissions = new ComposerAdmissions();
const snapshot = {
...typedSnapshot,
segments: [{ kind: "text" as const, content: "通知" }],
};
const method = {
method: "notify" as const,
params: { notification_request_id: "notification-1", message: "通知" },
};
admissions.begin("target", method, snapshot, "notify");
assertEquals(
admissions.acknowledge("target", {
event: "notification_rejected",
data: { notification_request_id: "other", message: "old" },
}),
null,
);
assertEquals(
admissions.acknowledge("target", {
event: "notification_rejected",
data: {
notification_request_id: "notification-1",
message: "not running",
},
})?.accepted,
false,
);
deepStrictEqual(
admissions.begin(
"target",
{
...method,
params: { ...method.params, notification_request_id: "new" },
},
snapshot,
"notify",
).method,
method,
);
assertEquals(
admissions.acknowledge("target", {
event: "notification_accepted",
data: { notification_request_id: "notification-1" },
})?.accepted,
true,
);
assertEquals(admissions.get("target"), null);
});
const base = {
protocolOpen: true,
sending: false,
@@ -1,3 +1,123 @@
import type { Event, Method, Segment } from "#lib/generated/protocol.ts";
import type { ComposerDraftSnapshot } from "./composer-draft.ts";
export type ComposerAdmissionMethod = Extract<
Method,
{ method: "submit" | "notify" }
>;
export type ComposerAdmission = {
method: ComposerAdmissionMethod;
snapshot: ComposerDraftSnapshot;
delivery: ComposerDelivery;
status: "waiting" | "unknown" | "rejected";
};
function admissionId(method: ComposerAdmissionMethod): string {
return method.method === "submit"
? method.params.submission_request_id
: method.params.notification_request_id;
}
/** Transport send is not admission. Keep exact typed input and idempotency IDs
* until an authoritative acknowledgement; uncertain sends retain upload leases.
*/
export class ComposerAdmissions {
private records = new Map<string, ComposerAdmission>();
get(target: string): ComposerAdmission | null {
return this.records.get(target) ?? null;
}
begin(
target: string,
method: ComposerAdmissionMethod,
snapshot: ComposerDraftSnapshot,
delivery: ComposerDelivery,
): ComposerAdmission {
const prior = this.get(target);
if (prior && prior.status !== "rejected") {
throw new Error(
"Resolve the in-flight admission before editing or sending a new draft.",
);
}
const unchanged = prior && prior.method.method === method.method &&
JSON.stringify(prior.snapshot.segments) ===
JSON.stringify(snapshot.segments) &&
(prior.snapshot.textPastes.length > 0) ===
(snapshot.textPastes.length > 0);
const record: ComposerAdmission = unchanged
? { ...prior, status: "waiting" }
: {
// Route-cached drafts may contain Svelte proxies. Snapshot the actual
// JSON wire values rather than trying to structured-clone those proxies.
method: JSON.parse(JSON.stringify(method)) as ComposerAdmissionMethod,
snapshot: JSON.parse(JSON.stringify(snapshot)) as ComposerDraftSnapshot,
delivery,
status: "waiting",
};
this.records.set(target, record);
return record;
}
retry(target: string): ComposerAdmission | null {
const prior = this.get(target);
if (!prior || prior.status === "rejected") return null;
const record: ComposerAdmission = { ...prior, status: "waiting" };
this.records.set(target, record);
return record;
}
disconnected(target: string): void {
const prior = this.get(target);
if (prior && prior.status !== "rejected") {
this.records.set(target, { ...prior, status: "unknown" });
}
}
protects(segment: Segment): boolean {
if (segment.kind !== "uploaded_file") return false;
return [...this.records.values()].some((record) =>
record.status !== "rejected" &&
record.snapshot.segments.some((candidate) =>
candidate.kind === "uploaded_file" &&
candidate.file.artifact_id === segment.file.artifact_id
)
);
}
acknowledge(
target: string,
event: Event,
): { record: ComposerAdmission; accepted: boolean; message?: string } | null {
const record = this.get(target);
if (!record) return null;
let id: string;
let accepted: boolean;
let message: string | undefined;
if (
(event.event === "submission_accepted" ||
event.event === "submission_rejected") &&
record.method.method === "submit"
) {
id = event.data.submission_request_id;
accepted = event.event === "submission_accepted";
if (event.event === "submission_rejected") message = event.data.message;
} else if (
(event.event === "notification_accepted" ||
event.event === "notification_rejected") &&
record.method.method === "notify"
) {
id = event.data.notification_request_id;
accepted = event.event === "notification_accepted";
if (event.event === "notification_rejected") message = event.data.message;
} else return null;
if (id !== admissionId(record.method)) return null;
if (accepted) this.records.delete(target);
else this.records.set(target, { ...record, status: "rejected" });
return { record, accepted, message };
}
}
export type ComposerDelivery = "submit" | "queue" | "notify";
export type ComposerDeliveryState = {
@@ -22,7 +142,8 @@ export function canDeliverComposerDraft(state: ComposerDeliveryState): boolean {
case "submit":
return state.workerState === "idle" && hasInput;
case "queue":
return (state.workerState === "running" || state.workerState === "paused") &&
return (state.workerState === "running" ||
state.workerState === "paused") &&
hasInput;
case "notify":
return state.workerState === "running" && state.hasText &&
@@ -3,6 +3,8 @@ import {
composerDeletionRange,
type ComposerPaste,
composerPasteToken,
composerTypedAtoms,
composerTypedToken,
pasteChipLabel,
snapshotComposerDraft,
} from "#lib/workspace/console/composer-draft.ts";
@@ -223,6 +225,50 @@ Deno.test("Paste content beginning with a colon remains opaque user input", () =
assertEquals(afterWhitespace.request.content, " :not-a-command\r\n");
});
Deno.test("typed invocation and attachment atoms round-trip in source order and delete atomically", () => {
const invocation: Segment = {
kind: "feature_invoke",
invocation: {
invocation_id: "invoke-1",
identity: "builtin:test/run",
name: "run",
arguments: [{ name: "value", value: { kind: "string", value: "資料 x" } }],
},
};
const attachment: Segment = {
kind: "uploaded_file",
file: {
artifact_id: "artifact-1",
file_name: "report.md",
media_type: "text/markdown",
created_at_ms: 1,
availability: "available",
byte_len: 4,
sha256: "abcd",
},
};
const first = composerTypedToken(10);
const second = composerTypedToken(11);
const document = `before ${first} between ${second} after`;
const registry = new Map([
[10, { segment: invocation, label: '/run(value="資料 x")' }],
[11, { segment: attachment, label: "Attached report.md", cleanup: true }],
]);
const snapshot = snapshotComposerDraft(document, new Map(), [], registry);
assertEquals(snapshot.segments.map((segment) => segment.kind), [
"text",
"feature_invoke",
"text",
"uploaded_file",
"text",
]);
const atoms = composerTypedAtoms(document, registry);
assertEquals(
composerDeletionRange({ from: atoms[1].to, to: atoms[1].to, head: atoms[1].to }, atoms, "backward"),
{ from: atoms[1].from, to: atoms[1].to },
);
});
Deno.test("plain short-paste Text retains the existing composer request path", () => {
const result = buildComposerSegmentsRequest([
{ kind: "text", content: " short\r\npaste\r\n " },
@@ -3,6 +3,9 @@ import type { Segment } from "#lib/generated/protocol.ts";
const PASTE_TOKEN_PREFIX = "\uFFF9";
const PASTE_TOKEN_SUFFIX = "\uFFFB";
const PASTE_TOKEN_PATTERN = /\uFFF9(\d+)\uFFFB/g;
const TYPED_TOKEN_PREFIX = "\uFFF8";
const TYPED_TOKEN_SUFFIX = "\uFFFA";
const TYPED_TOKEN_PATTERN = /\uFFF8(\d+)\uFFFA/g;
export interface ComposerPaste {
id: number;
@@ -24,6 +27,21 @@ export interface ComposerTextPaste {
content: string;
}
export interface ComposerTypedSegment {
segment: Segment;
label: string;
/** Whether deleting this unsent atom must release client-staged state. */
cleanup?: boolean;
/** Client-only reservation whose removal cancels an in-progress upload. */
uploadReservation?: boolean;
}
export interface ComposerTypedAtom extends ComposerTypedSegment {
key: number;
from: number;
to: number;
}
export interface ComposerSelection {
from: number;
to: number;
@@ -32,7 +50,7 @@ export interface ComposerSelection {
export function composerDeletionRange(
selection: ComposerSelection,
pastes: readonly ComposerPasteAtom[],
pastes: readonly { from: number; to: number }[],
direction: "backward" | "forward",
): { from: number; to: number } | null {
if (selection.from !== selection.to) {
@@ -75,6 +93,29 @@ export function composerPasteAtoms(
return atoms;
}
export function composerTypedToken(key: number): string {
return `${TYPED_TOKEN_PREFIX}${key}${TYPED_TOKEN_SUFFIX}`;
}
export function composerTypedAtoms(
document: string,
registry: ReadonlyMap<number, ComposerTypedSegment>,
): ComposerTypedAtom[] {
const atoms: ComposerTypedAtom[] = [];
for (const match of document.matchAll(TYPED_TOKEN_PATTERN)) {
const key = Number(match[1]);
const atom = registry.get(key);
if (!atom || match.index === undefined) continue;
atoms.push({
...atom,
key,
from: match.index,
to: match.index + match[0].length,
});
}
return atoms;
}
function appendTextSegment(segments: Segment[], content: string): void {
if (content.length === 0) return;
const previous = segments.at(-1);
@@ -89,8 +130,10 @@ export function snapshotComposerDraft(
document: string,
registry: ReadonlyMap<number, ComposerPaste>,
candidateTextPastes: readonly ComposerTextPaste[] = [],
typedRegistry: ReadonlyMap<number, ComposerTypedSegment> = new Map(),
): ComposerDraftSnapshot {
const pastes = composerPasteAtoms(document, registry);
const typedAtoms = composerTypedAtoms(document, typedRegistry);
const textPastes = candidateTextPastes
.filter((paste) =>
paste.from >= 0 &&
@@ -105,6 +148,12 @@ export function snapshotComposerDraft(
to: paste.to,
paste,
})),
...typedAtoms.map((atom) => ({
kind: "typed" as const,
from: atom.from,
to: atom.to,
atom,
})),
...textPastes.map((paste) => ({
kind: "text_paste" as const,
from: paste.from,
@@ -130,10 +179,14 @@ export function snapshotComposerDraft(
chars: event.paste.chars,
lines: event.paste.lines,
});
content += event.paste.content;
} else if (event.kind === "typed") {
segments.push(event.atom.segment);
content += event.atom.label;
} else {
appendTextSegment(segments, event.paste.content);
content += event.paste.content;
}
content += event.paste.content;
cursor = event.to;
}
@@ -29,7 +29,7 @@ type ComposerHistoryStorage = Pick<Storage, "getItem" | "setItem">;
function cloneEntry(entry: ComposerHistoryEntry): ComposerHistoryEntry {
return {
segments: entry.segments.map((segment) => ({ ...segment })) as Segment[],
segments: JSON.parse(JSON.stringify(entry.segments)) as Segment[],
preserveExactText: entry.preserveExactText,
};
}
@@ -45,6 +45,18 @@ function isSegment(value: unknown): value is Segment {
typeof segment.lines === "number";
}
if (segment.kind === "file_ref") return typeof segment.path === "string";
if (segment.kind === "feature_invoke") {
const invocation = segment.invocation as Record<string, unknown> | undefined;
return Boolean(invocation && typeof invocation.invocation_id === "string" &&
typeof invocation.identity === "string" && typeof invocation.name === "string" &&
Array.isArray(invocation.arguments) && invocation.arguments.every((argument) => {
if (!argument || typeof argument !== "object" || typeof argument.name !== "string") return false;
const value = argument.value;
return value && ((value.kind === "string" && typeof value.value === "string") ||
(value.kind === "integer" && Number.isSafeInteger(value.value) && value.value >= -2147483648 && value.value <= 2147483647) ||
(value.kind === "boolean" && typeof value.value === "boolean"));
}));
}
return false;
}
@@ -0,0 +1,260 @@
import { throws } from "node:assert/strict";
import type { FeatureInvocationDescriptor } from "#lib/generated/protocol.ts";
import {
activeInvocationArgument,
finalizeSelectedFeatureInvocations,
invocationInput,
selectedFeatureInvocationRanges,
} from "./composer-invocation.ts";
declare const Deno: { test(name: string, fn: () => void): void };
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const left = JSON.stringify(actual);
const right = JSON.stringify(expected);
if (left !== right) throw new Error(`Expected ${right}, got ${left}`);
}
const descriptor: FeatureInvocationDescriptor = {
identity: "builtin:test/run",
name: "run",
aliases: ["execute"],
display_name: "Run",
description: "Run a declared operation",
syntax: "parenthesized",
arguments: [
{
name: "path",
position: 0,
required: true,
value_type: { kind: "worker_file" },
completion: { kind: "worker_file" },
},
{
name: "mode",
required: false,
value_type: { kind: "enum", values: ["safe", "fast"] },
completion: { kind: "static", values: ["safe", "fast"] },
},
],
};
Deno.test("selected invocation parsing preserves Unicode, spaces, slashes, prose, and multiple calls", () => {
const text = '前 /run("資料/a b/c", mode=safe) 後 /execute("x/y") done';
const ranges = selectedFeatureInvocationRanges(text, [descriptor]);
assertEquals(ranges.length, 2);
assertEquals(text.slice(ranges[0].end), ' 後 /execute("x/y") done');
assertEquals(ranges[0].invocation.arguments.map((argument) => argument.value), [
{ kind: "string", value: "資料/a b/c" },
{ kind: "string", value: "safe" },
]);
const segments = finalizeSelectedFeatureInvocations(
[{ kind: "text", content: text }],
[descriptor],
);
assertEquals(segments.map((segment) => segment.kind), [
"text",
"feature_invoke",
"text",
"feature_invoke",
"text",
]);
});
Deno.test("multiple positional arguments advance once per value independent of descriptor order", () => {
const positional: FeatureInvocationDescriptor = {
...descriptor,
arguments: [
{ name: "enabled", position: 2, required: true, value_type: { kind: "boolean" }, completion: { kind: "none" } },
{ name: "mode", position: 0, required: true, value_type: { kind: "enum", values: ["brief", "full"] }, completion: { kind: "none" } },
{ name: "limit", position: 1, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } },
{ name: "note", required: false, value_type: { kind: "string" }, completion: { kind: "none" } },
],
};
for (const input of ['/run("brief", 2, true) following prose', '/run("brief", note="資料/a b", 2, true) following prose']) {
const parsed = selectedFeatureInvocationRanges(input, [positional])[0];
assertEquals(input.slice(parsed.end), " following prose");
assertEquals(parsed.invocation.arguments.filter((argument) => argument.name !== "note"), [
{ name: "mode", value: { kind: "string", value: "brief" } },
{ name: "limit", value: { kind: "integer", value: 2 } },
{ name: "enabled", value: { kind: "boolean", value: true } },
]);
}
});
Deno.test("unselected text, URLs, paths, and paste content are never promoted", () => {
assertEquals(
finalizeSelectedFeatureInvocations(
[
{ kind: "text", content: '/run("x") https://host/run("y") /tmp/run("z")' },
{ kind: "paste", id: 1, chars: 9, lines: 1, content: '/run("p")' },
],
[],
).map((segment) => segment.kind),
["text", "paste"],
);
});
Deno.test("argument completion ignores quoted invocation-looking data and escaped quotes", () => {
for (const [input, prefix] of [
['/run("a /run(pa", mode=safe) prose', 'a /run(pa'],
[String.raw`/run(path="a \" /execute(pa", mode=safe) prose`, 'a " /execute(pa'],
[String.raw`/run(path="a \" /run(pa, mode=safe) prose`, 'a " /run(pa'],
]) {
const dataCursor = input.lastIndexOf("(pa") + 3;
for (const occurrences of [undefined, [{ from: 0, descriptor }]]) {
const active = activeInvocationArgument(input, dataCursor, [descriptor], occurrences);
assertEquals(active?.descriptor.identity, descriptor.identity);
assertEquals(active?.argument?.name, "path");
assertEquals(active?.start, input.indexOf('"'));
assertEquals(active?.prefix, prefix);
}
}
const input = '/run("a /run(pa", mode=safe) prose /execute("new")';
const active = activeInvocationArgument(input, input.indexOf("new") + 2, [descriptor]);
assertEquals(active?.prefix, "ne");
assertEquals(active?.start, input.lastIndexOf('"new'));
assertEquals(activeInvocationArgument(input, input.indexOf("prose") + 3, [descriptor]), null);
const unknown = '/unknown("a /run(pa", mode=safe) prose';
assertEquals(activeInvocationArgument(unknown, unknown.lastIndexOf("pa") + 2, [descriptor]), null);
const quotedProse = 'quoted "a /run(pa" prose';
assertEquals(activeInvocationArgument(quotedProse, quotedProse.lastIndexOf("pa") + 2, [descriptor]), null);
});
Deno.test("editor occurrence anchors exclude unselected matching call contexts", () => {
const input = '/run("literal /run(unfinished data before /execute("selected")';
const from = input.indexOf("/execute(");
const active = activeInvocationArgument(input, input.indexOf("selected") + 3, [descriptor], [{ from, descriptor }]);
assertEquals(active?.prefix, "sel");
assertEquals(active?.start, input.indexOf('"selected'));
assertEquals(activeInvocationArgument(input, input.indexOf("literal") + 2, [descriptor], []), null);
});
Deno.test("incomplete quoted values bound completion replacement to the cursor", () => {
for (const input of ['/run(path="ab, mode=safe) following prose', '/run("ab, mode=safe) following prose', String.raw`/run(path="ab\q", mode=safe) following prose`]) {
const cursor = input.indexOf("ab") + 2;
const active = activeInvocationArgument(input, cursor, [descriptor]);
assertEquals(active?.end, cursor);
assertEquals(active?.prefix, "ab");
}
});
Deno.test("Feature strings reject lone surrogate escapes and code units like Rust JSON", () => {
for (const literal of [String.raw`"\uD800"`, String.raw`"\uDC00"`, String.raw`"\uD800x"`, String.raw`"\uD800\uD800"`, String.raw`"\uDC00\uD800"`, JSON.stringify("\uD800"), JSON.stringify("\uDC00")]) {
throws(() => selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor]), /unpaired Unicode surrogates/);
const input = `/run(${literal})`;
assertEquals(activeInvocationArgument(input, input.length - 1, [descriptor]), null);
}
throws(() => selectedFeatureInvocationRanges('/run("\uD800")', [descriptor]), /unpaired Unicode surrogates/);
throws(() => selectedFeatureInvocationRanges('/run(\uDC00)', [descriptor]), /unpaired Unicode surrogates/);
for (const literal of [String.raw`"\uD83D\uDE00"`, '"😀"', String.raw`"\uDBFF\uDFFF"`, String.raw`"\u0000\b\f\n\r\t\"\\\/資料"`]) {
const parsed = selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor])[0].invocation;
assertEquals(parsed.arguments[0].value, { kind: "string", value: JSON.parse(literal) });
}
for (const literal of [String.raw`"\q"`, String.raw`"\u12xz"`, '"line\nfeed"']) {
throws(() => selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor]), SyntaxError);
}
});
Deno.test("argument completion is cursor-aware and typed invocation round-trips to input", () => {
const named = activeInvocationArgument('/run("x", mo', 12, [descriptor]);
assert(named, "named argument completion should be active");
assertEquals(named.argument, null);
assertEquals(named.prefix, "mo");
const value = activeInvocationArgument('/run("x", mode=sa', 18, [descriptor]);
assert(value?.argument, "enum value completion should resolve its descriptor");
assertEquals(value.argument.name, "mode");
assertEquals(value.prefix, "sa");
const invocation = selectedFeatureInvocationRanges('/run("a b", mode=fast)', [descriptor])[0]
.invocation;
assertEquals(invocationInput(invocation), '/run(path="a b", mode="fast")');
});
Deno.test("complete invalid calls reject unknown, duplicate, missing, and mistyped arguments", () => {
for (const input of [
'/run(other="x")',
'/run("x", path="y")',
"/run()",
'/run("x", mode=invalid)',
]) {
let rejected = false;
try {
selectedFeatureInvocationRanges(input, [descriptor]);
} catch {
rejected = true;
}
assert(rejected, `${input} should be rejected`);
}
});
Deno.test("a leading slash without a selected descriptor terminates without promotion", () => {
assertEquals(activeInvocationArgument("/ru", 3, []), null);
assertEquals(activeInvocationArgument("/unknown(", 9, [descriptor]), null);
});
Deno.test("integer invocation arguments reject unsafe JavaScript wire values", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
let rejected = false;
try { selectedFeatureInvocationRanges("/run(9007199254740992)", [integerDescriptor]); }
catch { rejected = true; }
assert(rejected, "unsafe integers must reject before sending to Rust");
});
Deno.test("structured invocation-only Submit and mixed source order retain exact typed payloads", async () => {
const { buildComposerSegmentsRequest } = await import("./composer-command.ts");
const invocation = finalizeSelectedFeatureInvocations([{ kind: "text", content: '/run("a b/資料")' }], [descriptor])[0];
const segments = [{ kind: "text" as const, content: "before " }, invocation, { kind: "text" as const, content: " after" }];
const result = buildComposerSegmentsRequest(segments);
assert(result.ok && result.request, "mixed invocation should build a Submit");
assertEquals(result.request.segments, segments);
const only = buildComposerSegmentsRequest([invocation]);
assert(only.ok && only.request?.segments?.[0].kind === "feature_invoke", "invocation-only Submit must not become empty text");
});
Deno.test("positional completion after a named-only argument uses positional rather than comma index", () => {
const input = '/run(mode=safe, "';
assertEquals(activeInvocationArgument(input, input.length, [descriptor])?.argument?.name, "path");
});
Deno.test("Feature string parsing decodes full JSON escapes and preserves round-trip Unicode", () => {
const raw = String.raw`/run("a\b\f\n\r\t\"\\\/\u0000\uD83D\uDE00")`;
const parsed = selectedFeatureInvocationRanges(raw, [descriptor])[0].invocation;
assertEquals(parsed.arguments[0].value, { kind: "string", value: 'a\b\f\n\r\t"\\/\u0000😀' });
const restored = selectedFeatureInvocationRanges(invocationInput(parsed), [descriptor])[0].invocation;
assertEquals(restored.arguments, parsed.arguments);
});
Deno.test("Feature integer lexical syntax matches negative-or-positive digits only", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
for (const [raw, value] of [["-0", 0], ["0012", 12], ["-0012", -12]] as const) {
assertEquals(selectedFeatureInvocationRanges(`/run(${raw})`, [integerDescriptor])[0].invocation.arguments[0].value,
{ kind: "integer", value });
}
for (const raw of ["+1", "1.0", "1e0", "NaN", "Infinity"]) {
let rejected = false;
try { selectedFeatureInvocationRanges(`/run(${raw})`, [integerDescriptor]); } catch { rejected = true; }
assert(rejected, `${raw} must reject`);
}
});
Deno.test("invocation integer parsing matches the generated Rust int32 schema", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
for (const value of [-2147483648, 2147483647]) {
assertEquals(selectedFeatureInvocationRanges(`/run(${value})`, [integerDescriptor])[0].invocation.arguments[0].value,
{ kind: "integer", value });
}
for (const value of [-2147483649, 2147483648]) {
let rejected = false;
try { selectedFeatureInvocationRanges(`/run(${value})`, [integerDescriptor]); } catch { rejected = true; }
assert(rejected, "values outside Rust int32 range must reject");
}
});
@@ -0,0 +1,434 @@
import type {
FeatureInvocation,
FeatureInvocationDescriptor,
InvocationArgumentDescriptor,
InvocationArgumentValue,
InvocationValue,
Segment,
} from "#lib/generated/protocol.ts";
export type ActiveInvocationArgument = {
descriptor: FeatureInvocationDescriptor;
argument: InvocationArgumentDescriptor | null;
start: number;
end: number;
prefix: string;
};
export function quoteInvocationString(value: string): string {
return JSON.stringify(value);
}
export function invocationInput(invocation: FeatureInvocation): string {
const argumentsText = invocation.arguments.map((argument) =>
`${argument.name}=${invocationValueInput(argument.value)}`
).join(", ");
return `/${invocation.name}(${argumentsText})`;
}
function invocationValueInput(value: InvocationValue): string {
switch (value.kind) {
case "string":
return quoteInvocationString(value.value);
case "integer":
case "boolean":
return String(value.value);
}
}
export type SelectedInvocationOccurrence = {
from: number;
descriptor: FeatureInvocationDescriptor;
};
export function activeInvocationArgument(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[],
selectedOccurrences?: readonly SelectedInvocationOccurrence[],
): ActiveInvocationArgument | null {
const bounded = Math.max(0, Math.min(cursor, value.length));
const occurrences = selectedOccurrences ?? activeInvocationOccurrences(value, bounded, descriptors);
for (const { from: slash, descriptor } of occurrences) {
if (slash < 0 || slash >= bounded) continue;
const tail = value.slice(slash, bounded);
if (![descriptor.name, ...descriptor.aliases].some((name) => tail.startsWith(`/${name}(`))) continue;
const open = tail.indexOf("(");
const argumentsText = tail.slice(open + 1);
if (hasUnquotedClose(argumentsText)) continue;
const currentOffset = lastUnquotedComma(argumentsText) + 1;
const rawCurrent = argumentsText.slice(currentOffset);
const leading = rawCurrent.length - rawCurrent.trimStart().length;
const current = rawCurrent.trimStart();
const equal = unquotedEqual(current);
if (equal >= 0) {
const name = current.slice(0, equal).trim();
const argument = descriptor.arguments.find((candidate) => candidate.name === name) ?? null;
let valueOffset = equal + 1;
while (/\s/.test(current[valueOffset] ?? "")) valueOffset++;
const prefix = invocationCompletionPrefix(current.slice(valueOffset));
if (prefix === null) return null;
return {
descriptor,
argument,
start: slash + open + 1 + currentOffset + leading + valueOffset,
end: invocationValueEnd(value, bounded, slash + open + 1 + currentOffset + leading + valueOffset),
prefix,
};
}
const position = countPositionalArguments(argumentsText.slice(0, currentOffset));
const positional = descriptor.arguments.find((candidate) => candidate.position === position) ?? null;
const looksLikeArgumentName = /^[a-z][a-z0-9_]*$/.test(current) &&
descriptor.arguments.some((candidate) => candidate.name.startsWith(current));
const prefix = invocationCompletionPrefix(current);
if (prefix === null) return null;
return {
descriptor,
argument: looksLikeArgumentName ? null : positional,
start: slash + open + 1 + currentOffset + leading,
end: looksLikeArgumentName
? bounded + (/^[a-z0-9_]*/.exec(value.slice(bounded))?.[0].length ?? 0)
: invocationValueEnd(value, bounded, slash + open + 1 + currentOffset + leading),
prefix,
};
}
return null;
}
/** Scan forward and skip entire quoted argument regions, never treating a slash
* inside an unfinished outer value as a new call. The editor supplies selected
* occurrence anchors instead of relying on descriptor-only discovery.
*/
function activeInvocationOccurrences(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[],
): SelectedInvocationOccurrence[] {
let active: SelectedInvocationOccurrence | null = null;
scanEachUnquoted(value.slice(0, cursor), (char, slash) => {
if (active) {
if (char === ")") active = null;
return;
}
if (char !== "/" || (slash > 0 && !/\s/.test(value[slash - 1]))) return;
const descriptor = descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) => value.startsWith(`/${name}(`, slash))
);
if (descriptor) active = { from: slash, descriptor };
});
return active ? [active] : [];
}
function invocationCompletionPrefix(value: string): string | null {
let prefix = value;
if (value.startsWith('"')) {
try {
prefix = JSON.parse(value.endsWith('"') ? value : `${value}"`);
} catch {
prefix = value.slice(1);
}
}
try {
return validInvocationString(prefix);
} catch {
// Never send a completion prefix that Rust's JSON reader cannot decode.
return null;
}
}
function invocationValueEnd(text: string, cursor: number, start: number): number {
try {
const parsed = parseRawValue(text, start);
// Without a complete, valid token the suffix is untouched user input.
return parsed && parsed.end >= cursor ? parsed.end : cursor;
} catch {
return cursor;
}
}
export function selectedFeatureInvocationAt(
text: string,
start: number,
descriptor: FeatureInvocationDescriptor,
): ParsedInvocationRange | null {
const parsed = parseFeatureInvocation(text, start, descriptor);
return parsed ? { start, end: parsed.end, invocation: parsed.invocation } : null;
}
export type ParsedInvocationRange = {
start: number;
end: number;
invocation: FeatureInvocation;
};
/** Parse complete invocations selected from completion without promoting arbitrary text. */
export function selectedFeatureInvocationRanges(
text: string,
descriptors: readonly FeatureInvocationDescriptor[],
): ParsedInvocationRange[] {
const ranges: ParsedInvocationRange[] = [];
let search = 0;
while (search < text.length) {
const slash = text.indexOf("/", search);
if (slash < 0) break;
const descriptor = (slash === 0 || /\s/.test(text[slash - 1]))
? descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) =>
text.startsWith(`/${name}(`, slash)
)
)
: undefined;
if (!descriptor) {
search = slash + 1;
continue;
}
const parsed = parseFeatureInvocation(text, slash, descriptor);
if (!parsed) {
search = slash + 1;
continue;
}
ranges.push({ start: slash, end: parsed.end, invocation: parsed.invocation });
search = parsed.end;
}
return ranges;
}
export function finalizeSelectedFeatureInvocations(
segments: readonly Segment[],
descriptors: readonly FeatureInvocationDescriptor[],
): Segment[] {
const result: Segment[] = [];
for (const segment of segments) {
if (segment.kind !== "text") {
result.push(segment);
continue;
}
result.push(...parseTextInvocations(segment.content, descriptors));
}
return coalesceText(result);
}
function parseTextInvocations(
text: string,
descriptors: readonly FeatureInvocationDescriptor[],
): Segment[] {
const result: Segment[] = [];
let emitted = 0;
let search = 0;
while (search < text.length) {
const slash = text.indexOf("/", search);
if (slash < 0) break;
const leadingOk = slash === 0 || /\s/.test(text[slash - 1]);
const descriptor = leadingOk
? descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) =>
text.startsWith(`/${name}(`, slash)
)
)
: undefined;
if (!descriptor) {
search = slash + 1;
continue;
}
const parsed = parseFeatureInvocation(text, slash, descriptor);
if (!parsed) {
search = slash + 1;
continue;
}
appendText(result, text.slice(emitted, slash));
result.push({ kind: "feature_invoke", invocation: parsed.invocation });
emitted = parsed.end;
search = parsed.end;
}
if (!result.length) return [{ kind: "text", content: text }];
appendText(result, text.slice(emitted));
return result;
}
function parseFeatureInvocation(
text: string,
start: number,
descriptor: FeatureInvocationDescriptor,
): { invocation: FeatureInvocation; end: number } | null {
let cursor = start + 1;
while (/[a-z0-9_-]/.test(text[cursor] ?? "")) cursor++;
const name = text.slice(start + 1, cursor);
if (name !== descriptor.name && !descriptor.aliases.includes(name)) return null;
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] !== "(") return null;
cursor++;
const values: InvocationArgumentValue[] = [];
let position = 0;
while (true) {
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] === ")") {
cursor++;
break;
}
if (cursor >= text.length) return null;
const itemStart = cursor;
while (/[a-z0-9_]/.test(text[cursor] ?? "")) cursor++;
const possibleName = text.slice(itemStart, cursor);
while (/\s/.test(text[cursor] ?? "")) cursor++;
let argument: InvocationArgumentDescriptor | undefined;
if (possibleName && text[cursor] === "=") {
argument = descriptor.arguments.find((candidate) => candidate.name === possibleName);
if (!argument) throw new Error(`Unknown Feature argument: ${possibleName}`);
cursor++;
while (/\s/.test(text[cursor] ?? "")) cursor++;
} else {
cursor = itemStart;
argument = descriptor.arguments.find((candidate) => candidate.position === position);
position++;
if (!argument) throw new Error("Too many positional Feature arguments.");
}
const parsed = parseRawValue(text, cursor);
if (!parsed) return null;
cursor = parsed.end;
if (values.some((candidate) => candidate.name === argument.name)) {
throw new Error(`Duplicate Feature argument: ${argument.name}`);
}
values.push({ name: argument.name, value: coerceValue(argument, parsed.value) });
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] === ",") cursor++;
else if (text[cursor] !== ")") return null;
}
for (const argument of descriptor.arguments) {
if (argument.required && !values.some((value) => value.name === argument.name)) {
throw new Error(`Missing required Feature argument: ${argument.name}`);
}
}
return {
invocation: {
invocation_id: crypto.randomUUID(),
identity: descriptor.identity,
name,
arguments: values,
},
end: cursor,
};
}
function parseRawValue(text: string, start: number): { value: string; end: number } | null {
if (text[start] === '"') {
let cursor = start + 1;
let escaped = false;
while (cursor < text.length) {
const char = text[cursor];
if (!escaped && char === '"') {
const literal = text.slice(start, cursor + 1);
return { value: validInvocationString(JSON.parse(literal)), end: cursor + 1 };
}
escaped = !escaped && char === "\\";
if (char !== "\\") escaped = false;
cursor++;
}
return null;
}
let cursor = start;
while (cursor < text.length && !/[\s,)]/.test(text[cursor])) cursor++;
return cursor === start ? null : { value: validInvocationString(text.slice(start, cursor)), end: cursor };
}
/** Rust JSON strings contain Unicode scalar values, not lone UTF-16 code units.
* JSON.parse alone permits unpaired surrogate escapes that serde_json rejects.
*/
function validInvocationString(value: string): string {
for (let index = 0; index < value.length; index++) {
const unit = value.charCodeAt(index);
if (unit >= 0xD800 && unit <= 0xDBFF) {
const next = value.charCodeAt(++index);
if (next >= 0xDC00 && next <= 0xDFFF) continue;
} else if (unit < 0xDC00 || unit > 0xDFFF) continue;
throw new Error("Feature string arguments must not contain unpaired Unicode surrogates.");
}
return value;
}
function coerceValue(argument: InvocationArgumentDescriptor, value: string): InvocationValue {
switch (argument.value_type.kind) {
case "integer": {
if (!/^-?\d+$/.test(value)) throw new Error(`Feature argument ${argument.name} must be an integer.`);
const integer = Number(value);
if (!Number.isSafeInteger(integer)) {
throw new Error(`Feature argument ${argument.name} must be a safe integer.`);
}
if (integer < -2147483648 || integer > 2147483647) {
throw new Error(`Feature argument ${argument.name} must be a signed 32-bit integer.`);
}
return { kind: "integer", value: integer };
}
case "boolean":
if (value !== "true" && value !== "false") {
throw new Error(`Feature argument ${argument.name} must be true or false.`);
}
return { kind: "boolean", value: value === "true" };
case "enum":
if (!argument.value_type.values.includes(value)) {
throw new Error(`Feature argument ${argument.name} is not an allowed value.`);
}
return { kind: "string", value };
case "string":
case "worker_file":
case "client_file":
return { kind: "string", value };
}
}
function appendText(segments: Segment[], content: string): void {
if (!content) return;
const prior = segments.at(-1);
if (prior?.kind === "text") prior.content += content;
else segments.push({ kind: "text", content });
}
function coalesceText(segments: Segment[]): Segment[] {
const result: Segment[] = [];
for (const segment of segments) {
if (segment.kind === "text") appendText(result, segment.content);
else result.push(segment);
}
return result;
}
// These helpers only inspect syntax before the cursor. They deliberately do not
// execute or finalize anything.
function hasUnquotedClose(value: string): boolean {
return scanUnquoted(value, (char) => char === ")") >= 0;
}
function lastUnquotedComma(value: string): number {
let last = -1;
scanEachUnquoted(value, (char, index) => { if (char === ",") last = index; });
return last;
}
function unquotedEqual(value: string): number {
return scanUnquoted(value, (char) => char === "=");
}
function countPositionalArguments(value: string): number {
let count = 0;
let start = 0;
scanEachUnquoted(value, (char, index) => {
if (char !== ",") return;
if (value.slice(start, index).trim() && unquotedEqual(value.slice(start, index)) < 0) count++;
start = index + 1;
});
return count;
}
function scanUnquoted(value: string, predicate: (char: string) => boolean): number {
let found = -1;
scanEachUnquoted(value, (char, index) => {
if (found < 0 && predicate(char)) found = index;
});
return found;
}
function scanEachUnquoted(value: string, visit: (char: string, index: number) => void): void {
let quoted = false;
let escaped = false;
for (let index = 0; index < value.length; index++) {
const char = value[index];
if (!escaped && char === '"') quoted = !quoted;
if (!quoted) visit(char, index);
escaped = quoted && !escaped && char === "\\";
if (char !== "\\") escaped = false;
}
}
@@ -1,70 +1,79 @@
import { FileCompletions } from "./file-completions.ts";
import { deepStrictEqual as assertEquals, rejects } from "node:assert/strict";
async function assertRejects(
fn: () => Promise<unknown>,
_type: typeof Error,
message: string,
) {
await rejects(
fn,
(error: unknown) =>
error instanceof Error && error.message.includes(message),
);
}
import {
deepStrictEqual as assertEquals,
notEqual,
rejects,
} from "node:assert/strict";
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
Deno.test("file completion serializes the latest prefix behind cancelled wire replies", async () => {
const sent: string[] = [];
const lane = new FileCompletions((prefix) => sent.push(prefix));
Deno.test("completion nonces fence same prefix/context ABA without draining cancelled requests", async () => {
const sent: Array<[string, string]> = [];
const lane = new FileCompletions((prefix, id) => sent.push([prefix, id]));
const first = new AbortController();
const a = lane.request("a", first.signal);
const a = lane.request("same", first.signal);
first.abort();
assertEquals(await a, []);
const second = new AbortController();
const b = lane.request("b", second.signal);
second.abort();
const c = lane.request("c", new AbortController().signal);
const b = lane.request("other", new AbortController().signal);
const c = lane.request("same", new AbortController().signal);
assertEquals(await b, []);
assertEquals(sent, ["a"]);
lane.receive([{ value: "old-a" }]);
assertEquals(sent, ["a", "c"]);
lane.receive([{ value: "current-c" }]);
assertEquals(sent.map(([prefix]) => prefix), ["same", "other", "same"]);
notEqual(sent[0][1], sent[2][1]);
lane.receive([{ value: "old-a" }], "same", sent[0][1]);
lane.receive([{ value: "legacy" }], "same");
lane.receive([{ value: "wrong-prefix" }], "other", sent[2][1]);
assertEquals(lane.pending, true);
lane.receive([{ value: "current-c" }], "same", sent[2][1]);
assertEquals(await c, [{ value: "current-c" }]);
lane.close();
});
Deno.test("file completion timeout blocks late responses until a new connection", async () => {
const sent: string[] = [];
const lane = new FileCompletions((prefix) => sent.push(prefix), 5);
await assertRejects(
() => lane.request("a", new AbortController().signal),
Error,
"timed out",
Deno.test("multiple clients cannot consume each other's identical completion broadcasts", async () => {
const ids: string[] = [];
const lanes = [
new FileCompletions((_prefix, id) => ids.push(id)),
new FileCompletions((_prefix, id) => ids.push(id)),
];
const promises = lanes.map((lane) =>
lane.request("same", new AbortController().signal)
);
lane.receive([{ value: "late-a" }]);
await assertRejects(
() => lane.request("b", new AbortController().signal),
Error,
"timed out",
notEqual(ids[0], ids[1]);
for (const lane of lanes) {
lane.receive([{ value: "client-a" }], "same", ids[0]);
}
assertEquals(await promises[0], [{ value: "client-a" }]);
assertEquals(lanes[1].pending, true);
for (const lane of lanes) {
lane.receive([{ value: "client-b" }], "same", ids[1]);
}
assertEquals(await promises[1], [{ value: "client-b" }]);
lanes.forEach((lane) => lane.close());
});
Deno.test("completion timeout and reconnect invalidate old nonces but allow a fresh request", async () => {
const ids: string[] = [];
const lane = new FileCompletions((_prefix, id) => ids.push(id), 5);
await rejects(
lane.request("same", new AbortController().signal),
/timed out/,
);
assertEquals(sent, ["a"]);
lane.reset();
const c = lane.request("c", new AbortController().signal);
lane.receive([{ value: "c" }]);
assertEquals(await c, [{ value: "c" }]);
const current = lane.request("same", new AbortController().signal);
lane.receive([{ value: "old" }], "same", ids[0]);
assertEquals(lane.pending, true);
lane.receive([{ value: "new" }], "same", ids[1]);
assertEquals(await current, [{ value: "new" }]);
lane.close();
});
Deno.test("file completion close rejects both active and queued requests", async () => {
Deno.test("completion close rejects active request and fences requests until reset", async () => {
const lane = new FileCompletions(() => {});
const a = lane.request("a", new AbortController().signal);
const b = lane.request("b", new AbortController().signal);
const outcomes = Promise.allSettled([a, b]);
const rejected = rejects(a, /disconnected/);
lane.close(new Error("disconnected"));
assertEquals((await outcomes).map((value) => value.status), [
"rejected",
"rejected",
]);
await rejected;
await rejects(
lane.request("b", new AbortController().signal),
/disconnected/,
);
});
@@ -1,6 +1,7 @@
import type { ComposerCompletionEntry } from "./composer-completion.ts";
type Request = {
id: string;
prefix: string;
signal: AbortSignal;
resolve: (entries: ComposerCompletionEntry[]) => void;
@@ -8,18 +9,18 @@ type Request = {
abort: () => void;
};
/** The wire reply has no request id/prefix. Never overlap requests on a lane,
* including cancelled requests: their reply must be consumed before the next.
* A timeout poisons the lane until reconnect, rather than misrouting a late reply.
/** A lane is scoped to a transport target and completion kind/context. Each
* request also has a unique wire nonce: cancelled requests never need draining,
* and legacy replies or another client's same-prefix broadcasts cannot match.
* The editor's abort signal fences the draft, cursor and completion generation.
*/
export class FileCompletions {
private active: Request | null = null;
private queued: Request | null = null;
private timer: ReturnType<typeof setTimeout> | undefined;
private failure: Error | null = null;
constructor(
private send: (prefix: string) => void,
private send: (prefix: string, requestId: string) => void,
private timeoutMs = 30_000,
) {}
@@ -33,42 +34,48 @@ export class FileCompletions {
): Promise<ComposerCompletionEntry[]> {
if (signal.aborted) return Promise.resolve([]);
if (this.failure) return Promise.reject(this.failure);
this.finish([]);
return new Promise((resolve, reject) => {
const request: Request = {
id: crypto.randomUUID(),
prefix,
signal,
resolve,
reject,
abort: () => {
resolve([]);
if (this.queued === request) this.queued = null;
if (this.active === request) this.finish([]);
},
};
this.active = request;
signal.addEventListener("abort", request.abort, { once: true });
if (this.queued) this.finish(this.queued, []);
this.queued = request;
this.pump();
this.timer = setTimeout(() => {
if (this.active === request) {
this.reject(new Error("Worker completion request timed out; retry."));
}
}, this.timeoutMs);
try {
this.send(prefix, request.id);
} catch (error) {
this.reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
receive(entries: ComposerCompletionEntry[]): void {
if (!this.active) return;
clearTimeout(this.timer);
const request = this.active;
this.active = null;
this.finish(request, entries);
this.pump();
receive(
entries: ComposerCompletionEntry[],
prefix: string,
requestId?: string | null,
): void {
if (
!this.active || this.active.id !== requestId ||
this.active.prefix !== prefix
) return;
this.finish(entries);
}
close(error = new Error("Worker completion connection closed.")): void {
this.failure = error;
clearTimeout(this.timer);
for (const request of [this.active, this.queued]) {
if (!request) continue;
request.signal.removeEventListener("abort", request.abort);
request.reject(error);
}
this.active = this.queued = null;
this.reject(error);
}
reset(): void {
@@ -76,31 +83,20 @@ export class FileCompletions {
this.failure = null;
}
private finish(request: Request, entries: ComposerCompletionEntry[]): void {
request.signal.removeEventListener("abort", request.abort);
request.resolve(request.signal.aborted ? [] : entries);
private take(): Request | null {
clearTimeout(this.timer);
const request = this.active;
this.active = null;
request?.signal.removeEventListener("abort", request.abort);
return request;
}
private pump(): void {
if (this.active || !this.queued || this.failure) return;
const request = this.queued;
this.queued = null;
if (request.signal.aborted) {
this.finish(request, []);
return;
}
this.active = request;
this.timer = setTimeout(
() =>
this.close(
new Error("Worker completion request timed out; reconnect to retry."),
),
this.timeoutMs,
);
try {
this.send(request.prefix);
} catch (error) {
this.close(error instanceof Error ? error : new Error(String(error)));
}
private finish(entries: ComposerCompletionEntry[]): void {
const request = this.take();
request?.resolve(request.signal.aborted ? [] : entries);
}
private reject(error: Error): void {
this.take()?.reject(error);
}
}
@@ -352,7 +352,7 @@ Deno.test("console routing projects live errors but not completion replies", ()
} satisfies Event;
const completionEvent = {
event: "completions",
data: { kind: "file", entries: [] },
data: { kind: "file", prefix: "", entries: [] },
} satisfies Event;
assert(
@@ -4059,3 +4059,11 @@ Deno.test("committed history inserts current live rows after their last stable a
["current-user", "live-assistant"],
);
});
Deno.test("typed Feature invocations remain visible in live and restored user messages", () => {
const segment = { kind: "feature_invoke" as const, invocation: {
invocation_id: "invoke-1", identity: "test/run", name: "run",
arguments: [{ name: "path", value: { kind: "string" as const, value: "資料/a b" } }],
} };
assertEquals(segmentsToText([segment]), '/run(path="資料/a b")');
});
@@ -19,6 +19,7 @@ import type {
WorkerStateSnapshot,
WorkerStatus,
} from "#lib/generated/protocol.ts";
import { invocationInput } from "./composer-invocation.ts";
import { stringify as stringifyYaml } from "yaml";
import {
applyRunActivityEvent,
@@ -1348,6 +1349,8 @@ export function segmentsToText(segments: Segment[]): string {
return `[Large paste artifact ${segment.artifact.artifact_id}: ${segment.artifact.byte_len} bytes, ${segment.artifact.media_type}, ${segment.artifact.availability}, created ${segment.artifact.created_at_ms} ms, sha256 ${segment.artifact.sha256}]`;
case "uploaded_file":
return `[Attachment: ${segment.file.file_name} · ${segment.file.media_type} · ${segment.file.byte_len} bytes · ${segment.file.availability}]`;
case "feature_invoke":
return invocationInput(segment.invocation);
case "file_ref":
return `@file ${segment.path}`;
case "unknown":
@@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
subjectIndexLoad.includes('"/subjektiv/subjects"') &&
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
subjectIndexPage.includes(">Subjects</h1>") &&
subjectIndexPage.includes("subject.store_revision") &&
subjectIndexPage.includes("Worker connection") &&
subjectIndexPage.includes("subject.current_worker") &&
!subjectIndexPage.includes("subject.store_revision") &&
subjectIndexPage.includes('data-memory-view="subjects"'),
"Memory product entry should list explicit typed subjects",
);
@@ -510,10 +512,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
subjectPageLoad.includes("`${subjectPath}/surface`") &&
subjectPageLoad.includes("`${subjectPath}/memories`") &&
subjectPage.includes("DocumentMarkdown from") &&
subjectPage.includes("Resident surface") &&
subjectPage.includes("Resident context") &&
subjectPage.includes("Subject store revision") &&
subjectPage.includes("Current Memories") &&
subjectPage.includes("surface?.availability === 'ready'") &&
subjectPage.includes("surface?.availability === 'ungenerated'") &&
subjectPage.includes("surface?.availability === 'stale'") &&
subjectPage.includes("surface?.availability === 'failed'") &&
subjectPage.includes("data-surface-ready-empty") &&
!subjectPage.includes("overflow-y"),
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
@@ -1043,7 +1048,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
composerInput.includes('key: "Backspace"') &&
composerInput.includes('key: "Delete"') &&
composerInput.includes(
"composerDeletionRange(selection, pastes, direction)",
"composerDeletionRange(selection, atoms, direction)",
) &&
composerInput.includes("EditorState.readOnly.of(isDisabled)") &&
composerInput.includes('key: "Mod-z"') &&
@@ -1594,8 +1599,8 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
"Completion navigation must precede input history",
);
assert(
page.includes("fileCompletions.receive(event.data.entries)") &&
page.includes("fileCompletions.close()"),
page.includes("fileCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id)") &&
page.includes("rejectPendingCompletion(new Error(\"Worker completion connection closed.\"), `${target.workspaceId}:${target.runtimeId}:${target.workerId}`)"),
"File completion lifecycle must be scoped to the transport",
);
});
@@ -1719,7 +1724,7 @@ Deno.test("Web Console uses Notify while running and exposes queued input cancel
consolePage.includes("{#if workerRunning || workerPaused}") &&
consolePage.includes('delivery === "queue" && request.kind !== "user"') &&
consolePage.includes(
'method.method === "submit" && delivery === "submit"',
'event.event === "submission_accepted" && event.data.disposition === "started"',
),
"busy Console must offer explicit Queue without changing paused state or queueing commands",
);
+107 -5
View File
@@ -1,4 +1,7 @@
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
import {
MEMORY_API_LIMITS,
SUBJEKTIV_API_LIMITS,
} from "#lib/generated/memory-api.ts";
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import type {
Diagnostic,
@@ -26,6 +29,7 @@ import type {
SubjektivResidentSurfaceAvailability,
SubjektivResidentSurfaceResponse,
SubjektivResidentSurfaceSnapshot,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectCreateRequest,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
} as const;
const MAX_SUBJECT_ROLE_BYTES = 256;
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
export type SubjektivSubjectCreateErrorKind =
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
"Subject role must be at most 256 bytes and contain no control characters.",
);
}
return { role: request.role };
const behaviorMd = request.behavior_md ?? "";
validateSubjectBehavior(behaviorMd);
return { role: request.role, behavior_md: behaviorMd };
}
function validateSubjectBehavior(behaviorMd: string): void {
const bytes = new TextEncoder().encode(behaviorMd).byteLength;
if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) {
throw new SubjektivSubjectCreateError(
"validation",
`Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`,
);
}
if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior must be empty or contain non-whitespace text.",
);
}
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior contains an unsupported control character.",
);
}
}
export async function createSubjektivSubject(
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
const subject = parseSubjektivSubjectResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
);
if (subject.role !== validated.role) {
throw new Error("created Subject role does not match the request");
if (
subject.role !== validated.role ||
subject.behavior_md !== validated.behavior_md
) {
throw new Error("created Subject does not match the request");
}
return subject;
} catch {
@@ -182,6 +214,68 @@ export async function createSubjektivSubject(
}
}
export async function updateSubjektivSubjectBehavior(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: SubjektivSubjectBehaviorUpdateRequest,
): Promise<SubjektivSubjectResponse> {
validateSubjectBehavior(request.behavior_md);
if (
!Number.isSafeInteger(request.expected_behavior_revision) ||
request.expected_behavior_revision < 0
) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior revision is invalid.",
);
}
let response: Response;
try {
response = await fetchFn(
workspaceApiPath(
workspaceId,
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/behavior`,
),
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
} catch {
throw new SubjektivSubjectCreateError(
"unknown_outcome",
"The save outcome is unknown. Reload the Subject before retrying.",
);
}
if (!response.ok) {
const message = await subjektivSubjectCreateErrorMessage(response);
throw new SubjektivSubjectCreateError(
response.status >= 500 ? "unknown_outcome" : "rejected",
response.status === 409
? "The behavior changed elsewhere. Reload the Subject and review the latest text."
: message,
response.status,
);
}
try {
const subject = parseSubjektivSubjectResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
subjectId,
);
if (subject.behavior_md !== request.behavior_md) {
throw new Error("saved Subject behavior does not match the request");
}
return subject;
} catch {
throw new SubjektivSubjectCreateError(
"unknown_outcome",
"The behavior may have been saved, but the response could not be confirmed. Reload the Subject.",
);
}
}
async function subjektivSubjectCreateErrorMessage(
response: Response,
): Promise<string> {
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
} catch {
// A rejected request still has a known outcome even when its body is invalid.
}
return `Subject creation failed with HTTP ${response.status}.`;
return `Subject request failed with HTTP ${response.status}.`;
}
export function parseMemoryDocumentResponse(
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
[
"id",
"role",
"behavior_md",
"behavior_revision",
"state",
"store_revision",
"created_at",
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
const result: SubjektivSubjectResponse = {
id,
role: requiredString(record, "role"),
behavior_md: requiredString(
record,
"behavior_md",
MAX_SUBJECT_BEHAVIOR_BYTES,
),
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
state,
store_revision: requiredNonNegativeInteger(record, "store_revision"),
created_at: requiredString(record, "created_at"),
@@ -323,3 +323,28 @@ Deno.test('stateful selector validation fences a routed event before projection'
'mismatched Worker event crossed its subscription selector',
);
});
Deno.test('generated subscription schema accepts invocation descriptors, context and typed user segments', () => {
const descriptor = { identity: 'test/run', name: 'run', aliases: ['execute'], display_name: 'Run', description: 'Test',
syntax: 'parenthesized', client_adapter: null, arguments: [{ name: 'count', position: 0, required: true,
value_type: { kind: 'integer' }, completion: { kind: 'static', values: ['1', '2'] } }] };
const frame = (events: unknown[]) => subscribedFrame({
selector: { topic: 'worker_protocol', worker_id: 'worker-1' },
snapshot: { topic: 'worker_protocol', data: { worker_id: 'worker-1', events } },
});
const completion = { event: 'completions', data: { kind: 'feature_argument', prefix: '1',
context: { invocation: 'test/run', argument: 'count' },
entries: [{ value: '1', is_dir: false, invocation: descriptor }] } };
const segment = { kind: 'feature_invoke', invocation: { invocation_id: 'invoke-1', identity: 'test/run', name: 'run',
arguments: [{ name: 'count', value: { kind: 'integer', value: 1 } }] } };
const valid = decode(frame([completion, { event: 'user_message', data: { segments: [segment] } }]));
assert(valid.ok, `invocation schema mismatch: ${JSON.stringify(valid)}`);
const invalid = decode(frame([{ event: 'user_message', data: { segments: [{ ...segment, invocation: {
...segment.invocation, arguments: [{ name: 'count', value: { kind: 'integer', value: 9007199254740992 } }],
} }] } }]));
assert(!invalid.ok, 'generated invocation schema must reject unsafe wire integers');
const newer = decode(frame([{ ...completion, data: { ...completion.data, entries: [
{ value: '1', is_dir: false, invocation: { ...descriptor, client_adapter: 'newer_adapter' } },
] } }]));
assert(!newer.ok, 'unknown adapter capabilities must fail closed');
});
@@ -1,8 +1,15 @@
<script lang="ts">
import { untrack } from 'svelte';
import Spinner from '#lib/workspace/console/Spinner.svelte';
import { workerConsoleHref } from '#lib/workspace/resource-links.ts';
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import {
canRestoreWorker,
createRestoreRequest,
restoreWorkspaceWorker,
restoreNotice,
restoreErrorNotice,
type RestoreRequest,
canStopSidebarWorker,
canDeleteSidebarWorker,
deleteSidebarWorker,
@@ -10,6 +17,7 @@
} from './worker-actions';
import {
workspaceWorkersStore,
refreshWorkspaceWorkers,
type SidebarWorker,
} from './worker-subscription';
import {
@@ -21,7 +29,7 @@
import { sidebarWorkdirMeta } from './worker-workdir-meta';
const COLLAPSED_WORKER_COUNT = 6;
type WorkerActionKind = 'stop' | 'delete';
type WorkerActionKind = 'restore' | 'stop' | 'delete';
type Props = {
currentPath?: string;
@@ -35,6 +43,7 @@
let openWorkerKey = $state<string | null>(null);
let menuElement = $state<HTMLElement | null>(null);
let menuTrigger = $state<HTMLButtonElement | null>(null);
let restoreRetries = $state<Record<string, RestoreRequest>>({});
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let visibleWorkers = $derived(
visibleWorkersForSidebar(workers, {
@@ -90,58 +99,104 @@
closeWorkerMenu(true);
}
let lifetime = 0;
function actionScope() {
const id = workspaceId;
const epoch = lifetime;
return { workspaceId: id, isCurrent: () => lifetime === epoch && workspaceId === id };
}
async function restoreWorker(worker: SidebarWorker, retry = false) {
const key = workerKey(worker);
if (busyAction || (retry ? !restoreRetries[key] : restoreRetries[key] || !canRestoreWorker(worker))) return;
const scope = actionScope();
const request = retry ? restoreRetries[key] : createRestoreRequest(worker);
closeWorkerMenu();
busyAction = { workerKey: key, kind: 'restore' };
try {
let notice;
try {
notice = restoreNotice(await restoreWorkspaceWorker(scope.workspaceId, worker, request));
} catch (cause) {
notice = restoreErrorNotice(cause);
}
if (!scope.isCurrent()) return;
const next = { ...restoreRetries };
if (notice.retry) next[key] = request;
else delete next[key];
restoreRetries = next;
pushWorkspaceAlert(notice.level, notice.message, { title: notice.title });
await refreshWorkspaceWorkers(scope.workspaceId).catch(() => {});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
async function stopWorker(worker: SidebarWorker) {
if (busyAction || !canStopSidebarWorker(worker)) return;
const scope = actionScope();
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'stop' };
try {
await stopSidebarWorker(workspaceId, worker);
workers = workers.map((item) =>
workerKey(item) === workerKey(worker)
? { ...item, state: 'stopped', lifecycleState: 'stopped' }
: item
);
await stopSidebarWorker(scope.workspaceId, worker);
if (!scope.isCurrent()) return;
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
pushWorkspaceAlert('info', `${worker.display_name || worker.label} stopped`, {
title: 'Worker stopped',
});
} catch (cause) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker stop failed', {
title: 'Worker stop failed',
});
} finally {
busyAction = null;
if (scope.isCurrent()) busyAction = null;
}
}
async function deleteWorker(worker: SidebarWorker) {
if (busyAction || !canDeleteSidebarWorker(worker)) return;
const scope = actionScope();
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
try {
await deleteSidebarWorker(workspaceId, worker);
workers = workers.filter((item) => workerKey(item) !== workerKey(worker));
await deleteSidebarWorker(scope.workspaceId, worker, (input, init) => {
if (!scope.isCurrent()) throw new Error('Worker action is no longer current');
return fetch(input, init);
});
if (!scope.isCurrent()) return;
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
pushWorkspaceAlert('info', `${worker.display_name || worker.label} deleted`, {
title: 'Worker deleted',
});
} catch (cause) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
busyAction = null;
if (scope.isCurrent()) busyAction = null;
}
}
$effect(() => {
const id = workspaceId;
lifetime++;
busyAction = null;
restoreRetries = {};
expanded = false;
openWorkerKey = null;
menuElement = null;
menuTrigger = null;
const subscription = workspaceWorkersStore(workspaceId);
return subscription.subscribe((state) => {
untrack(closeWorkerMenu);
const unsubscribe = workspaceWorkersStore(id).subscribe((state) => {
loading = state.loading;
workers = state.workers.filter(canShowWorkerInSidebar);
});
return () => {
lifetime++;
unsubscribe();
};
});
</script>
@@ -221,6 +276,23 @@
</button>
{#if openWorkerKey === key}
<div class="worker-actions-menu" role="menu" aria-label={`Actions for ${label}`} bind:this={menuElement}>
<button
type="button"
role="menuitem"
disabled={busyAction !== null || !canRestoreWorker(worker) || Boolean(restoreRetries[key])}
onclick={() => restoreWorker(worker)}
>
{isBusy(worker, 'restore') ? 'Restoring…' : 'Restore'}
</button>
{#if restoreRetries[key]}
<button
type="button"
role="menuitem"
disabled={busyAction !== null}
onclick={() => restoreWorker(worker, true)}
>Retry Restore</button>
<p role="status">Restore outcome unresolved. Retry the same request to reconcile.</p>
{/if}
<button
type="button"
role="menuitem"
@@ -4,6 +4,8 @@ import type {
CleanupWorkerCandidate,
RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse,
WorkerRestoreResult,
WorkerRestoreState,
} from "#lib/generated/runtime-api.ts";
import type {
BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse,
@@ -13,7 +15,6 @@ import type {
WorkerLaunchOptionsResponse as SharedWorkerLaunchOptionsResponse,
WorkerLaunchProfileCandidate as SharedWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as SharedWorkerLaunchRuntimeOption,
WorkerLaunchWorkerSummary,
WorkerSummary as SharedWorkerSummary,
WorkerWorkdirAttachmentSummary as SharedWorkerWorkdirAttachmentSummary,
WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption,
@@ -95,18 +96,7 @@ export type Worker =
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
/** Typed result of restore across Runtime, Workspace, Web, and TUI clients. */
export type WorkerRestoreState =
| "accepted"
| "rejected"
| "rolled_back"
| "reconciliation_required";
export type WorkerRestoreResult = {
state: WorkerRestoreState;
worker?: WorkerLaunchWorkerSummary | null;
diagnostics: Diagnostic[];
};
export type { WorkerRestoreResult, WorkerRestoreState };
export type WorkerLaunchRuntimeOption = SharedWorkerLaunchRuntimeOption;
export type WorkerLaunchProfileCandidate = SharedWorkerLaunchProfileCandidate;
@@ -1,8 +1,10 @@
import { workspaceApiPath } from "#lib/workspace/api/http.ts";
import type { WorkerRestoreRequest } from "#lib/generated/runtime-api.ts";
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import {
parseRuntimeCleanupExecution,
parseRuntimeCleanupPlan,
parseRuntimeWorkerLifecycleResult,
parseWorkerRestoreResponse,
} from "#lib/workspace/api/runtime-workers.ts";
import type { Diagnostic, Worker } from "./types";
@@ -21,7 +23,7 @@ function workerPath(workspaceId: string, worker: WorkerActionTarget): string {
async function responseError(response: Response): Promise<string> {
const fallback = `${response.status} ${response.statusText}`.trim();
try {
const payload: unknown = await response.json();
const payload: unknown = await readBoundedJson(response, 64 * 1024);
if (
typeof payload !== "object" || payload === null || Array.isArray(payload)
) {
@@ -52,6 +54,134 @@ function diagnosticMessage(
fallback;
}
export function canRestoreWorker(worker: {
availability: Worker["availability"];
state?: string;
lifecycleState?: string;
restore_observation_token?: string | null;
}): boolean {
return worker.availability === "observed" &&
(worker.lifecycleState ?? worker.state) === "stopped" &&
Boolean(worker.restore_observation_token);
}
/** Immutable intent: retained unchanged for explicit uncertain-result recovery. */
export type RestoreRequest = WorkerRestoreRequest;
export function createRestoreRequest(
worker: { restore_observation_token?: string | null },
): RestoreRequest {
if (!worker.restore_observation_token) {
throw new Error(
"Worker Restore observation is unavailable; refresh Workers",
);
}
return {
expected_observation_token: worker.restore_observation_token,
request_id: crypto.randomUUID(),
};
}
export class WorkerActionHttpError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
}
}
export async function restoreWorkspaceWorker(
workspaceId: string,
worker: WorkerActionTarget,
request: RestoreRequest,
fetchFn: FetchFn = fetch,
) {
const response = await fetchFn(`${workerPath(workspaceId, worker)}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new WorkerActionHttpError(
response.status,
await responseError(response),
);
}
const result = parseWorkerRestoreResponse(
await readBoundedJson(response, 8 * 1024 * 1024),
);
if (
result.workspace_id !== workspaceId ||
result.runtime_id !== worker.runtime_id ||
result.worker_id !== worker.worker_id
) {
throw new Error(
"Worker Restore response does not match the requested target",
);
}
return result;
}
export type RestoreNotice = {
level: "info" | "warning" | "error";
title: string;
message: string;
retry: boolean;
};
/** Shared classification: neither an HTTP 200 nor a GET settles an unknown operation. */
export function restoreNotice(
result: Awaited<ReturnType<typeof restoreWorkspaceWorker>>,
): RestoreNotice {
const state = result.result.state;
if (state === "accepted") {
return {
level: "info",
title: "Worker restored",
message: "Worker restored. Open Console to continue.",
retry: false,
};
}
const pending = state === "reconciliation_required";
return {
level: pending ? "warning" : "error",
title: pending
? "Worker Restore needs reconciliation"
: "Worker Restore failed",
message: diagnosticMessage(
result.result.diagnostics,
`Worker Restore was ${state}`,
) +
(pending
? ". Outcome is unresolved. Refresh observations and explicitly retry the same Restore to reconcile; refreshing alone does not settle it."
: ""),
retry: pending,
};
}
export function restoreErrorNotice(cause: unknown): RestoreNotice {
const conflict = cause instanceof WorkerActionHttpError &&
cause.status === 409;
const uncertain = !(cause instanceof WorkerActionHttpError) ||
cause.status >= 500;
return {
level: conflict || uncertain ? "warning" : "error",
title: conflict
? "Worker observation changed"
: uncertain
? "Worker Restore outcome unknown"
: "Worker Restore failed",
message:
(cause instanceof Error
? cause.message
: "Worker Restore request failed") +
(conflict
? ". Workers refreshed; review the latest state and explicitly try again."
: uncertain
? ". Outcome is unknown. Check the latest state and explicitly retry the same Restore; no automatic retry was made."
: ""),
retry: uncertain,
};
}
export function canStopSidebarWorker(worker: {
availability: Worker["availability"];
lifecycleState: string;
@@ -9,6 +9,18 @@ declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
Deno.test("logical config attachments are never offered as process working directories", () => {
const config = { working_directory_id: "config-1", display_name: "Workspace config",
source: { kind: "workspace_config" as const, access: "read_write" as const, content_path: "/workspace-config", purpose: "Configuration" },
materializer_kind: "logical_workspace_config" as const, status: "active" as const, cleanliness: "clean" };
const form = defaultWorkerLaunchForm({ ...options, working_directories: [config] }, emptyForm({
workdir_attachments: [{ alias: "config", working_directory_id: "config-1", relative_cwd: "" }],
}));
if (form.workdir_attachments.some((attachment) => attachment.working_directory_id === "config-1")) {
throw new Error("logical config was converted to a process cwd attachment");
}
});
Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initial Workdir", () => {
const form = defaultWorkerLaunchForm(
options,
@@ -41,6 +41,7 @@ export function defaultWorkerLaunchForm(
const availableWorkingDirectories =
options?.working_directories.filter((directory) =>
directory.status === "active" &&
directory.source.kind !== "workspace_config" &&
(directory.source.kind === "external_grant" ||
directory.cleanliness === "clean") &&
directory.occupied_by == null
@@ -0,0 +1,233 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
SubscriptionFrame,
SubscriptionWorker,
} from "#lib/generated/protocol.ts";
import type { WorkspaceWorkersState } from "./worker-subscription";
const mock = vi.hoisted(() => ({ handlers: null as any }));
vi.mock("#lib/workspace/multiplexer.ts", () => ({
workspaceMultiplexer: () => ({
subscribe: (_: unknown, handlers: unknown) => {
mock.handlers = handlers;
return { close: vi.fn() };
},
}),
}));
vi.mock(
"#lib/workspace/alerts/store.ts",
() => ({ pushWorkspaceAlert: vi.fn(), dismissWorkspaceAlert: vi.fn() }),
);
import {
disposeWorkspaceWorkersStore,
refreshWorkspaceWorkers,
workspaceWorkersStore,
} from "./worker-subscription";
function summary(state = "stopped", token = "generation-1") {
return {
runtime_id: "runtime",
worker_id: "worker",
resource_key: "W-1",
host_id: "runtime",
display_name: "test",
label: "test",
availability: "observed",
state,
restore_observation_token: token,
workspace: {
visibility: "workspace",
identity: "registered",
workspace_id: "team",
},
implementation: { kind: "runtime", display_hint: "Runtime" },
pinned: true,
retention_state: "pinned",
workdir_attachments: [],
diagnostics: [],
};
}
function response(state = "stopped", token = "generation-1") {
return new Response(
JSON.stringify({
workspace_id: "team",
limit: 100,
items: [summary(state, token)],
source: "catalog",
diagnostics: [],
}),
);
}
function snapshot(
state = "stopped",
token = "generation-1",
): SubscriptionFrame {
const worker = {
worker_id: "worker",
runtime_id: "runtime",
resource_key: "W-1",
availability: "observed",
state,
restore_observation_token: token,
has_running_internal_workers: false,
workspace_id: "team",
workdir_attachments: [],
} as SubscriptionWorker;
return {
protocol_version: 2,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: "request",
subscription_id: "sub",
selector: { topic: "workspace_workers" },
snapshot: { topic: "workers", data: { workers: [worker] } },
},
},
};
}
const settle = async () => {
for (let i = 0; i < 12; i++) await Promise.resolve();
};
let unsubscribe: (() => void) | null;
beforeEach(() => {
mock.handlers = null;
unsubscribe = null;
});
afterEach(() => {
unsubscribe?.();
disposeWorkspaceWorkersStore("team");
vi.unstubAllGlobals();
});
describe("shared catalog observation refresh", () => {
it("keeps full catalog metadata while exposing catalog lifecycle separately from foreground display", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) =>
url.endsWith("/workers")
? response()
: new Response(JSON.stringify({ items: [] }))
),
);
let latest!: WorkspaceWorkersState;
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
latest = state
);
mock.handlers.onFrame(snapshot());
await settle();
expect(latest.catalogWorkers?.[0].pinned).toBe(true);
expect(latest.catalogWorkers?.[0].retention_state).toBe("pinned");
expect(latest.workers[0].lifecycleState).toBe("stopped");
expect(latest.workers[0].restore_observation_token).toBe("generation-1");
});
it("invalidates dependent plans before refresh and discards a GET superseded by a newer catalog event", async () => {
const pending: Array<(response: Response) => void> = [];
vi.stubGlobal(
"fetch",
vi.fn((url: string) =>
url.endsWith("/workers")
? new Promise<Response>((resolve) => pending.push(resolve))
: Promise.resolve(new Response(JSON.stringify({ items: [] })))
),
);
let latest!: WorkspaceWorkersState;
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
latest = state
);
mock.handlers.onFrame(snapshot());
const before = latest.observationVersion;
const old = pending.at(-1)!;
mock.handlers.onFrame(snapshot("idle", "generation-2"));
expect(latest.observationVersion).toBeGreaterThan(before);
expect(latest.workers[0].lifecycleState).toBe("idle");
pending.at(-1)!(response("idle", "generation-2"));
await settle();
old(response("stopped", "generation-1"));
await settle();
expect(latest.catalogWorkers?.[0].state).toBe("idle");
expect(latest.workers[0].restore_observation_token).toBe("generation-2");
});
it("explicit uncertain-outcome refresh is only GET and cannot settle or replay Restore", async () => {
const fetchMock = vi.fn(async (url: string) =>
url.endsWith("/workers")
? response()
: new Response(JSON.stringify({ items: [] }))
);
vi.stubGlobal("fetch", fetchMock);
let latest!: WorkspaceWorkersState;
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
latest = state
);
mock.handlers.onFrame(snapshot());
await settle();
const before = latest.observationVersion;
await refreshWorkspaceWorkers("team");
expect(latest.observationVersion).toBeGreaterThan(before);
expect(fetchMock.mock.calls.every(([url]) => !url.endsWith("/restore")))
.toBe(true);
});
it("does not filter catalog-only targets on a stream update while its metadata refresh is pending", async () => {
let hold = false;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (!url.endsWith("/workers")) {
return new Response(JSON.stringify({ items: [] }));
}
if (hold) return new Promise<Response>(() => {});
return Response.json({
workspace_id: "team",
limit: 100,
source: "catalog",
diagnostics: [],
items: [summary(), {
...summary(),
worker_id: "registry-only",
resource_key: "W-2",
implementation: {
kind: "backend_worker_registry",
display_hint: "registry",
},
}],
});
}),
);
let latest!: WorkspaceWorkersState;
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
latest = state
);
mock.handlers.onFrame(snapshot());
await settle();
expect(latest.catalogWorkers).toHaveLength(2);
hold = true;
mock.handlers.onFrame(snapshot("idle", "generation-2"));
expect(latest.catalogRefreshing).toBe(true);
expect(latest.catalogWorkers).toHaveLength(2);
expect(latest.catalogWorkers?.[1].implementation.kind).toBe(
"backend_worker_registry",
);
});
it("does not publish a late catalog GET after disposal", async () => {
let resolve!: (response: Response) => void;
vi.stubGlobal(
"fetch",
vi.fn((url: string) =>
url.endsWith("/workers")
? new Promise<Response>((r) => resolve = r)
: Promise.resolve(new Response(JSON.stringify({ items: [] })))
),
);
const seen: WorkspaceWorkersState[] = [];
unsubscribe = workspaceWorkersStore("team").subscribe((state) =>
seen.push(state)
);
const before = seen.length;
disposeWorkspaceWorkersStore("team");
resolve(response("idle", "generation-2"));
await settle();
expect(seen.length).toBe(before);
});
});
@@ -14,6 +14,10 @@ import type {
} from "#lib/generated/protocol.ts";
import WorkersNavSection from "./WorkersNavSection.svelte";
import { disposeWorkspaceWorkersStore } from "./worker-subscription";
import {
clearWorkspaceAlerts,
workspaceAlerts,
} from "#lib/workspace/alerts/store.ts";
const transport = vi.hoisted(() => ({
listener: null as { onFrame(frame: SubscriptionFrame): void } | null,
@@ -34,6 +38,8 @@ vi.mock("#lib/workspace/multiplexer.ts", () => ({
afterEach(() => {
cleanup();
disposeWorkspaceWorkersStore("stop-test");
disposeWorkspaceWorkersStore("next-test");
clearWorkspaceAlerts();
transport.listener = null;
vi.unstubAllGlobals();
});
@@ -41,6 +47,7 @@ afterEach(() => {
function snapshot(
state: SubscriptionWorker["state"],
availability: SubscriptionWorker["availability"],
workspaceId = "stop-test",
): SubscriptionFrame {
return {
protocol_version: 2,
@@ -64,8 +71,9 @@ function snapshot(
worker_state: null,
has_running_internal_workers: false,
display_name: "Test worker",
workspace_id: "stop-test",
workspace_id: workspaceId,
workdir_attachments: [],
...{ restore_observation_token: "observation-1" },
}],
},
},
@@ -107,6 +115,14 @@ test.each(["idle", "running", "paused", "stopped"] as const)(
await fireEvent.click(
screen.getByRole("button", { name: "Actions for Test worker" }),
);
await waitFor(() =>
expect(
(screen.getByRole("menuitem", { name: "Stop" }) as HTMLButtonElement)
.disabled,
).toBe(false)
);
// A successful POST is not a lifecycle observation. Only a newer authoritative frame disables Stop.
transport.listener!.onFrame(snapshot("stopped", "observed"));
await waitFor(() =>
expect(
(screen.getByRole("menuitem", { name: "Stop" }) as HTMLButtonElement)
@@ -132,3 +148,249 @@ test("Unavailable observation disables Stop even when catalog state is running",
.disabled,
).toBe(true);
});
test.each(["switch", "dispose"] as const)(
"late Stop completion is ignored after workspace %s",
async (mode) => {
let resolve!: (response: Response) => void;
const pending = new Promise<Response>((done) => {
resolve = done;
});
const request = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/stop")
? pending
: Promise.resolve(new Response(null, { status: 404 }))
);
vi.stubGlobal("fetch", request);
const view = render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot("running", "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
await fireEvent.click(screen.getByRole("menuitem", { name: "Stop" }));
const operation = request.mock.calls.find(([url]) =>
String(url).endsWith("/stop")
);
expect(String(operation?.[0])).toContain("/api/w/stop-test/");
if (mode === "switch") {
await view.rerender({ workspaceId: "next-test" });
transport.listener!.onFrame(snapshot("idle", "observed", "next-test"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
} else view.unmount();
clearWorkspaceAlerts();
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const readsBeforeCompletion =
request.mock.calls.filter(([url]) => String(url).endsWith("/workers"))
.length;
resolve(
Response.json({
state: "accepted",
runtime_id: "runtime-a",
worker_id: "worker-a",
diagnostics: [],
}),
);
await pending;
await new Promise((done) => setTimeout(done, 0));
expect(alerts).toEqual([]);
expect(
request.mock.calls.filter(([url]) => String(url).endsWith("/workers"))
.length,
).toBe(readsBeforeCompletion);
if (mode === "switch") {
expect(
(screen.getByRole("menuitem", { name: "Stop" }) as HTMLButtonElement)
.disabled,
).toBe(false);
}
unsubscribe();
},
);
test.each(["idle", "running", "paused", "stopped"] as const)(
"sidebar Restore eligibility (%s)",
async (state) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 404 })),
);
render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot(state, "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
expect(
(screen.getByRole("menuitem", { name: "Restore" }) as HTMLButtonElement)
.disabled,
).toBe(state !== "stopped");
},
);
test("sidebar Restore pins the target and preserves an uncertain request for explicit retry", async () => {
let attempts = 0;
const request = vi.fn(
async (input: RequestInfo | URL, _init?: RequestInit) => {
if (!String(input).endsWith("/restore")) {
return new Response(null, { status: 404 });
}
return Response.json({
workspace_id: "stop-test",
runtime_id: "runtime-a",
worker_id: "worker-a",
result: {
state: ++attempts === 1 ? "reconciliation_required" : "accepted",
diagnostics: [],
},
});
},
);
vi.stubGlobal("fetch", request);
render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot("stopped", "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
await fireEvent.click(screen.getByRole("menuitem", { name: "Restore" }));
await waitFor(() => expect(attempts).toBe(1));
await fireEvent.click(
screen.getByRole("button", { name: "Actions for Test worker" }),
);
const retry = await screen.findByRole("menuitem", { name: "Retry Restore" });
await waitFor(() =>
expect((retry as HTMLButtonElement).disabled).toBe(false)
);
expect(
(screen.getByRole("menuitem", { name: "Restore" }) as HTMLButtonElement)
.disabled,
).toBe(true);
await fireEvent.click(retry);
await waitFor(() => expect(attempts).toBe(2));
const operations = request.mock.calls.filter(([url]) =>
String(url).endsWith("/restore")
);
expect(String(operations[0][0])).toContain(
"/api/w/stop-test/runtimes/runtime-a/workers/worker-a/restore",
);
expect(operations[1][1]?.body).toBe(operations[0][1]?.body);
});
test.each(["switch", "dispose"] as const)(
"late sidebar Restore completion is ignored after %s",
async (mode) => {
let resolve!: (response: Response) => void;
const pending = new Promise<Response>((done) => {
resolve = done;
});
const request = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/restore")
? pending
: Promise.resolve(new Response(null, { status: 404 }))
);
vi.stubGlobal("fetch", request);
const view = render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot("stopped", "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
await fireEvent.click(screen.getByRole("menuitem", { name: "Restore" }));
if (mode === "switch") {
await view.rerender({ workspaceId: "next-test" });
transport.listener!.onFrame(snapshot("stopped", "observed", "next-test"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
} else view.unmount();
clearWorkspaceAlerts();
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const readsBeforeCompletion =
request.mock.calls.filter(([url]) => String(url).endsWith("/workers"))
.length;
resolve(
Response.json({
workspace_id: "stop-test",
runtime_id: "runtime-a",
worker_id: "worker-a",
result: { state: "accepted", diagnostics: [] },
}),
);
await pending;
await new Promise((done) => setTimeout(done, 0));
expect(alerts).toEqual([]);
expect(
request.mock.calls.filter(([url]) => String(url).endsWith("/workers"))
.length,
).toBe(readsBeforeCompletion);
if (mode === "switch") {
expect(
(screen.getByRole("menuitem", { name: "Restore" }) as HTMLButtonElement)
.disabled,
).toBe(false);
}
unsubscribe();
},
);
test("sidebar Delete cannot proceed to execution when its cleanup plan arrives after workspace switch", async () => {
let resolve!: (response: Response) => void;
const pending = new Promise<Response>((done) => {
resolve = done;
});
const request = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/cleanup-plan")
? pending
: Promise.resolve(new Response(null, { status: 404 }))
);
vi.stubGlobal("fetch", request);
const view = render(WorkersNavSection, { workspaceId: "stop-test" });
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(snapshot("stopped", "observed"));
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Test worker" }),
);
await fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(
request.mock.calls.some(([url]) => String(url).endsWith("/cleanup-plan")),
).toBe(true);
await view.rerender({ workspaceId: "next-test" });
clearWorkspaceAlerts();
resolve(Response.json({
workspace_id: "stop-test",
runtime_id: "runtime-a",
generated_at: "2026-10-06T00:00:00Z",
revision: "old-plan",
digest: "old-digest",
workdirs: [],
diagnostics: [],
workers: [{
target_id: "worker-target",
action: "worker_delete",
worker_id: "W-1",
runtime_worker_id: "worker-a",
runtime_id: "runtime-a",
reason: "stopped",
pinned: false,
retention_state: "normal",
linked_workdir_ids: [],
running_linked: false,
}],
}));
await pending;
await new Promise((done) => setTimeout(done, 0));
expect(
request.mock.calls.some(([url]) =>
String(url).endsWith("/cleanup-executions")
),
).toBe(false);
});
@@ -65,6 +65,9 @@ beforeEach(() => {
);
return Response.json({
workspace_id: workspaceId,
...(String(input).endsWith("/workers")
? { limit: 100, source: "catalog" }
: {}),
items: [],
diagnostics: [],
});
@@ -283,7 +286,17 @@ test("terminal subscription rejection is shown only through WorkspaceAlerts", as
test("Workdir fetch failure preserves Worker updates and uses a separate stable alert", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("Workdir lookup failed", { status: 503 })),
vi.fn(async (input: RequestInfo | URL) =>
String(input).endsWith("/workers")
? Response.json({
workspace_id: "workspace-a",
limit: 100,
source: "catalog",
items: [],
diagnostics: [],
})
: new Response("Workdir lookup failed", { status: 503 })
),
);
mount();
await waitFor(() => expect(transport.subscribe).toHaveBeenCalledOnce());
@@ -6,6 +6,7 @@ import {
pushWorkspaceAlert,
} from "#lib/workspace/alerts/store.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkerListResponse } from "#lib/workspace/api/workers.ts";
import { parseWorkingDirectoryListResponse } from "#lib/workspace/api/workdirs.ts";
import {
workspaceMultiplexer,
@@ -31,11 +32,17 @@ export type SidebarWorker = Omit<Worker, "workdir_attachments"> & {
export type WorkspaceWorkersState = {
loading: boolean;
workers: SidebarWorker[];
/** Full catalog metadata; null until fetched. Never substitutes sidebar display state. */
catalogWorkers: Worker[] | null;
/** Changes immediately when an observation invalidates dependent cleanup plans. */
observationVersion: number;
catalogRefreshing: boolean;
};
type WorkerAlertKind = "subscription" | "workdirs";
type WorkerAlertKind = "subscription" | "workdirs" | "catalog";
type WorkspaceWorkersStoreEntry = {
store: Readable<WorkspaceWorkersState>;
refresh(): Promise<void>;
dispose(): void;
};
@@ -55,6 +62,8 @@ function reportWorkerFailure(
id: workerAlertId(workspaceId, kind),
title: kind === "workdirs"
? "Worker Workdirs unavailable"
: kind === "catalog"
? "Worker catalog unavailable"
: "Worker updates unavailable",
});
}
@@ -66,6 +75,7 @@ function clearWorkerFailure(workspaceId: string, kind: WorkerAlertKind): void {
function clearWorkerFailures(workspaceId: string): void {
clearWorkerFailure(workspaceId, "subscription");
clearWorkerFailure(workspaceId, "workdirs");
clearWorkerFailure(workspaceId, "catalog");
}
export function disposeWorkspaceWorkersStore(workspaceId: string): void {
@@ -78,6 +88,13 @@ export function disposeWorkspaceWorkersStore(workspaceId: string): void {
entry.dispose();
}
/** Refresh observations only; this does not POST or complete pending reconciliation. */
export async function refreshWorkspaceWorkers(
workspaceId: string,
): Promise<void> {
await stores.get(workspaceId)?.refresh();
}
export function workspaceWorkersStore(
workspaceId: string,
): Readable<WorkspaceWorkersState> {
@@ -85,11 +102,24 @@ export function workspaceWorkersStore(
if (cached) return cached.store;
let stopActive: (() => void) | null = null;
let refreshActive: (() => Promise<void>) | null = null;
const store = readable<WorkspaceWorkersState>(
{ loading: true, workers: [] },
{
loading: true,
workers: [],
catalogWorkers: null,
observationVersion: 0,
catalogRefreshing: true,
},
(set) => {
if (!workspaceId) {
set({ loading: false, workers: [] });
set({
loading: false,
workers: [],
catalogWorkers: [],
observationVersion: 0,
catalogRefreshing: false,
});
return;
}
const projection = createWorkspaceWorkersProjection();
@@ -99,13 +129,24 @@ export function workspaceWorkersStore(
let workers: SidebarWorker[] = [];
let loading = true;
let disposed = false;
let catalogWorkers: Worker[] | null = null;
let observationVersion = 0;
let catalogRefreshing = true;
let refreshSequence = 0;
let catalogAbort: AbortController | null = null;
const publish = (): boolean => {
if (disposed) return false;
try {
workers = [...projection.workers.values()]
.map((worker) => projectWorker(worker, workdirs))
.sort(compareWorkersForSidebar);
set({ loading, workers });
set({
loading,
workers,
catalogWorkers,
observationVersion,
catalogRefreshing,
});
return true;
} catch (cause) {
reportWorkerFailure(
@@ -115,10 +156,86 @@ export function workspaceWorkersStore(
? cause.message
: "Invalid Worker subscription frame",
);
set({ loading: false, workers });
set({
loading: false,
workers,
catalogWorkers,
observationVersion,
catalogRefreshing,
});
return false;
}
};
const refreshCatalog = async () => {
if (disposed) return;
const sequence = ++refreshSequence;
const version = ++observationVersion;
catalogAbort?.abort();
const abort = new AbortController();
catalogAbort = abort;
catalogRefreshing = true;
publish(); // Invalidate cleanup plans before any request can settle.
const result = await loadJson(
fetch,
workspaceApiPath(workspaceId, "/workers"),
{ signal: abort.signal },
parseWorkerListResponse,
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
);
if (
disposed || sequence !== refreshSequence ||
version !== observationVersion
) return;
if (!result.data || result.data.workspace_id !== workspaceId) {
reportWorkerFailure(
workspaceId,
"catalog",
result.error ?? "Worker catalog identity mismatch",
"warning",
);
return;
}
catalogWorkers = result.data.items;
catalogRefreshing = false;
// GET is a new observation, never a cached POST response. Existing
// subscription-only fields (Job/Internal activity) retain their owners.
for (const summary of catalogWorkers) {
const key = JSON.stringify([summary.runtime_id, summary.worker_id]);
const current = projection.workers.get(key);
if (current) {
projection.workers.set(key, {
...current,
availability: summary.availability,
state: summary.state as SubscriptionWorker["state"],
worker_state: summary.worker_state,
restore_observation_token: summary.restore_observation_token,
});
}
}
clearWorkerFailure(workspaceId, "catalog");
publish();
};
refreshActive = refreshCatalog;
const observeCatalog = () => {
if (catalogWorkers !== null) {
catalogWorkers = catalogWorkers.map((worker) => {
const latest = projection.workers.get(
JSON.stringify([worker.runtime_id, worker.worker_id]),
);
// The catalog can contain targets absent from the sidebar stream;
// absence is not a new filtering rule for the Workers page.
if (!latest) return worker;
return {
...worker,
availability: latest.availability,
state: latest.state,
worker_state: latest.worker_state,
restore_observation_token: latest.restore_observation_token,
};
});
}
void refreshCatalog();
};
const loadWorkdirs = async () => {
const result = await loadJson(
fetch,
@@ -183,6 +300,13 @@ export function workspaceWorkersStore(
try {
applyWorkspaceWorkersFrame(projection, frame);
loading = false;
if (
(frame.frame === "response" &&
frame.message.result === "subscribed") ||
(frame.frame === "event" && frame.message.event === "event" &&
frame.message.data.subscription_id ===
projection.subscriptionId)
) observeCatalog();
if (publish()) clearWorkerFailure(workspaceId, "subscription");
} catch (cause) {
loading = false;
@@ -216,11 +340,14 @@ export function workspaceWorkersStore(
},
);
void loadWorkdirs();
void refreshCatalog();
const stop = () => {
if (disposed) return;
disposed = true;
workdirRequest.abort();
catalogAbort?.abort();
if (refreshActive === refreshCatalog) refreshActive = null;
subscription?.close();
clearWorkerFailures(workspaceId);
if (stopActive === stop) stopActive = null;
@@ -231,6 +358,9 @@ export function workspaceWorkersStore(
);
const entry: WorkspaceWorkersStoreEntry = {
store,
refresh: async () => {
await refreshActive?.();
},
dispose: () => {
stopActive?.();
clearWorkerFailures(workspaceId);
@@ -268,6 +398,7 @@ function projectWorker(
availability: worker.availability,
state: liveWorkerState(worker),
lifecycleState: worker.state,
restore_observation_token: worker.restore_observation_token,
worker_state: worker.worker_state,
pinned: false,
retention_state: "transient",
@@ -47,6 +47,18 @@ function attachment(
};
}
Deno.test("logical config sidebar label is not fabricated as a repository revision", () => {
const meta = sidebarWorkdirMeta([attachment("workspace-config", {
display_name: "Workspace configuration",
source: { kind: "workspace_config", access: "read_write", content_path: "/workspace-config", purpose: "Configuration" },
materializer_kind: "logical_workspace_config",
})]);
assertEquals(meta.text, "Workspace configuration");
assert(!meta.text.includes("unknown-repo"), "config must not be labeled as a repository");
assert(!meta.text.includes("editable"), "catalog grant ceiling must not claim effective connection editability");
assert(meta.details.includes("workspace-config — Workspace configuration"), "logical alias remains visible");
});
Deno.test("Worker sidebar Workdir label prefers the current branch selector", () => {
const meta = sidebarWorkdirMeta([
attachment("checkout", {
@@ -27,6 +27,11 @@ export function sidebarWorkdirMeta(
function attachmentLabel(attachment: SidebarWorkdirAttachment): string {
const workdir = attachment.working_directory;
if (workdir?.source.kind === "workspace_config") {
// Catalog access is the grant ceiling, not this connection's effective
// restriction. The invocation result reports effective attachment access.
return clean(workdir.display_name) ?? "Workspace config";
}
if (workdir?.source.kind === "external_grant") {
return clean(workdir.display_name) ?? "external";
}
@@ -14,13 +14,16 @@
const subjects = $derived(data.subjects.data?.items ?? []);
let createExpanded = $state(false);
let role = $state('');
let behaviorMd = $state('');
let submitting = $state(false);
let createError = $state<string | null>(null);
let roleInvalid = $state(false);
let behaviorInvalid = $state(false);
let createOutcomeUnknown = $state(false);
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>();
let behaviorInput = $state<HTMLTextAreaElement>();
function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
@@ -31,10 +34,15 @@
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
return state === 'active' ? 'Available' : 'Retired';
}
async function openCreateForm(): Promise<void> {
createExpanded = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
@@ -45,8 +53,10 @@
if (submitting) return;
createExpanded = false;
role = '';
behaviorMd = '';
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
@@ -58,9 +68,13 @@
submitting = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
role,
behavior_md: behaviorMd,
});
createdSubject = subject;
try {
await goto(subjectHref(subject.id));
@@ -69,14 +83,16 @@
}
} catch (error) {
if (error instanceof SubjektivSubjectCreateError) {
roleInvalid = error.kind === 'validation';
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
createOutcomeUnknown = error.kind === 'unknown_outcome';
createError = error.status === 401 || error.status === 403
? 'You do not have permission to create Subjects in this Workspace.'
: error.message;
if (roleInvalid) {
if (roleInvalid || behaviorInvalid) {
await tick();
roleInput?.focus();
if (behaviorInvalid) behaviorInput?.focus();
else roleInput?.focus();
}
} else {
createOutcomeUnknown = true;
@@ -109,7 +125,7 @@
</div>
<div class="memory-header-actions">
{#if data.subjects.data}
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
<span class="memory-count">{subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''}</span>
{/if}
<button
bind:this={createButton}
@@ -145,9 +161,22 @@
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={roleInvalid ? 'true' : undefined}
aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'}
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
/>
</label>
<label class="subject-role-field">
<span>Behavior <small>Optional, user-managed</small></span>
<textarea
bind:this={behaviorInput}
bind:value={behaviorMd}
rows="7"
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={behaviorInvalid ? 'true' : undefined}
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
></textarea>
<small id="subject-behavior-help">Persistent guidance injected verbatim for this Subject. It remains separate from generated Memory and can be edited later.</small>
</label>
{#if createError}
<div
id="subject-create-error"
@@ -184,14 +213,19 @@
{#each subjects as subject (subject.id)}
<a class="subject-row" href={subjectHref(subject.id)}>
<div class="subject-copy">
<span class="memory-state-pill is-{subject.state}">{subject.state}</span>
<span class="memory-state-pill is-{subject.state}">{subjectStateLabel(subject.state)}</span>
<h2>{subject.role}</h2>
<code title={subject.id}>{subject.id}</code>
</div>
<dl class="subject-meta">
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
<div>
<dt>Worker connection</dt>
<dd>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
</dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
@@ -309,20 +343,25 @@
}
.subject-create-actions {
grid-column: 3;
grid-row: 1;
grid-column: 2;
}
.subject-create-panel {
display: grid;
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
align-items: end;
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
align-items: start;
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
border-radius: var(--radius-soft);
}
.subject-create-copy {
grid-column: 1;
grid-row: 1 / span 4;
align-self: start;
}
.subject-create-copy h2,
.subject-create-copy p,
.subject-create-error p {
@@ -344,7 +383,8 @@
font-weight: 700;
}
.subject-role-field input {
.subject-role-field input,
.subject-role-field textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
@@ -354,7 +394,15 @@
color: var(--text-strong);
}
.subject-role-field input[aria-invalid='true'] {
.subject-role-field textarea {
min-height: 8rem;
resize: vertical;
font: inherit;
line-height: 1.5;
}
.subject-role-field input[aria-invalid='true'],
.subject-role-field textarea[aria-invalid='true'] {
border-color: var(--danger);
}
@@ -426,8 +474,8 @@
.subject-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
align-self: center;
}
@@ -441,12 +489,19 @@
}
.subject-meta dd {
display: grid;
gap: var(--space-1);
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
overflow-wrap: anywhere;
}
.subject-meta dd strong {
color: var(--text);
font-weight: 700;
}
.memory-state {
padding: var(--space-5) 0;
color: var(--text-muted);
@@ -480,6 +535,7 @@
align-items: stretch;
}
.subject-create-copy,
.subject-create-error,
.subject-create-actions {
grid-column: 1;
@@ -1,11 +1,91 @@
<script lang="ts">
import { untrack } from 'svelte';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import type { SubjektivMemoryState } from '#lib/generated/memory-api.ts';
import {
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let subject = $state(untrack(() => data.subject.data));
let editingBehavior = $state(false);
let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? ''));
let behaviorSaving = $state(false);
let behaviorError = $state<string | null>(null);
let behaviorSaveConfirmed = $state(false);
let routeGeneration = 0;
let subjectWorkspaceId = untrack(() => data.workspaceId);
// SvelteKit reuses this page across Subject and Workspace routes. A new
// loader snapshot owns a new editor and fences all previous mutations.
$effect(() => {
const workspaceId = data.workspaceId;
const subjectId = data.subjectId;
const loadedSubject = data.subject.data;
untrack(() => {
routeGeneration += 1;
subjectWorkspaceId = workspaceId;
subject = loadedSubject?.id === subjectId ? loadedSubject : null;
behaviorDraft = subject?.behavior_md ?? '';
editingBehavior = false;
behaviorSaving = false;
behaviorError = null;
behaviorSaveConfirmed = false;
});
});
async function saveBehavior(): Promise<void> {
if (!subject || behaviorSaving || subject.id !== data.subjectId || subjectWorkspaceId !== data.workspaceId) return;
const generation = routeGeneration;
const workspaceId = subjectWorkspaceId;
const subjectId = subject.id;
const request = {
expected_behavior_revision: subject.behavior_revision,
behavior_md: behaviorDraft,
};
const isCurrent = () => generation === routeGeneration &&
data.workspaceId === workspaceId && data.subjectId === subjectId;
behaviorSaving = true;
behaviorError = null;
behaviorSaveConfirmed = false;
try {
const updated = await updateSubjektivSubjectBehavior(fetch, workspaceId, subjectId, request);
if (!isCurrent()) return;
subject = updated;
behaviorDraft = updated.behavior_md;
editingBehavior = false;
behaviorSaveConfirmed = true;
} catch (error) {
if (!isCurrent()) return;
behaviorError = error instanceof SubjektivSubjectCreateError
? (error.status === 401 || error.status === 403
? 'You do not have permission to edit this Subject.'
: error.message)
: 'The save outcome is unknown. Reload the Subject before retrying.';
} finally {
if (isCurrent()) behaviorSaving = false;
}
}
function beginBehaviorEdit(): void {
if (!subject) return;
behaviorDraft = subject.behavior_md;
behaviorError = null;
behaviorSaveConfirmed = false;
editingBehavior = true;
}
function cancelBehaviorEdit(): void {
behaviorDraft = subject?.behavior_md ?? '';
behaviorError = null;
editingBehavior = false;
}
const surface = $derived(data.surface.data);
const snapshot = $derived(surface?.snapshot ?? null);
const memories = $derived(data.memories.data?.items ?? []);
@@ -36,10 +116,28 @@
function kindLabel(kind: string): string {
return kind.replaceAll('_', ' ');
}
function surfaceLabel(availability: SubjektivResidentSurfaceAvailability): string {
if (availability === 'ready') return snapshot?.body_md.trim() ? 'Ready to use' : 'Ready, no context';
if (availability === 'ungenerated') return 'Not generated';
if (availability === 'stale') return 'Needs refresh';
return 'Generation failed';
}
function surfaceSummary(availability: SubjektivResidentSurfaceAvailability): string {
if (availability !== 'ready') return 'No current generated context is available to use.';
return snapshot?.body_md.trim()
? 'Current generated context is available.'
: 'A current empty surface exists; there is no generated Memory context to show.';
}
function subjectStateLabel(state: 'active' | 'retired'): string {
return state === 'active' ? 'Available' : 'Retired';
}
</script>
<svelte:head>
<title>{data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
<title>{subject?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
<meta name="description" content="Subject resident surface and current committed Memories" />
</svelte:head>
@@ -49,20 +147,57 @@
<header class="memory-page-header">
<div>
<p class="memory-eyebrow">Memory subject</p>
<h1 id="subject-heading">{data.subject.data?.role ?? data.subjectId}</h1>
<h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
</div>
<span class="read-only-label">Read-only</span>
<span class="read-only-label">Behavior editable</span>
</header>
{#if data.subject.data}
<dl class="subject-facts" aria-label="Subject details">
<div><dt>Role</dt><dd>{data.subject.data.role}</dd></div>
<div><dt>State</dt><dd><span class="subject-state is-{data.subject.data.state}">{data.subject.data.state}</span></dd></div>
<div><dt>Store revision</dt><dd>{data.subject.data.store_revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
<div><dt>Current worker</dt><dd>{data.subject.data.current_worker?.display_name ?? 'None'}</dd></div>
</dl>
{#if subject}
<section class="subject-overview" aria-label="Subject status">
<div>
<span>Subject status</span>
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
</div>
<div>
<span>Worker connection</span>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
</div>
<div>
<span>Committed Memories</span>
{#if data.memories.data}
<strong>{data.cursor ? `${memories.length} on this page` : memories.length === 0 ? 'None' : 'Available'}</strong>
<p>{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</p>
{:else}
<strong>Unavailable</strong>
<p>The current Memory list could not be read.</p>
{/if}
</div>
<div>
<span>Generated Memory context</span>
{#if surface}
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
<p>{surfaceSummary(surface.availability)}</p>
{:else}
<strong>Unavailable</strong>
<p>The resident context status could not be read.</p>
{/if}
</div>
</section>
<details class="subject-technical-details">
<summary>Technical details</summary>
<dl>
<div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
<div>
<dt>Subject store revision</dt>
<dd>{subject.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</details>
{:else if data.subject.error}
<div class="memory-state is-error" role="alert">
<strong>Subject details unavailable.</strong>
@@ -72,41 +207,77 @@
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
{/if}
{#if subject}
<section class="behavior-section" aria-labelledby="subject-behavior-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">User-managed context</p>
<h2 id="subject-behavior-heading">Behavior</h2>
</div>
{#if !editingBehavior}
<button type="button" class="behavior-edit" onclick={beginBehaviorEdit}>Edit</button>
{/if}
</header>
<p class="behavior-help">The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest revision before later model requests; this page does not report that application.</p>
{#if editingBehavior}
<form class="behavior-form" aria-busy={behaviorSaving} onsubmit={(event) => { event.preventDefault(); void saveBehavior(); }}>
<label>
<span class="sr-only">Subject behavior</span>
<textarea bind:value={behaviorDraft} rows="9" disabled={behaviorSaving} aria-invalid={behaviorError ? 'true' : undefined} aria-describedby={behaviorError ? 'subject-behavior-edit-error' : undefined}></textarea>
</label>
{#if behaviorError}<p id="subject-behavior-edit-error" class="behavior-error" role="alert">{behaviorError}</p>{/if}
<div class="behavior-actions">
<button type="submit" disabled={behaviorSaving}>{behaviorSaving ? 'Saving…' : 'Save behavior'}</button>
<button type="button" disabled={behaviorSaving} onclick={cancelBehaviorEdit}>Cancel</button>
<button type="button" disabled={behaviorSaving || behaviorDraft.length === 0} onclick={() => { behaviorDraft = ''; }}>Clear</button>
</div>
</form>
{:else if subject.behavior_md.length > 0}
<article class="behavior-document" aria-label="User-managed Subject behavior"><pre>{subject.behavior_md}</pre></article>
{:else}
<div class="memory-state" role="status"><strong>No behavior is set.</strong><p>Connected Workers receive an explicit empty behavior document.</p></div>
{/if}
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed at revision {subject.behavior_revision}. This page does not confirm application by a connected Worker.</p>{/if}
</section>
{/if}
<section class="surface-section" aria-labelledby="resident-surface-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">Resident context</p>
<h2 id="resident-surface-heading">Resident surface</h2>
<p class="memory-eyebrow">Generated from committed Memory</p>
<h2 id="resident-surface-heading">Resident context</h2>
</div>
{#if surface}
<span class="availability is-{surface.availability}">{surface.availability}</span>
<span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
{/if}
</header>
{#if surface?.availability === 'ready' && snapshot}
<div class="surface-meta">
<span>Built from store revision {snapshot.built_from_store_revision}</span>
<span>Generated <time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time></span>
<span aria-hidden="true">·</span>
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time>
<span aria-hidden="true">·</span>
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
</div>
{#if snapshot.body_md.trim().length === 0}
<div class="memory-state" role="status" data-surface-ready-empty>
<strong>Resident surface is ready and empty.</strong>
<p>This subject currently has no resident context to display.</p>
<strong>Resident context is current but empty.</strong>
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
</div>
{:else}
<article class="surface-document" aria-label="Resident surface">
<article class="surface-document" aria-label="Resident context">
<DocumentMarkdown text={snapshot.body_md} />
</article>
{/if}
<details class="surface-details">
<summary>Surface provenance</summary>
<summary>Surface sources and diagnostics</summary>
<dl>
<div><dt>Snapshot id</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
<div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
<div>
<dt>Memory refs</dt>
<dt>Generated from Subject revision</dt>
<dd>{snapshot.built_from_store_revision}<small>The Subject change number used for this surface, distinct from each Memory’s revision.</small></dd>
</div>
<div>
<dt>Referenced Memory revisions</dt>
<dd>
{#if snapshot.memory_refs.length === 0}
None
@@ -123,26 +294,26 @@
</details>
{:else if surface?.availability === 'ungenerated'}
<div class="memory-state" role="status">
<strong>Resident surface has not been generated.</strong>
<p>Committed Memories remain available below.</p>
<strong>Resident context has not been generated.</strong>
<p>Committed Memories, if any, remain available below.</p>
</div>
{:else if surface?.availability === 'stale'}
<div class="memory-state is-warning" role="status">
<strong>Resident surface is stale.</strong>
<p>The latest committed Memories are not represented by a publishable snapshot.</p>
<strong>Resident context needs to be refreshed.</strong>
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
</div>
{:else if surface?.availability === 'failed'}
<div class="memory-state is-error" role="alert">
<strong>Resident surface generation failed.</strong>
<p>No failed or partial snapshot is displayed.</p>
<strong>Resident context generation failed.</strong>
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
</div>
{:else if data.surface.error}
<div class="memory-state is-error" role="alert">
<strong>Resident surface unavailable.</strong>
<strong>Resident context status unavailable.</strong>
<p>{data.surface.error}</p>
</div>
{:else}
<div class="memory-state" role="status"><p>Resident surface data is unavailable.</p></div>
<div class="memory-state" role="status"><p>Resident context status is unavailable.</p></div>
{/if}
</section>
@@ -152,14 +323,14 @@
<p class="memory-eyebrow">Committed records</p>
<h2 id="current-memories-heading">Current Memories</h2>
</div>
{#if data.memories.data}<span class="memory-count">{memories.length}{data.memories.data.has_more ? '+' : ''}</span>{/if}
{#if data.memories.data}<span class="memory-count">{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
{#if data.memories.data}
{#if memories.length === 0}
<div class="memory-state" role="status" data-memories-empty>
<strong>No committed Memories.</strong>
<p>This subject has no current Memory revisions.</p>
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
<p>{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}</p>
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
@@ -175,9 +346,9 @@
<p>{memory.excerpt || 'No excerpt.'}</p>
</div>
<dl>
<div><dt>Memory id</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
<div><dt>Revision</dt><dd>{memory.revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
<div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
<div><dt>Memory revision</dt><dd>{memory.revision}</dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
@@ -270,25 +441,79 @@
font-size: var(--font-size-compact);
}
.subject-facts {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
.subject-overview {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
border-radius: var(--radius-soft);
}
.subject-facts > div {
display: block;
.subject-overview > div {
display: grid;
align-content: start;
gap: var(--space-1);
min-width: 0;
}
.subject-facts dt {
white-space: normal;
.subject-overview span,
.subject-overview p,
.subject-technical-details summary,
.subject-technical-details small,
.surface-details small {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.subject-facts dd {
margin-top: var(--space-1);
color: var(--text);
.subject-overview strong {
color: var(--text-strong);
overflow-wrap: anywhere;
}
.subject-overview p {
margin: 0;
overflow-wrap: anywhere;
}
.subject-technical-details {
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--line);
}
.subject-technical-details summary {
width: fit-content;
cursor: pointer;
font-weight: 700;
}
.subject-technical-details dl,
.surface-details dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-3);
}
.subject-technical-details dl > div,
.surface-details dl > div {
display: block;
min-width: 0;
}
.subject-technical-details dd,
.surface-details dd {
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
.subject-technical-details small,
.surface-details small {
display: block;
margin-top: var(--space-1);
line-height: 1.4;
}
.subject-state,
.availability,
.memory-pill,
@@ -320,6 +545,7 @@
color: var(--danger);
}
.behavior-section,
.surface-section,
.memories-section {
display: grid;
@@ -328,6 +554,78 @@
padding-top: var(--space-3);
}
.behavior-help,
.behavior-saved,
.behavior-error {
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.behavior-error { color: var(--danger); }
.behavior-saved { color: var(--success); }
.behavior-form,
.behavior-form label {
display: grid;
gap: var(--space-3);
}
.behavior-form textarea {
width: 100%;
min-height: 10rem;
padding: var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.5;
resize: vertical;
}
.behavior-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.behavior-edit,
.behavior-actions button {
padding: var(--space-2) var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
cursor: pointer;
}
.behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; }
.behavior-document pre {
margin: 0;
padding: var(--space-4);
overflow-wrap: anywhere;
white-space: pre-wrap;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.surface-meta,
.pill-row {
display: flex;
@@ -455,7 +753,7 @@
}
@media (max-width: 900px) {
.subject-facts {
.subject-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -472,10 +770,16 @@
gap: var(--space-2);
}
.subject-facts {
.subject-overview,
.subject-technical-details dl,
.surface-details dl {
grid-template-columns: minmax(0, 1fr);
}
.subject-overview {
padding: var(--space-3);
}
.memory-row {
padding-inline: 0;
}
@@ -63,6 +63,11 @@
return value.replaceAll('_', ' ');
}
function titleLabel(value: string): string {
const normalized = label(value);
return normalized[0].toUpperCase() + normalized.slice(1);
}
function rangeLabel(range: [number, number] | null | undefined): string {
return range ? `${range[0]}–${range[1]}` : 'None';
}
@@ -98,8 +103,8 @@
<header class="memory-page-header">
<div>
<div class="pill-row">
<span class="memory-pill is-{memory.state}">{memory.state}</span>
<span class="memory-kind">{label(memory.kind)}</span>
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
<span class="memory-kind">{titleLabel(memory.kind)}</span>
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
</div>
<h1 id="memory-detail-heading">{memory.claim}</h1>
@@ -109,11 +114,11 @@
</header>
<dl class="memory-facts" aria-label="Memory details">
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
<div><dt>State</dt><dd>{memory.state}</dd></div>
<div><dt>Kind</dt><dd>{label(memory.kind)}</dd></div>
<div><dt>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
<div><dt>Type</dt><dd>{titleLabel(memory.kind)}</dd></div>
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
@@ -140,7 +145,7 @@
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
<header class="section-heading">
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
<span>{memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'}</span>
<span>{memory.source_candidates.length} shown</span>
</header>
{#if memory.source_candidates.length === 0}
@@ -226,7 +231,11 @@
</section>
<section class="detail-section revision-section" aria-labelledby="revision-heading">
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header>
<header class="section-heading">
<div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>
{#if data.revisions.data}<span>{revisions.length} shown{data.revisions.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
<p class="section-intro">These are versions of this Memory record, separate from the Subject’s internal update number.</p>
{#if data.revisions.data}
{#if revisions.length === 0}
<div>
@@ -238,7 +247,7 @@
{#each revisions as revision (revision.revision)}
<li class:current={revision.revision === memory.revision}>
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
<div><strong>Revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{revision.state}</span><span class="memory-kind">{label(revision.kind)}</span></div>
<div><strong>Memory revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{titleLabel(revision.state)}</span><span class="memory-kind">{titleLabel(revision.kind)}</span></div>
<p>{revision.claim}</p>
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
</a>
@@ -386,6 +395,7 @@
}
.memory-facts {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
}
@@ -414,6 +424,7 @@
.why-useful,
.staleness,
.change-reason,
.section-intro,
.empty-copy,
.bounded-note {
margin: 0;
@@ -34,6 +34,8 @@ function subject() {
return {
id: "subject-1",
role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active" as const,
store_revision: 9,
created_at: "2026-01-01T00:00:00Z",
@@ -73,6 +75,9 @@ test("renders the subject index as the Memory product entry", () => {
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
.toBeNull();
expect(screen.getByText("subject-1")).not.toBeNull();
expect(screen.getByText("Not connected")).not.toBeNull();
expect(screen.queryByText("Store revision", { exact: true })).toBeNull();
expect(screen.getByText("1 shown · more available")).not.toBeNull();
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
.toBe("/w/workspace-1/memory");
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
@@ -96,6 +101,12 @@ test("creates one Subject with the exact role and navigates to its generated det
}) as HTMLInputElement;
await waitFor(() => expect(document.activeElement).toBe(roleInput));
await fireEvent.input(roleInput, { target: { value: " Review lead " } });
const behaviorInput = screen.getByRole("textbox", {
name: /Behavior/,
}) as HTMLTextAreaElement;
await fireEvent.input(behaviorInput, {
target: { value: "Challenge unsupported assumptions." },
});
const submit = screen.getByRole("button", { name: "Create Subject" });
await fireEvent.click(submit);
@@ -110,6 +121,8 @@ test("creates one Subject with the exact role and navigates to its generated det
resolveRequest(Response.json({
id: "generated-subject-1",
role: " Review lead ",
behavior_md: "Challenge unsupported assumptions.",
behavior_revision: 0,
state: "active",
store_revision: 0,
created_at: "2026-01-03T00:00:00Z",
@@ -127,7 +140,9 @@ test("creates one Subject with the exact role and navigates to its generated det
];
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
expect(init.method).toBe("POST");
expect(init.body).toBe('{"role":" Review lead "}');
expect(init.body).toBe(
'{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}',
);
});
test("keeps creation available for an empty list and restores focus on cancel", async () => {
@@ -146,7 +161,7 @@ test("keeps creation available for an empty list and restores focus on cancel",
expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull();
});
test("preserves invalid and rejected role drafts with accessible errors", async () => {
test("preserves invalid and rejected Subject drafts with accessible field errors", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(Response.json(
{
@@ -172,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
expect(fetchMock).not.toHaveBeenCalled();
await fireEvent.input(input, { target: { value: "Release reviewer" } });
const behaviorInput = screen.getByRole("textbox", {
name: /Behavior/,
}) as HTMLTextAreaElement;
await fireEvent.input(behaviorInput, { target: { value: " " } });
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
expect(screen.getByRole("alert").textContent).toContain(
"empty or contain non-whitespace",
);
expect(behaviorInput.getAttribute("aria-invalid")).toBe("true");
expect(fetchMock).not.toHaveBeenCalled();
await fireEvent.input(behaviorInput, {
target: { value: "Challenge unsupported assumptions." },
});
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -214,7 +243,10 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
workspaceId: "workspace-1",
subjectId: "subject-1",
cursor: "memory-current",
subject: result(subject()),
subject: result({
...subject(),
current_worker: { display_name: "Release Worker" },
}),
surface: result({
subject_id: "subject-1",
availability: "ready" as const,
@@ -267,9 +299,20 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
.not.toBeNull();
});
expect(screen.getByRole("article", { name: "Resident surface" })).not
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Prefer explicit evidence.");
expect(screen.getByRole("article", { name: "Resident context" })).not
.toBeNull();
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull();
expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull();
expect(screen.getByText("Subject store revision", { exact: true })).not
.toBeNull();
expect(screen.getByText(/not a Memory count/)).not.toBeNull();
expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan(
0,
);
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
@@ -280,6 +323,359 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
.toContain("cursor=memory-next");
});
test("edits and clears user-managed behavior with CAS while distinguishing save from Worker application", async () => {
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
const request = JSON.parse(String(init?.body));
return Response.json({
...subject(),
behavior_md: request.behavior_md,
behavior_revision: request.expected_behavior_revision + 1,
});
});
vi.stubGlobal("fetch", fetchMock);
render(SubjectPage, {
data: {
workspaceId: "workspace-1",
subjectId: "subject-1",
subject: result(subject()),
surface: result({
subject_id: "subject-1",
availability: "ungenerated" as const,
}),
memories: result({ items: [], has_more: false }),
},
} as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
await fireEvent.input(textarea, {
target: { value: "Ask before irreversible actions." },
});
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [path, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects/subject-1/behavior");
expect(init.method).toBe("PATCH");
expect(init.body).toBe(
'{"expected_behavior_revision":2,"behavior_md":"Ask before irreversible actions."}',
);
await waitFor(() => {
expect(
screen.getByText(/Behavior storage confirmed at revision 3/).textContent,
)
.toContain("does not confirm application");
});
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Ask before irreversible actions.");
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const secondBody =
(fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body;
expect(secondBody).toBe('{"expected_behavior_revision":3,"behavior_md":""}');
await waitFor(() =>
expect(screen.getByText("No behavior is set.")).not.toBeNull()
);
});
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
return {
workspaceId,
subjectId: record.id,
subject: result(record),
surface: result({
subject_id: record.id,
availability: "ungenerated" as const,
}),
memories: result({ items: [], has_more: false }),
};
}
test.each([
["subject-2", "workspace-1"],
["subject-1", "workspace-2"],
])(
"resets reused Subject editor for %s in %s and saves only its own behavior",
async (id, workspaceId) => {
const nextSubject = { ...subject(), id, behavior_md: "Only B behavior." };
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
const request = JSON.parse(String(init?.body));
return Response.json({
...nextSubject,
behavior_md: request.behavior_md,
behavior_revision: request.expected_behavior_revision + 1,
});
});
vi.stubGlobal("fetch", fetchMock);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.input(
screen.getByRole("textbox", { name: "Subject behavior" }),
{
target: { value: "Unsaved A draft" },
},
);
await view.rerender(
{ data: subjectPageData(nextSubject, workspaceId) } as never,
);
expect(screen.queryByRole("textbox", { name: "Subject behavior" }))
.toBeNull();
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Only B behavior.");
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
expect(
(screen.getByRole("textbox", {
name: "Subject behavior",
}) as HTMLTextAreaElement).value,
)
.toBe("Only B behavior.");
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [path, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(path).toBe(
`/api/w/${workspaceId}/subjektiv/subjects/${id}/behavior`,
);
expect(JSON.parse(String(init.body))).toEqual({
expected_behavior_revision: 2,
behavior_md: "Only B behavior.",
});
await waitFor(() =>
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
);
},
);
test.each(["success", "failure"])(
"fences late %s from a previous Subject without clearing the new save state",
async (outcome) => {
const pending: Array<(response: Response) => void> = [];
const fetchMock = vi.fn(() =>
new Promise<Response>((resolve) => pending.push(resolve))
);
vi.stubGlobal("fetch", fetchMock);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const nextSubject = {
...subject(),
id: "subject-2",
behavior_md: "Only B behavior.",
};
await view.rerender({ data: subjectPageData(nextSubject) } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.input(
screen.getByRole("textbox", { name: "Subject behavior" }),
{
target: { value: "Updated B behavior." },
},
);
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
pending[0](
outcome === "success"
? Response.json({
...subject(),
behavior_md: "Late A response",
behavior_revision: 3,
})
: Response.json({
error: "Forbidden",
message: "A denied",
diagnostics: [],
}, { status: 403 }),
);
// Drain the previous response through its success/catch/finally branches.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
screen.getByRole("button", { name: "Saving…" }).hasAttribute("disabled"),
).toBe(true);
expect(
(screen.getByRole("textbox", {
name: "Subject behavior",
}) as HTMLTextAreaElement).value,
)
.toBe("Updated B behavior.");
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText(/storage confirmed/)).toBeNull();
pending[1](
Response.json({
...nextSubject,
behavior_md: "Updated B behavior.",
behavior_revision: 3,
}),
);
await waitFor(() =>
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
);
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Updated B behavior.");
},
);
test("a refreshed loader snapshot fences old saves even after returning to the same Subject", async () => {
let resolveRequest!: (response: Response) => void;
vi.stubGlobal(
"fetch",
vi.fn(() =>
new Promise<Response>((resolve) => {
resolveRequest = resolve;
})
),
);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await view.rerender(
{ data: subjectPageData({ ...subject(), id: "subject-2" }) } as never,
);
const fresh = {
...subject(),
behavior_md: "Fresh A loader",
behavior_revision: 4,
};
await view.rerender({ data: subjectPageData(fresh) } as never);
resolveRequest(
Response.json({
...subject(),
behavior_md: "Stale A response",
behavior_revision: 3,
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Fresh A loader");
expect(screen.queryByText(/storage confirmed/)).toBeNull();
await view.rerender(
{
data: {
...subjectPageData(),
subject: result(null, "Subject unavailable"),
},
} as never,
);
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
expect(
screen.queryByRole("article", { name: "User-managed Subject behavior" }),
).toBeNull();
});
test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => {
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
// stages a candidate without committing a Memory. Its public read projection is
// the same empty committed list as a new Subject; candidates are not a page input.
render(SubjectPage, {
data: {
workspaceId: "workspace-1",
subjectId: "subject-1",
cursor: null,
subject: result({ ...subject(), store_revision: 0 }),
surface: result({ subject_id: "subject-1", availability: "ungenerated" }),
memories: result({ limit: 100, items: [], has_more: false }),
},
} as never);
expect(screen.getByText("None", { exact: true })).not.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
expect(screen.queryByRole("article", { name: "Resident context" }))
.toBeNull();
expect(
screen.queryByText(
/candidate count|all Memories lost|automatically updating/i,
),
).toBeNull();
});
test.each(["ready", "ungenerated", "stale", "failed"] as const)(
"user behavior and diagnostic disclosures stay independent of %s generated Memory",
async (availability) => {
const record = { ...subject(), store_revision: 0 };
const view = render(SubjectPage, {
data: {
...subjectPageData(record),
surface: result({
subject_id: record.id,
availability,
...(availability === "ready"
? {
snapshot: {
snapshot_id: "empty-snapshot",
body_md: "",
memory_refs: [],
built_from_store_revision: 0,
created_at: "2026-01-02T03:04:05Z",
},
}
: {}),
}),
},
} as never);
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain(record.behavior_md);
expect(screen.getByText("Generated Memory context", { exact: true })).not
.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
const technical = view.container.querySelector(
"details.subject-technical-details",
) as HTMLDetailsElement;
expect(technical.open).toBe(false);
expect(technical.textContent).toContain("Subject store revision");
if (availability === "ready") {
expect(screen.getByText("Resident context is current but empty.")).not
.toBeNull();
expect(screen.getByText(/no generated Memory context to show/)).not
.toBeNull();
expect(
view.container.querySelector("details.surface-details")?.textContent,
)
.toContain("Generated from Subject revision");
}
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain(record.behavior_md);
expect(technical.open).toBe(false);
},
);
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
const base = {
workspaceId: "workspace-1",
@@ -303,8 +699,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
}),
},
} as never);
expect(screen.getByText("Resident surface is ready and empty.")).not
expect(screen.getByText("Resident context is current but empty.")).not
.toBeNull();
expect(screen.getByText(/current empty surface exists/)).not.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
await view.rerender(
{
data: {
...base,
surface: result({
subject_id: "subject-1",
availability: "ungenerated" as const,
}),
},
} as never,
);
expect(screen.getByText("Resident context has not been generated.")).not
.toBeNull();
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
await view.rerender(
{
@@ -317,7 +730,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
},
} as never,
);
expect(screen.getByText("Resident surface is stale.")).not.toBeNull();
expect(screen.getByText("Resident context needs to be refreshed.")).not
.toBeNull();
await view.rerender(
{
@@ -333,7 +747,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
expect(screen.getByRole("alert").textContent).toContain("generation failed");
await view.rerender({ data: { ...base, surface: result(null) } } as never);
expect(screen.getByText("Resident surface data is unavailable.")).not
expect(screen.getByText("Resident context status is unavailable.")).not
.toBeNull();
await view.rerender(
@@ -342,7 +756,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
} as never,
);
expect(screen.getByRole("alert").textContent).toContain(
"Resident surface unavailable",
"Resident context status unavailable",
);
});
@@ -1,4 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { canRestoreWorker, createRestoreRequest, restoreWorkspaceWorker, restoreNotice, restoreErrorNotice, type RestoreRequest } from '#lib/workspace/sidebar/worker-actions.ts';
import { workspaceWorkersStore, refreshWorkspaceWorkers } from '#lib/workspace/sidebar/worker-subscription.ts';
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import { workspaceApiPath } from '#lib/workspace/api/http.ts';
import {
@@ -14,16 +17,71 @@
import type { CleanupWorkerCandidate, RuntimeCleanupPlanResponse, Worker } from '#lib/workspace/sidebar/types.ts';
import type { PageProps } from './$types';
type WorkerActionKind = 'pin' | 'delete';
type WorkerActionKind = 'restore' | 'pin' | 'delete';
let { data }: PageProps = $props();
let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({});
let workers = $state<Worker[]>([]);
let restoreRetries = $state<Record<string, RestoreRequest>>({});
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let lifetime = 0;
let cleanupEpoch = 0;
let catalogRefreshing = true;
let catalogReady = $state(false);
function actionScope() {
const id = data.workspaceId;
const epoch = lifetime;
return { workspaceId: id, isCurrent: () => lifetime === epoch && data.workspaceId === id };
}
$effect(() => {
cleanupPlans = data.cleanupPlans;
workers = data.workers?.items ?? [];
const id = data.workspaceId;
lifetime++;
cleanupEpoch++;
busyAction = null;
restoreRetries = {};
catalogReady = false;
const initial = untrack(() => data);
workers = initial.workers?.items ?? [];
cleanupPlans = initial.cleanupPlans;
let previousCatalog: string | undefined;
let previousObservation: number | undefined;
let previousRefreshing: boolean | undefined;
const scope = actionScope();
const unsubscribe = workspaceWorkersStore(id).subscribe((state) => {
if (!scope.isCurrent()) return;
const observationChanged = previousObservation !== state.observationVersion;
const refreshStarted = state.catalogRefreshing && previousRefreshing !== true;
const refreshCompleted = !state.catalogRefreshing && previousRefreshing === true;
const catalog = state.catalogWorkers === null ? undefined : JSON.stringify(state.catalogWorkers);
const catalogChanged = catalog !== previousCatalog;
previousObservation = state.observationVersion;
previousRefreshing = state.catalogRefreshing;
previousCatalog = catalog;
catalogRefreshing = state.catalogRefreshing;
if (observationChanged || catalogChanged || refreshStarted) {
cleanupPlans = {};
++cleanupEpoch;
}
if (state.catalogWorkers === null) return;
catalogReady = true;
workers = state.catalogWorkers;
// Subscription overlays stay visible during the GET, but cleanup must wait
// for its authoritative catalog result, even if that result is unchanged.
if (catalogRefreshing || (!catalogChanged && !observationChanged && !refreshCompleted)) return;
cleanupPlans = {};
const epoch = ++cleanupEpoch;
for (const runtimeId of new Set(state.catalogWorkers.map((worker) => worker.runtime_id))) {
void refreshCleanupPlan(runtimeId, scope, epoch).catch(() => {});
}
});
return () => {
lifetime++;
cleanupEpoch++;
unsubscribe();
};
});
function workerKey(worker: Worker): string {
@@ -56,41 +114,78 @@
return fallback;
}
async function refreshCleanupPlan(runtimeId: string): Promise<void> {
async function refreshCleanupPlan(
runtimeId: string,
scope = actionScope(),
epoch = cleanupEpoch,
): Promise<void> {
if (!scope.isCurrent() || catalogRefreshing || cleanupEpoch !== epoch) return;
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
workspaceApiPath(scope.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
);
if (!response.ok) return;
const plan = parseRuntimeCleanupPlan(await response.json());
if (!scope.isCurrent() || catalogRefreshing || cleanupEpoch !== epoch) return;
cleanupPlans = { ...cleanupPlans, [runtimeId]: plan };
}
async function restoreWorker(worker: Worker, retry = false): Promise<void> {
const key = workerKey(worker);
if (busyAction || (retry ? !restoreRetries[key] : restoreRetries[key] || !canRestoreWorker(worker))) return;
const scope = actionScope();
const request = retry ? restoreRetries[key] : createRestoreRequest(worker);
busyAction = { workerKey: key, kind: 'restore' };
cleanupPlans = {};
++cleanupEpoch;
try {
let notice;
try {
notice = restoreNotice(await restoreWorkspaceWorker(scope.workspaceId, worker, request));
} catch (cause) {
notice = restoreErrorNotice(cause);
}
if (!scope.isCurrent()) return;
const next = { ...restoreRetries };
if (notice.retry) next[key] = request;
else delete next[key];
restoreRetries = next;
pushWorkspaceAlert(notice.level, notice.message, { title: notice.title });
await refreshWorkspaceWorkers(scope.workspaceId).catch(() => {});
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope).catch(() => {});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
if (busyAction) return;
const scope = actionScope();
busyAction = { workerKey: workerKey(worker), kind: 'pin' };
cleanupPlans = {};
++cleanupEpoch;
try {
const response = await fetch(
workspaceApiPath(
data.workspaceId,
scope.workspaceId,
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${encodeURIComponent(worker.worker_id)}/pin`,
),
{ method: pinned ? 'PUT' : 'DELETE' },
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
pushWorkspaceAlert('error', errorMessage(payload, response.statusText), { title: 'Worker pin failed' });
return;
}
const retention = parseWorkerRetentionResponse(payload);
worker.pinned = retention.pinned;
worker.retention_state = retention.retention_state;
await refreshCleanupPlan(worker.runtime_id);
if (!scope.isCurrent()) return;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
parseWorkerRetentionResponse(payload);
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope);
} catch (error) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker pin failed', {
title: 'Worker pin failed',
});
} finally {
busyAction = null;
if (scope.isCurrent()) busyAction = null;
}
}
@@ -101,12 +196,15 @@
}
async function deleteWorker(worker: Worker, candidate: CleanupWorkerCandidate): Promise<void> {
if (!cleanupPlans?.[worker.runtime_id] || busyAction) return;
const plan = cleanupPlans?.[worker.runtime_id];
if (!plan || busyAction || candidate.blocking_reason || cleanupCandidate(worker) !== candidate) return;
const scope = actionScope();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
cleanupPlans = {};
++cleanupEpoch;
try {
const plan = cleanupPlans[worker.runtime_id];
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(worker.runtime_id)}/cleanup-executions`),
workspaceApiPath(scope.workspaceId, `/runtimes/${encodeURIComponent(worker.runtime_id)}/cleanup-executions`),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -120,22 +218,23 @@
},
);
const payload = await response.json().catch(() => null);
if (!scope.isCurrent()) return;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
const execution = parseRuntimeCleanupExecution(payload);
cleanupPlans = { ...cleanupPlans, [worker.runtime_id]: execution.plan_after };
const result = execution.results.find((entry) => entry.target_id === candidate.target_id);
if (!result || result.status !== 'deleted') {
throw new Error(result?.message ?? 'Runtime did not delete the selected Worker');
}
workers = workers.filter(
(item) => !(item.runtime_id === worker.runtime_id && item.worker_id === worker.worker_id),
);
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope);
} catch (error) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
busyAction = null;
if (scope.isCurrent()) busyAction = null;
}
}
@@ -180,9 +279,9 @@
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
</header>
{#if data.workersError}
{#if !catalogReady && data.workersError}
<p class="section-state error">{data.workersError}</p>
{:else if !data.workers}
{:else if !catalogReady && !data.workers}
<p class="section-state">Loading Workers…</p>
{:else if workers.length === 0}
<p class="section-state">No Workers are visible.</p>
@@ -222,6 +321,24 @@
<td>{workerDirectory(worker)}</td>
<td>
<div class="worker-actions" aria-label={`Actions for ${workerDisplayName}`}>
<button
class="icon-action"
type="button"
disabled={anyActionDisabled || !canRestoreWorker(worker) || Boolean(restoreRetries[workerKey(worker)])}
aria-label={`Restore ${workerDisplayName}`}
title="Restore"
onclick={() => restoreWorker(worker)}
>
{#if isActionBusy(worker, 'restore')}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2.6 6.4" /><path d="M3 3v8h8" /></svg>
{/if}
</button>
{#if restoreRetries[workerKey(worker)]}
<button type="button" disabled={anyActionDisabled} onclick={() => restoreWorker(worker, true)} aria-label={`Retry Restore ${workerDisplayName}`}>Retry Restore</button>
<span role="status">Restore outcome unresolved. Retry the same request to reconcile.</span>
{/if}
<button
class="icon-action"
type="button"
@@ -11,6 +11,8 @@
import ComposerInput from "#lib/workspace/console/ComposerInput.svelte";
import type { ComposerDraftSnapshot } from "#lib/workspace/console/composer-draft.ts";
import {
ComposerAdmissions,
type ComposerAdmission,
canDeliverComposerDraft,
sendComposerDelivery,
type ComposerDelivery,
@@ -38,6 +40,7 @@
type ConsoleViewScroll,
} from "#lib/workspace/console/model.ts";
import type {
CompletionContext,
Event as ProtocolEvent,
Method as ProtocolMethod,
PendingSubmissionsSnapshot,
@@ -115,10 +118,15 @@
containsTarget(target: EventTarget | null): boolean;
cursor(): number;
replaceRange(from: number, to: number, content: string): void;
clear(): void;
insertSegment(segment: Segment, cleanup?: boolean): boolean;
reserveUpload(fileName: string): number | null;
completeUpload(key: number, segment: Segment): boolean;
cancelUpload(key: number): void;
clear(acceptedTypedResources?: boolean): void;
restoreSegments(
segments: readonly Segment[],
preserveExactText?: boolean,
ownStagedUploads?: boolean,
): void;
};
@@ -138,9 +146,35 @@
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
let attachments = $state<ComposerAttachment[]>([]);
let nextAttachmentId = 1;
const stagedAttachmentPaths = new Map<string, string>();
const uploadReservations = new Map<number, number>();
let fileInput: HTMLInputElement | null = null;
let isDraggingFiles = $state(false);
const admissions = new ComposerAdmissions();
let admission = $state.raw<ComposerAdmission | null>(null);
let sending = $state(false);
function syncAdmission() {
admission = admissions.get(activeComposerTargetKey);
sending = admission !== null && admission.status !== "rejected";
}
function admissionDisconnected(targetKey = activeComposerTargetKey) {
admissions.disconnected(targetKey);
syncAdmission();
}
function retryAdmission() {
const retry = admissions.retry(activeComposerTargetKey);
if (!retry || protocolState !== "open") return;
syncAdmission();
try {
sendProtocolMethod(retry.method);
} catch (error) {
admissionDisconnected();
reportComposerError(error instanceof Error ? error.message : String(error));
}
}
let rewindTargets = $state<RewindTarget[]>([]);
let rewindHeadEntries = $state(0);
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
@@ -158,9 +192,30 @@
submissions: [],
});
let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []);
const fileCompletions = new FileCompletions((prefix) => sendProtocolMethod({
method: "list_completions", params: { kind: "file", prefix },
const fileCompletions = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions", params: { kind: "file", prefix, request_id },
}));
const featureCompletions = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions", params: { kind: "feature", prefix, request_id },
}));
const featureArgumentCompletions = new Map<string, FileCompletions>();
function completionContextKey(context: CompletionContext): string {
return JSON.stringify([context.invocation, context.argument ?? null]);
}
function featureArgumentCompletionLane(context: CompletionContext): FileCompletions {
const key = completionContextKey(context);
let lane = featureArgumentCompletions.get(key);
if (!lane) {
lane = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions",
params: { kind: "feature_argument", prefix, context, request_id },
}));
featureArgumentCompletions.set(key, lane);
}
return lane;
}
let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false);
@@ -260,7 +315,9 @@
const workerPaused = $derived(workerState === "paused");
const composerEditable = $derived(protocolState === "open" && !sending);
const draftHasText = $derived(draft.content.trim().length > 0);
const draftHasAttachments = $derived(attachments.length > 0);
const draftHasAttachments = $derived(
attachments.length > 0 || draft.segments.some((segment) => segment.kind === "uploaded_file"),
);
const canSubmitDraft = $derived(
canDeliverComposerDraft({
delivery: "submit",
@@ -712,6 +769,17 @@
initialSnapshot?: { token: number; source: ConsoleDisplaySource },
) {
handleProtocolCommandEvent(payload);
if (payload.event === "rewind_applied") {
if (draft.segments.length === 0 && attachments.length === 0) {
composerInputElement?.restoreSegments(payload.data.input);
} else {
pushWorkspaceAlert(
"info",
"Rewind restored Session history but kept the non-empty Composer unchanged.",
{ id: controlAlertId, title: "Rewind" },
);
}
}
if (payload.event === "snapshot") {
pendingSubmissions = payload.data.session.pending_submissions;
} else if (payload.event === "segment_rotated") {
@@ -928,6 +996,7 @@
function discardAllAttachments(): void {
const discarded = attachments;
attachments = [];
uploadReservations.clear();
for (const attachment of discarded) {
attachment.request?.abort();
if (attachment.reference) {
@@ -944,31 +1013,50 @@
if (nextKey === activeComposerTargetKey) return;
if (activeComposerTargetKey) discardAllAttachments();
if (composerInputElement) {
composerDrafts.set(
activeComposerTargetKey,
cachedComposerDraft(composerInputElement.snapshot()),
);
const cached = cachedComposerDraft(draft);
const pending = admissions.get(activeComposerTargetKey);
if (!pending || pending.status === "rejected") {
cached.segments = cached.segments.filter((segment) => segment.kind !== "uploaded_file" && segment.kind !== "unknown");
}
composerDrafts.set(activeComposerTargetKey, cached);
}
activeComposerTargetKey = nextKey;
const restored = composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
syncAdmission();
const pending = admissions.get(nextKey);
const restored = pending && pending.status !== "rejected"
? cachedComposerDraft(pending.snapshot)
: composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
void tick().then(() => {
if (activeComposerTargetKey !== nextKey) return;
composerInputElement?.restoreSegments(
restored.segments,
restored.preserveExactText,
true,
);
});
}
function composerSnapshotOrReport(): ComposerDraftSnapshot | null {
try {
return composerInputElement?.snapshot() ?? draft;
} catch (error) {
reportComposerError(error instanceof Error ? error.message : String(error));
return null;
}
}
function handleComposerCommand() {
void submitDraft(composerInputElement?.snapshot() ?? draft);
const value = composerSnapshotOrReport();
if (value) void submitDraft(value);
}
function handleComposerSubmit() {
if ((composerInputElement?.snapshot() ?? draft).document.trimStart().startsWith(":")) {
const value = composerSnapshotOrReport();
if (!value) return;
if (value.document.trimStart().startsWith(":")) {
handleComposerCommand();
return;
}
@@ -976,15 +1064,17 @@
sendWorkerControl("cancel");
return;
}
void submitDraft(composerInputElement?.snapshot() ?? draft);
void submitDraft(value);
}
function handleQueueSubmit() {
void submitDraft(composerInputElement?.snapshot() ?? draft, "queue");
const value = composerSnapshotOrReport();
if (value) void submitDraft(value, "queue");
}
function handleNotifySubmit() {
void submitDraft(composerInputElement?.snapshot() ?? draft, "notify");
const value = composerSnapshotOrReport();
if (value) void submitDraft(value, "notify");
}
function attachmentPath(): string {
@@ -1001,6 +1091,43 @@
);
}
function cancelComposerUpload(reservation: number): void {
const attachment = attachments.find((candidate) =>
uploadReservations.get(candidate.id) === reservation);
if (!attachment) return;
// The editor already cancelled/tombstoned its reservation. Do not call
// cancelUpload here: this may run inside a CodeMirror update listener.
uploadReservations.delete(attachment.id);
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
attachment.request?.abort();
}
function completeAttachmentUpload(
attachment: ComposerAttachment,
reference: NonNullable<ComposerAttachment["reference"]>,
): void {
if (!attachments.some((candidate) => candidate.id === attachment.id) ||
!composerInputElement || !composerEditable || attachment.uploadPath !== attachmentPath()) {
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
void fetch(
`${attachment.uploadPath}/attachments/${encodeURIComponent(reference.artifact_id)}`,
{ method: "DELETE" },
).catch(() => undefined);
return;
}
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
stagedAttachmentPaths.set(reference.artifact_id, attachment.uploadPath);
const segment: Segment = { kind: "uploaded_file", file: reference };
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation === undefined || !composerInputElement.completeUpload(reservation, segment)) {
void releaseComposerAtom(segment);
}
}
function startAttachmentUpload(attachment: ComposerAttachment): void {
const error = validateAttachmentFile(attachment.file);
if (error) {
@@ -1020,14 +1147,7 @@
attachment.uploadId,
{
progress: (progress) => updateAttachment(attachment.id, { progress }),
complete: (reference) =>
updateAttachment(attachment.id, {
state: "uploaded",
progress: 1,
reference,
error: null,
request: null,
}),
complete: (reference) => completeAttachmentUpload(attachment, reference),
failed: (message) =>
updateAttachment(attachment.id, {
state: "failed",
@@ -1044,7 +1164,8 @@
function addAttachmentFiles(files: Iterable<File>): void {
if (!composerEditable) return;
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length);
const stagedFiles = draft.segments.filter((segment) => segment.kind === "uploaded_file").length;
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length - stagedFiles);
for (const file of Array.from(files).slice(0, available)) {
const attachment: ComposerAttachment = {
id: nextAttachmentId++,
@@ -1057,13 +1178,30 @@
error: null,
request: null,
};
const reservation = composerInputElement?.reserveUpload(file.name);
if (reservation === null || reservation === undefined) continue;
uploadReservations.set(attachment.id, reservation);
attachments = [...attachments, attachment];
startAttachmentUpload(attachment);
}
}
async function releaseComposerAtom(segment: Segment): Promise<void> {
if (segment.kind !== "uploaded_file" || admissions.protects(segment)) return;
const path = stagedAttachmentPaths.get(segment.file.artifact_id);
if (!path) return; // Restored references are Session-owned, not client staging.
stagedAttachmentPaths.delete(segment.file.artifact_id);
await fetch(
`${path}/attachments/${encodeURIComponent(segment.file.artifact_id)}`,
{ method: "DELETE" },
).catch(() => undefined);
}
async function removeAttachment(attachment: ComposerAttachment): Promise<void> {
attachment.request?.abort();
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
if (attachment.reference) {
await fetch(`${attachment.uploadPath}/attachments/${encodeURIComponent(attachment.reference.artifact_id)}`, {
@@ -1106,8 +1244,13 @@
value: ComposerDraftSnapshot,
delivery: ComposerDelivery = "submit",
) {
if (delivery === "notify" && attachments.length > 0) {
reportComposerError("Notify accepts text only; remove attachments or queue a Submit.");
if (
delivery === "notify" &&
(attachments.length > 0 || value.segments.some((segment) =>
segment.kind !== "text" && segment.kind !== "paste"
))
) {
reportComposerError("Notify accepts text only; remove attachments or Feature invocations or queue a Submit.");
return;
}
const incompleteAttachment = attachments.find((attachment) =>
@@ -1119,12 +1262,8 @@
: incompleteAttachment.error ?? "Retry or remove the failed attachment.");
return;
}
const attachmentSegments: Segment[] = attachments.map((attachment) => ({
kind: "uploaded_file",
file: attachment.reference!,
}));
const command = buildComposerSegmentsRequest(
[...value.segments, ...attachmentSegments],
value.segments,
{
preserveExactText: value.textPastes.length > 0,
});
@@ -1143,7 +1282,8 @@
protocolOpen: protocolState === "open",
sending,
hasText: value.content.trim().length > 0,
hasAttachments: attachments.length > 0,
hasAttachments: attachments.length > 0 ||
value.segments.some((segment) => segment.kind === "uploaded_file"),
};
// Commands are controls, not idle-only chat submissions. Keep the
// removed header controls available while the Worker is busy too.
@@ -1166,22 +1306,28 @@
}
sending = true;
try {
const method = composerRequestToProtocolMethod(request);
let method = composerRequestToProtocolMethod(request);
if (method.method === "submit" || method.method === "notify") {
// Store before send: even a synchronous acknowledgement must correlate.
const pending = admissions.begin(activeComposerTargetKey, method, value, delivery);
method = pending.method;
syncAdmission();
}
if (isCommand) {
sendProtocolMethod(method);
} else if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) {
return;
}
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
attachments = [];
if (method.method === "submit" && delivery === "submit") {
liveWorkerState = "running";
if (method.method !== "submit" && method.method !== "notify") {
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
}
// Submit/Notify retain the editor, history and upload leases until ack.
} catch (error) {
admissionDisconnected();
reportComposerError(error instanceof Error ? error.message : String(error));
} finally {
sending = false;
syncAdmission();
}
}
@@ -1229,6 +1375,7 @@
return (await response.json()) as WorkerSessionObservation;
})
.then((observation) => {
if (controller.signal.aborted || token !== reloadToken) return;
const currentTarget = consoleTarget;
if (!currentTarget) return;
const currentIdentity: WorkerSessionRequestIdentity = {
@@ -1362,6 +1509,9 @@
);
}
fileCompletions.reset();
featureCompletions.reset();
for (const lane of featureArgumentCompletions.values()) lane.reset();
featureArgumentCompletions.clear();
protocolState = "open";
} else if (
frame.frame === "event" &&
@@ -1416,7 +1566,7 @@
return () => {
if (protocolSubscription === subscription) {
protocolSubscription = null;
fileCompletions.close();
rejectPendingCompletion(new Error("Worker completion connection closed."), `${target.workspaceId}:${target.runtimeId}:${target.workerId}`);
}
subscription.close();
};
@@ -1430,8 +1580,42 @@
}
function handleProtocolCommandEvent(event: ProtocolEvent) {
const acknowledgement = admissions.acknowledge(activeComposerTargetKey, event);
if (acknowledgement) {
const { record, accepted, message } = acknowledgement;
if (accepted) {
// Only an authoritative acceptance transfers staging ownership.
for (const segment of record.snapshot.segments) {
if (segment.kind === "uploaded_file") stagedAttachmentPaths.delete(segment.file.artifact_id);
}
composerInputElement?.recordHistory(record.snapshot);
// A late acceptance of a previously rejected attempt must not erase
// edits made while the recovered draft was unlocked.
if (JSON.stringify(draft.segments) === JSON.stringify(record.snapshot.segments)) {
composerInputElement?.clear(true);
attachments = [];
}
if (event.event === "submission_accepted" && event.data.disposition === "started") {
liveWorkerState = "running";
}
} else {
// The editor has stayed frozen with the exact typed input, Undo state
// and staged resources. Unlock it; unchanged retries reuse the IDs.
reportComposerError(message ?? "Worker rejected the input.");
}
syncAdmission();
return;
}
if (event.event === "completions") {
if (event.data.kind === "file") fileCompletions.receive(event.data.entries);
if (event.data.kind === "file") {
fileCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id);
} else if (event.data.kind === "feature") {
featureCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id);
} else if (event.data.context) {
featureArgumentCompletions
.get(completionContextKey(event.data.context))
?.receive(event.data.entries, event.data.prefix, event.data.request_id);
}
return;
}
if (event.event === "rewind_targets") {
@@ -1448,7 +1632,10 @@
}
if (event.event === "error") {
const error = new Error(event.data.message);
if (fileCompletions.pending) {
if (
fileCompletions.pending || featureCompletions.pending ||
[...featureArgumentCompletions.values()].some((lane) => lane.pending)
) {
rejectPendingCompletion(error);
}
streamDiagnostics = [
@@ -1462,8 +1649,11 @@
}
}
function rejectPendingCompletion(error: Error) {
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
admissionDisconnected(targetKey);
fileCompletions.close(error);
featureCompletions.close(error);
for (const lane of featureArgumentCompletions.values()) lane.close(error);
}
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
@@ -1633,7 +1823,13 @@
});
$effect(() => {
return () => discardAllAttachments();
return () => {
// Observation frames must not project or start history reads after
// this Console is gone. In-flight history must not enqueue a refresh.
cancelObservationFlush();
pendingHistoryRefreshes.clear();
discardAllAttachments();
};
});
$effect(() => {
@@ -1960,6 +2156,14 @@
onkeydown={handleComposerKeydown}
completionScope={activeComposerTargetKey}
resolveFileCompletions={(prefix, signal) => fileCompletions.request(prefix, signal)}
resolveFeatureCompletions={(prefix, signal) => featureCompletions.request(prefix, signal)}
resolveFeatureArgumentCompletions={(context, prefix, signal) =>
featureArgumentCompletionLane(context).request(prefix, signal)}
onremoveatom={(segment) => void releaseComposerAtom(segment)}
oncancelupload={cancelComposerUpload}
onclientadapter={(descriptor) => {
if (descriptor.client_adapter === "attachment") fileInput?.click();
}}
oncommand={handleComposerCommand}
onsubmit={handleComposerSubmit}
onpasteimages={addPastedImages}
@@ -1989,6 +2193,20 @@
{/if}
<div class="composer-input-footer">
<div class="composer-footer-slot">
{#if admission && admission.status !== "rejected"}
<button
class="composer-retry-button"
type="button"
aria-label="Retry admission"
title="Resend the same input and request ID; admission is not yet confirmed"
disabled={protocolState !== "open"}
onclick={retryAdmission}
>
<svg class="composer-retry-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M20 7V12H15M20 12A8 8 0 1 0 18 17" />
</svg>
</button>
{/if}
<button
class="composer-attach-button"
type="button"
@@ -2583,6 +2801,7 @@
.composer-attachment button,
.composer-attach-button,
.composer-retry-button,
.composer-queue-button,
.composer-notify-button {
border: 0;
@@ -2594,6 +2813,7 @@
}
.composer-attach-button,
.composer-retry-button,
.composer-queue-button,
.composer-notify-button {
display: inline-grid;
@@ -2606,6 +2826,7 @@
}
.composer-attach-button:hover:not(:disabled),
.composer-retry-button:hover:not(:disabled),
.composer-queue-button:hover:not(:disabled),
.composer-notify-button:hover:not(:disabled) {
background: var(--bg-subtle);
@@ -2613,6 +2834,7 @@
}
.composer-attach-button:disabled,
.composer-retry-button:disabled,
.composer-queue-button:disabled,
.composer-notify-button:disabled {
cursor: not-allowed;
@@ -2660,6 +2882,7 @@
}
.composer-attach-icon,
.composer-retry-icon,
.composer-queue-icon,
.composer-notify-icon,
.composer-send-icon {
@@ -11,11 +11,15 @@ import {
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type {
Event as ProtocolEvent,
FeatureInvocationDescriptor,
Method,
SessionConversationTurn,
SessionSnapshot,
} from "#lib/generated/protocol.ts";
import type { Worker } from "#lib/workspace/sidebar/types.ts";
import { EditorView } from "@codemirror/view";
import * as attachmentsApi from "#lib/workspace/console/composer-attachments.ts";
import { undo } from "@codemirror/commands";
import * as alerts from "#lib/workspace/alerts/store.ts";
import * as consoleModel from "#lib/workspace/console/model.ts";
import ConsolePage from "./+page.svelte";
@@ -213,11 +217,276 @@ function latestListener() {
return listener;
}
function protocolEvent(event: ProtocolEvent) {
latestListener().onFrame({ frame: "event", message: { event: "event", data: { payload: {
event: "worker_protocol", data: { worker_id: "worker-a", event },
} } } });
}
async function settleMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
const runDescriptor: FeatureInvocationDescriptor = {
identity: "feature:test/run", name: "run", aliases: [], display_name: "Run", description: "Run",
syntax: "parenthesized", arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" }, completion: { kind: "worker_file" } }],
};
async function liveComposer() {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({ availability: "live_protocol" }));
vi.stubGlobal("fetch", fetchMock);
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame());
await screen.findByText("No conversation to display");
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
const form = view.container.querySelector(".console-composer") as HTMLFormElement;
return { view, cm, form, fetchMock };
}
function completionRequest(): Extract<Method, { method: "list_completions" }> {
const method = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Method;
if (method.method !== "list_completions") throw new Error("Expected completion request");
return method;
}
function completionReply(method: Extract<Method, { method: "list_completions" }>, value: string, invocation?: FeatureInvocationDescriptor) {
protocolEvent({ event: "completions", data: { ...method.params, entries: [{ value, invocation, is_dir: false }] } });
}
async function selectRun(cm: EditorView) {
cm.focus();
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "/ru" }, selection: { anchor: 3 } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("ru"));
completionReply(completionRequest(), "run", runDescriptor);
await screen.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a b/資料")' }, selection: { anchor: '/run("a b/資料")'.length } });
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
}
test("Console teardown cancels pending observation frames before they can read history", async () => {
const { view, fetchMock } = await liveComposer();
const schedule = vi.spyOn(window, "requestAnimationFrame");
const cancel = vi.spyOn(window, "cancelAnimationFrame");
const warn = vi.spyOn(console, "warn");
protocolEvent({ event: "user_message", data: { segments: [{ kind: "text", content: "pending at teardown" }] } });
const handle = schedule.mock.results.at(-1)!.value as number;
const requestsBeforeUnmount = fetchMock.mock.calls.length;
await view.unmount();
expect(cancel).toHaveBeenCalledWith(handle);
// Let the real happy-dom frame queue run; do not mock away the lifecycle bug.
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
expect(fetchMock).toHaveBeenCalledTimes(requestsBeforeUnmount);
expect(warn).not.toHaveBeenCalled();
});
test("Feature and argument completion correlate same-prefix ABA, context and other clients", async () => {
const { cm } = await liveComposer();
cm.focus();
cm.dispatch({ changes: { from: 0, insert: "/ru" }, selection: { anchor: 3 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalled());
const old = completionRequest();
cm.dispatch({ changes: { from: 2, to: 3, insert: "x" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("rx"));
cm.dispatch({ changes: { from: 2, to: 3, insert: "u" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("ru"));
const current = completionRequest();
expect(current.params.request_id).not.toBe(old.params.request_id);
completionReply(old, "old", runDescriptor);
completionReply({ ...current, params: { ...current.params, request_id: "another-client" } }, "foreign", runDescriptor);
completionReply({ ...current, params: { ...current.params, request_id: undefined } }, "legacy", runDescriptor);
await settleMicrotasks();
expect(screen.queryByRole("option")).toBeNull();
completionReply(current, "run", runDescriptor);
await screen.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a' }, selection: { anchor: 7 } });
await waitFor(() => expect(completionRequest().params.context?.argument).toBe("path"));
const argOld = completionRequest();
cm.dispatch({ changes: { from: 6, to: 7, insert: "b" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("b"));
cm.dispatch({ changes: { from: 6, to: 7, insert: "a" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("a"));
const argCurrent = completionRequest();
completionReply(argOld, "stale-path");
completionReply({ ...argCurrent, params: { ...argCurrent.params, context: { invocation: "other-feature", argument: "path" } } }, "wrong-context");
await settleMicrotasks();
expect(screen.queryByRole("option")).toBeNull();
completionReply(argCurrent, "a b/資料");
expect((await screen.findByRole("option")).textContent).toContain("a b/資料");
});
test("Submit rejection keeps exact typed draft, request/invocation IDs, and only acceptance clears", async () => {
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
const { cm, form } = await liveComposer();
await selectRun(cm);
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
expect(first.method).toBe("submit");
expect(first.params.input[0]).toMatchObject({ kind: "feature_invoke", invocation: { identity: runDescriptor.identity, arguments: [{ name: "path", value: { kind: "string", value: "a b/資料" } }] } });
const doc = cm.state.doc.toString();
await waitFor(() => expect(cm.state.readOnly).toBe(true));
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
await fireEvent.keyDown(cm.contentDOM, { key: "z", ctrlKey: true });
expect(cm.state.doc.toString()).toBe(doc);
protocolEvent({ event: "submission_rejected", data: { submission_request_id: "other-client", message: "foreign" } });
await settleMicrotasks();
expect(cm.state.readOnly).toBe(true);
protocolEvent({ event: "submission_rejected", data: { submission_request_id: first.params.submission_request_id, message: "Feature validation failed" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
expect(alert).toHaveBeenCalledWith("error", "Feature validation failed", expect.any(Object));
expect(cm.state.doc.toString()).toBe(doc);
await fireEvent.submit(form);
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "s", disposition: "started" } });
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
expect(undo(cm)).toBe(false);
});
test("failed upload retry preserves middle insertion, Undo, unknown admission and accepted ownership", async () => {
const uploads: attachmentsApi.AttachmentUploadCallbacks[] = [];
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, callbacks) => {
uploads.push(callbacks);
return { abort: vi.fn() };
});
const { cm, form, view, fetchMock } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "before after" }, selection: { anchor: 7 } });
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "report.md", { type: "text/markdown" })] } });
uploads[0].failed("upload failed");
await fireEvent.click(await screen.findByRole("button", { name: "Retry" }));
const reference = { artifact_id: "staged-artifact", file_name: "report.md", media_type: "text/markdown", created_at_ms: 1, availability: "available" as const, byte_len: 4, sha256: "abcd" };
uploads[1].complete(reference);
await waitFor(() => expect(view.container.textContent).toContain("Attached report.md"));
cm.dispatch({ selection: { anchor: 10 } });
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(view.container.textContent).not.toContain("Attached report.md");
undo(cm);
await waitFor(() => expect(view.container.textContent).toContain("Attached report.md"));
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
expect(first.params.input).toEqual([{ kind: "text", content: "before " }, { kind: "uploaded_file", file: reference }, { kind: "text", content: " after" }]);
latestListener().onStatus?.("closed", { kind: "transport", message: "ack lost" });
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
latestListener().onStatus?.("connecting");
latestListener().onFrame(subscribedFrame());
await fireEvent.click(await screen.findByRole("button", { name: "Retry admission" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "accepted", disposition: "queued" } });
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
view.unmount();
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
});
test("late acceptance after rejection does not clear newer draft edits", async () => {
const { cm, form } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "original" }, selection: { anchor: 8 } });
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
protocolEvent({ event: "submission_rejected", data: { submission_request_id: first.params.submission_request_id, message: "try later" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "newer draft" }, selection: { anchor: 11 } });
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "old", disposition: "queued" } });
await settleMicrotasks();
expect(cm.state.doc.toString()).toBe("newer draft");
});
test("unmounting unknown upload admission does not delete possibly accepted artifacts", async () => {
let complete!: attachmentsApi.AttachmentUploadCallbacks["complete"];
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, callbacks) => {
complete = callbacks.complete;
return { abort: vi.fn() };
});
const { view, form, fetchMock } = await liveComposer();
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "unknown.md", { type: "text/markdown" })] } });
complete({ artifact_id: "unknown-artifact", file_name: "unknown.md", media_type: "text/markdown", created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd" });
await waitFor(() => expect(view.container.textContent).toContain("Attached unknown.md"));
await fireEvent.submit(form);
latestListener().onStatus?.("closed", { kind: "transport", message: "ack lost" });
view.unmount();
await settleMicrotasks();
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
});
test("cached unsent typed draft survives target round-trip and remains sendable", async () => {
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
const { cm, form, view } = await liveComposer();
await selectRun(cm);
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
await view.rerender({ data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
await fireEvent.submit(form);
expect(alert).not.toHaveBeenCalledWith("error", expect.any(String), expect.any(Object));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({ method: "submit", params: { submission_request_id: expect.any(String), input: [expect.objectContaining({ kind: "feature_invoke" })] } });
});
test("unknown Submit survives target ABA and cannot be deleted into a new admission", async () => {
const { cm, form, view } = await liveComposer();
await selectRun(cm);
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
await waitFor(() => expect(cm.state.readOnly).toBe(true));
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
await view.rerender({ data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
expect(cm.state.readOnly).toBe(true);
const doc = cm.state.doc.toString();
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(cm.state.doc.toString()).toBe(doc);
await fireEvent.click(screen.getByRole("button", { name: "Retry admission" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
view.unmount();
});
test("cancelled attachment ignores late completion and deletes only abandoned staging", async () => {
let callbacks!: attachmentsApi.AttachmentUploadCallbacks;
const abort = vi.fn();
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, value) => {
callbacks = value;
return { abort };
});
const { cm, view, fetchMock } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "keep this" }, selection: { anchor: 4 } });
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "late.md", { type: "text/markdown" })] } });
await fireEvent.click(screen.getByRole("button", { name: "Remove late.md" }));
expect(abort).toHaveBeenCalledOnce();
callbacks.complete({ artifact_id: "late-artifact", file_name: "late.md", media_type: "text/markdown", created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd" });
await settleMicrotasks();
expect(view.container.textContent).not.toContain("Attached late.md");
expect(cm.state.doc.toString()).toBe("keep this");
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/attachments/late-artifact"), { method: "DELETE" });
undo(cm);
expect(cm.dom.textContent).not.toContain("Uploading late.md");
});
test.each(["notification_rejected", "notification_accepted"] as const)("Notify keeps text until authoritative %s", async (outcome) => {
const { cm } = await liveComposer();
protocolEvent({ event: "worker_state", data: { snapshot: { last_command_id: 0, state: { kind: "busy", state: { kind: "run", state: "running" } } } } });
cm.dispatch({ changes: { from: 0, insert: "通知" }, selection: { anchor: 2 } });
await fireEvent.click(await screen.findByRole("button", { name: "Notify Worker" }));
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "notify" }>;
expect(cm.state.doc.toString()).toBe("通知");
protocolEvent(outcome === "notification_accepted"
? { event: outcome, data: { notification_request_id: first.params.notification_request_id } }
: { event: outcome, data: { notification_request_id: first.params.notification_request_id, message: "not running" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
expect(cm.state.doc.toString()).toBe(outcome === "notification_accepted" ? "" : "通知");
if (outcome === "notification_rejected") {
await fireEvent.click(screen.getByRole("button", { name: "Notify Worker" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
}
});
beforeEach(() => {
multiplexer.listeners.length = 0;
multiplexer.close.mockClear();
@@ -419,6 +688,11 @@ test.each([
expect(form.querySelector('[role="alert"]')).toBeNull();
expect(form.textContent).not.toContain(message);
if (transportFailure) {
const method = multiplexer.sendWorkerMethod.mock.calls[0][0];
protocolEvent({ event: "submission_rejected", data: { submission_request_id: method.params.submission_request_id, message } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
}
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: ":peer teammate" } });
await fireEvent.submit(form);
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({
@@ -556,7 +830,7 @@ test.each(["idle", "running", "paused"] as const)("Composer completion executes
expect(multiplexer.sendWorkerMethod.mock.calls.every(([method]) => method.method !== "submit" && method.method !== "cancel")).toBe(true);
});
test("file completions consume stale responses before the latest prefix and close with the connection", async () => {
test("file completion nonces reject stale replies without blocking newer requests", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
@@ -565,23 +839,20 @@ test("file completions consume stale responses before the latest prefix and clos
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
cm.focus();
cm.dispatch({ changes: { from: 0, insert: "@old" }, selection: { anchor: 4 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "old" } }));
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "old", request_id: expect.any(String) } }));
const old = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0].params.request_id;
cm.dispatch({ changes: { from: 1, to: 4, insert: "new" }, selection: { anchor: 4 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "new", request_id: expect.any(String) } }));
const current = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0].params.request_id;
const receive = (value: string, prefix: string, request_id?: string) => protocolEvent({ event: "completions", data: { kind: "file", prefix, request_id, entries: [{ value, is_dir: false }] } });
receive("old-result", "old", old);
await settleMicrotasks();
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledOnce();
const receive = (value: string) => latestListener().onFrame({
frame: "event", message: { event: "event", data: { payload: {
event: "worker_protocol", data: { event: { event: "completions", data: { kind: "file", entries: [{ value, is_dir: false }] } } },
} } },
});
receive("old-result");
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "new" } }));
expect(screen.queryByRole("option")).toBeNull();
receive("new-result");
receive("new-result", "new", current);
expect((await screen.findByRole("option")).textContent).toContain("@new-result");
latestListener().onStatus?.("closed", { kind: "transport", message: "connection lost" });
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
receive("late-result");
receive("late-result", "new", current);
await settleMicrotasks();
expect(screen.queryByRole("listbox")).toBeNull();
});
@@ -0,0 +1,600 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { writable } from "svelte/store";
import type { ComponentProps } from "svelte";
import type {
RuntimeCleanupPlanResponse,
Worker,
} from "#lib/workspace/sidebar/types.ts";
import type { WorkspaceWorkersState } from "#lib/workspace/sidebar/worker-subscription.ts";
import {
clearWorkspaceAlerts,
workspaceAlerts,
} from "#lib/workspace/alerts/store.ts";
import WorkersPage from "./+page.svelte";
const shared = vi.hoisted(() => ({
stores: new Map(),
refresh: vi.fn(async (_workspaceId: string) => {}),
}));
vi.mock("#lib/workspace/sidebar/worker-subscription.ts", () => ({
workspaceWorkersStore: (id: string) => shared.stores.get(id),
refreshWorkspaceWorkers: shared.refresh,
}));
type Data = ComponentProps<typeof WorkersPage>["data"];
function worker(overrides: Partial<Worker> = {}): Worker {
return {
runtime_id: "runtime-a",
worker_id: "worker-a",
resource_key: "W-1",
host_id: "host-a",
display_name: "Catalog worker",
label: "worker-a",
profile: "builtin:coder",
tags: [],
workspace: { visibility: "workspace", identity: "workspace-a" },
availability: "observed",
state: "stopped",
pinned: true,
retention_state: "pinned",
implementation: { kind: "runtime", display_hint: "Runtime Worker" },
workdir_attachments: [],
diagnostics: [],
...{ restore_observation_token: "observation-1" },
...overrides,
};
}
function plan(revision = "plan-1"): RuntimeCleanupPlanResponse {
return {
workspace_id: "page-test",
generated_at: "2026-10-06T00:00:00Z",
runtime_id: "runtime-a",
revision,
digest: `digest-${revision}`,
diagnostics: [],
workdirs: [],
workers: [{
target_id: `target-${revision}`,
action: "worker_delete",
worker_id: "W-1",
runtime_worker_id: "worker-a",
runtime_id: "runtime-a",
reason: "stopped",
pinned: false,
retention_state: "normal",
linked_workdir_ids: [],
running_linked: false,
}],
};
}
function catalog(id = "page-test") {
const state = writable<WorkspaceWorkersState>({
loading: true,
workers: [],
catalogWorkers: null,
observationVersion: 0,
catalogRefreshing: true,
});
shared.stores.set(id, state);
return state;
}
function data(id = "page-test", item = worker()): Data {
return {
workspaceId: id,
workers: { items: [item] },
workersError: null,
cleanupPlans: { "runtime-a": plan() },
cleanupPlanErrors: {},
} as unknown as Data;
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function publish(store: ReturnType<typeof catalog>, items: Worker[]) {
store.update((previous) => ({
loading: false,
workers: [],
catalogWorkers: items,
observationVersion: previous.observationVersion + 1,
catalogRefreshing: false,
}));
}
afterEach(() => {
cleanup();
shared.stores.clear();
vi.clearAllMocks();
vi.unstubAllGlobals();
clearWorkspaceAlerts();
});
test("catalog updates preserve retention and workdirs, and stale page data never replaces a ready catalog", async () => {
const store = catalog();
vi.stubGlobal("fetch", vi.fn(async () => Response.json(plan())));
const view = render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(),
});
const attachment: NonNullable<Worker["workdir_attachments"]>[number] = {
alias: "source",
effective_permissions: { read: true, write: false, command: false },
working_directory: {
working_directory_id: "dir-a",
display_name: "Retained Workdir",
status: "active",
materializer_kind: "client_hosted_external",
source: {
kind: "external_grant",
grant_id: "grant-a",
grant_permissions: { read: true, write: false, command: false },
},
},
};
publish(store, [
worker({
display_name: "Live catalog",
retention_state: "retained",
workdir_attachments: [attachment],
}),
]);
expect(await screen.findByText("Live catalog")).toBeTruthy();
expect(screen.getByText("retained")).toBeTruthy();
expect(screen.getByText(/Retained Workdir/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Unpin Live catalog" }))
.toBeTruthy();
expect(
screen.getByRole("link", { name: "Live catalog" }).getAttribute("href"),
).toBe("/w/page-test/workers/W-1-live-catalog");
await view.rerender({
data: data("page-test", worker({ display_name: "Stale loader worker" })),
});
expect(screen.getByText("Live catalog")).toBeTruthy();
expect(screen.queryByText("Stale loader worker")).toBeNull();
});
test("catalog change invalidates Delete immediately and an older cleanup response cannot restore a stale action", async () => {
const store = catalog();
const older = deferred<Response>();
const newer = deferred<Response>();
const request = vi.fn().mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker({ pinned: false })]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull();
publish(store, [worker({ pinned: false, state: "idle" })]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
newer.resolve(Response.json({ ...plan("new"), workers: [] }));
older.resolve(Response.json(plan("old")));
await new Promise((done) => setTimeout(done, 0));
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull();
expect(
request.mock.calls.every(([url]) =>
!String(url).endsWith("/cleanup-executions")
),
).toBe(true);
});
test("refresh start invalidates an installed Delete candidate without requesting cleanup from the old catalog", async () => {
const store = catalog();
const request = vi.fn(async () => Response.json(plan()));
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker({ pinned: false })]);
expect(await screen.findByRole("button", { name: "Delete Catalog worker" }))
.toBeTruthy();
expect(request).toHaveBeenCalledTimes(1);
// The flag itself invalidates plans, independently of the observation version.
store.update((previous) => ({ ...previous, catalogRefreshing: true }));
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull()
);
store.update((previous) => ({ ...previous }));
await new Promise((done) => setTimeout(done, 0));
expect(request).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull();
// No catalog or version change on completion: the transition still reloads.
store.update((previous) => ({ ...previous, catalogRefreshing: false }));
expect(await screen.findByRole("button", { name: "Delete Catalog worker" }))
.toBeTruthy();
expect(request).toHaveBeenCalledTimes(2);
});
test.each(["during refresh", "after completion"] as const)(
"a previous cleanup GET arriving %s cannot reinstall a stale candidate; completion reschedules cleanup",
async (settlement) => {
const store = catalog();
const older = deferred<Response>();
const newer = deferred<Response>();
const request = vi.fn().mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker({ pinned: false })]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
store.update((previous) => ({
...previous,
catalogRefreshing: true,
observationVersion: previous.observationVersion + 1,
catalogWorkers: [
worker({ pinned: false, display_name: "Overlay worker" }),
],
}));
expect(await screen.findByText("Overlay worker")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Delete Overlay worker" }))
.toBeNull();
expect(request).toHaveBeenCalledTimes(1);
if (settlement === "during refresh") {
older.resolve(Response.json(plan("stale")));
await new Promise((done) => setTimeout(done, 0));
expect(screen.queryByRole("button", { name: "Delete Overlay worker" }))
.toBeNull();
}
// Successful catalog GET retains exactly the same catalog and observationVersion.
store.update((previous) => ({ ...previous, catalogRefreshing: false }));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
if (settlement === "after completion") {
older.resolve(Response.json(plan("stale")));
await new Promise((done) => setTimeout(done, 0));
expect(screen.queryByRole("button", { name: "Delete Overlay worker" }))
.toBeNull();
}
newer.resolve(Response.json({ ...plan("fresh"), workers: [] }));
await new Promise((done) => setTimeout(done, 0));
expect(screen.queryByRole("button", { name: "Delete Overlay worker" }))
.toBeNull();
expect(request).toHaveBeenCalledTimes(2);
expect(
request.mock.calls.every(([url]) =>
String(url).endsWith("/cleanup-plan")
),
).toBe(true);
},
);
test("restore follow-up cannot request cleanup while the authoritative catalog refresh remains unresolved", async () => {
const store = catalog();
const request = vi.fn(async (input: RequestInfo | URL) =>
String(input).endsWith("/restore")
? restoreResponse("accepted")
: Response.json(plan())
);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker({ pinned: false })]);
expect(await screen.findByRole("button", { name: "Delete Catalog worker" }))
.toBeTruthy();
shared.refresh.mockImplementationOnce(async () => {
store.update((previous) => ({
...previous,
catalogRefreshing: true,
observationVersion: previous.observationVersion + 1,
}));
});
await fireEvent.click(
screen.getByRole("button", { name: "Restore Catalog worker" }),
);
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Pin Catalog worker",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
.toBeNull();
expect(
request.mock.calls.filter(([url]) => String(url).endsWith("/cleanup-plan")),
).toHaveLength(1);
});
test.each(["switch", "dispose"] as const)(
"late pin response has no UI, alert, refresh, or cleanup effects after %s",
async (mode) => {
catalog();
catalog("next-workspace");
const pending = deferred<Response>();
const request = vi.fn((_input: RequestInfo | URL) => pending.promise);
vi.stubGlobal("fetch", request);
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const view = render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(),
});
await fireEvent.click(
screen.getByRole("button", { name: "Unpin Catalog worker" }),
);
expect(String(request.mock.calls[0][0])).toContain("/api/w/page-test/");
if (mode === "switch") {
await view.rerender({
data: data("next-workspace", worker({ display_name: "Next worker" })),
});
} else view.unmount();
pending.resolve(
Response.json({ message: "old pin failure" }, { status: 409 }),
);
await pending.promise;
await new Promise((done) => setTimeout(done, 0));
expect(shared.refresh).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(1);
expect(alerts).toEqual([]);
if (mode === "switch") {
expect(screen.getByRole("button", { name: "Unpin Next worker" }))
.toBeTruthy();
}
unsubscribe();
},
);
test("pin response does not overwrite authoritative catalog retention", async () => {
const store = catalog();
const pending = deferred<Response>();
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/pin")
? pending.promise
: Promise.resolve(Response.json(plan()))
),
);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
publish(store, [worker()]);
await fireEvent.click(
await screen.findByRole("button", { name: "Unpin Catalog worker" }),
);
publish(store, [worker({ retention_state: "newer catalog", pinned: true })]);
pending.resolve(
Response.json({
workspace_id: "page-test",
runtime_id: "runtime-a",
worker_id: "worker-a",
pinned: false,
retention_state: "old response",
}),
);
await waitFor(() => expect(shared.refresh).toHaveBeenCalledWith("page-test"));
expect(screen.getByText("newer catalog")).toBeTruthy();
expect(screen.queryByText("old response")).toBeNull();
});
test.each(["switch", "dispose"] as const)(
"late Delete response is ignored after %s",
async (mode) => {
const store = catalog();
catalog("next-workspace");
const pending = deferred<Response>();
const request = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith("/cleanup-executions")
? pending.promise
: Promise.resolve(Response.json(plan()))
);
vi.stubGlobal("fetch", request);
const view = render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(),
});
publish(store, [worker({ pinned: false })]);
await fireEvent.click(
await screen.findByRole("button", { name: "Delete Catalog worker" }),
);
const execution = request.mock.calls.find(([url]) =>
String(url).endsWith("/cleanup-executions")
);
expect(String(execution?.[0])).toContain("/api/w/page-test/");
if (mode === "switch") {
await view.rerender({
data: data("next-workspace", worker({ display_name: "Next worker" })),
});
} else view.unmount();
const countBeforeCompletion = request.mock.calls.length;
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
pending.resolve(
Response.json({ message: "old deletion failure" }, { status: 409 }),
);
await pending.promise;
await new Promise((done) => setTimeout(done, 0));
expect(shared.refresh).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(countBeforeCompletion);
expect(alerts).toEqual([]);
if (mode === "switch") {
expect(screen.getByRole("button", { name: "Unpin Next worker" }))
.toBeTruthy();
}
unsubscribe();
},
);
function restoreResponse(
state: "accepted" | "rejected" | "rolled_back" | "reconciliation_required",
extra = {},
) {
return Response.json({
workspace_id: "page-test",
runtime_id: "runtime-a",
worker_id: "worker-a",
result: { state, diagnostics: [], ...extra },
});
}
test.each(["stopped", "idle", "running", "paused", "unavailable"] as const)(
"Restore eligibility uses observed stopped catalog lifecycle (%s)",
async (state) => {
catalog();
vi.stubGlobal("fetch", vi.fn(async () => Response.json(plan())));
render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(
"page-test",
worker({
state,
availability: state === "unavailable" ? "unavailable" : "observed",
}),
),
});
expect(
(screen.getByRole("button", {
name: "Restore Catalog worker",
}) as HTMLButtonElement).disabled,
).toBe(state !== "stopped");
},
);
test.each(
["accepted", "rejected", "rolled_back", "reconciliation_required"] as const,
)(
"Restore classifies %s through the shared helper, without treating HTTP 200 as success",
async (state) => {
catalog();
let alerts: { title?: string; level: string }[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const request = vi.fn(async (input: RequestInfo | URL) =>
String(input).endsWith("/restore")
? restoreResponse(state)
: Response.json(plan())
);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
await fireEvent.click(
screen.getByRole("button", { name: "Restore Catalog worker" }),
);
await waitFor(() =>
expect(shared.refresh).toHaveBeenCalledWith("page-test")
);
expect(alerts.at(-1)?.level).toBe(
state === "accepted"
? "info"
: state === "reconciliation_required"
? "warning"
: "error",
);
expect(alerts.some((alert) => alert.title === "Worker restored")).toBe(
state === "accepted",
);
expect(
Boolean(
screen.queryByRole("button", { name: "Retry Restore Catalog worker" }),
),
).toBe(state === "reconciliation_required");
// The response is never projected into the catalog by the action.
expect(screen.getByRole("cell", { name: "stopped" })).toBeTruthy();
unsubscribe();
},
);
test.each(["transport", "reconciliation"] as const)(
"explicit %s retry reuses the exact guarded request despite newer observations",
async (failure) => {
const store = catalog();
let attempts = 0;
const request = vi.fn(
async (input: RequestInfo | URL, _init?: RequestInit) => {
if (!String(input).endsWith("/restore")) return Response.json(plan());
if (++attempts === 1) {
if (failure === "transport") throw new TypeError("connection lost");
return restoreResponse("reconciliation_required");
}
return restoreResponse("accepted");
},
);
vi.stubGlobal("fetch", request);
render(WorkersPage, { params: { workspaceId: "page-test" }, data: data() });
await fireEvent.click(
screen.getByRole("button", { name: "Restore Catalog worker" }),
);
const retry = await screen.findByRole("button", {
name: "Retry Restore Catalog worker",
});
publish(store, [
worker({
pinned: true,
...{ restore_observation_token: "observation-2" },
}),
]);
await waitFor(() =>
expect((retry as HTMLButtonElement).disabled).toBe(false)
);
expect(attempts).toBe(1);
expect(
(screen.getByRole("button", {
name: "Restore Catalog worker",
}) as HTMLButtonElement).disabled,
).toBe(true);
await fireEvent.click(retry);
await waitFor(() => expect(attempts).toBe(2));
const operations = request.mock.calls.filter(([url]) =>
String(url).endsWith("/restore")
);
expect(operations[1][1]?.body).toBe(operations[0][1]?.body);
expect(JSON.parse(String(operations[0][1]?.body))).toEqual({
expected_observation_token: "observation-1",
request_id: expect.any(String),
});
},
);
test.each(["switch", "dispose"] as const)(
"late restore result is ignored after %s",
async (mode) => {
catalog();
catalog("next-workspace");
const pending = deferred<Response>();
const request = vi.fn((_input: RequestInfo | URL) => pending.promise);
vi.stubGlobal("fetch", request);
let alerts: unknown[] = [];
const unsubscribe = workspaceAlerts.subscribe((value) => {
alerts = value;
});
const view = render(WorkersPage, {
params: { workspaceId: "page-test" },
data: data(),
});
await fireEvent.click(
screen.getByRole("button", { name: "Restore Catalog worker" }),
);
expect(
(screen.getByRole("button", {
name: "Unpin Catalog worker",
}) as HTMLButtonElement).disabled,
).toBe(true);
if (mode === "switch") {
await view.rerender({
data: data("next-workspace", worker({ display_name: "Next worker" })),
});
} else view.unmount();
pending.resolve(restoreResponse("accepted"));
await pending.promise;
await new Promise((done) => setTimeout(done, 0));
expect(shared.refresh).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledTimes(1);
expect(alerts).toEqual([]);
unsubscribe();
},
);
@@ -0,0 +1,314 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import type {
SubscriptionFrame,
SubscriptionWorker,
} from "#lib/generated/protocol.ts";
import {
clearWorkspaceAlerts,
workspaceAlerts,
} from "#lib/workspace/alerts/store.ts";
import { disposeWorkspaceWorkersStore } from "#lib/workspace/sidebar/worker-subscription.ts";
import WorkersNavSection from "#lib/workspace/sidebar/WorkersNavSection.svelte";
import WorkersPage from "./+page.svelte";
const transport = vi.hoisted(() => ({
listener: null as null | { onFrame(frame: SubscriptionFrame): void },
}));
vi.mock("#lib/workspace/multiplexer.ts", () => ({
workspaceMultiplexer: () => ({
subscribe: (_: unknown, listener: typeof transport.listener) => {
transport.listener = listener;
return { close: vi.fn() };
},
}),
}));
const workspaceId = "team /";
const runtimeId = "runtime /";
const workerId = "worker /";
const restoreUrl =
"/api/w/team%20%2F/runtimes/runtime%20%2F/workers/worker%20%2F/restore";
function worker(state = "stopped", token = "generation-1") {
return {
runtime_id: runtimeId,
worker_id: workerId,
resource_key: "W-1",
host_id: runtimeId,
display_name: "Team Worker",
label: "Team Worker",
availability: "observed" as const,
state,
restore_observation_token: token,
tags: [],
diagnostics: [],
pinned: false,
retention_state: "normal",
workspace: {
visibility: "workspace",
identity: "registered",
workspace_id: workspaceId,
},
implementation: { kind: "runtime", display_hint: "Runtime" },
workdir_attachments: [],
worker_state: state === "stopped"
? null
: { last_command_id: 0, state: { kind: "idle" as const } },
};
}
function frame(current: ReturnType<typeof worker>): SubscriptionFrame {
const subscriptionWorker = {
...current,
has_running_internal_workers: false,
workspace_id: workspaceId,
} as SubscriptionWorker;
return {
protocol_version: 2,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: "request",
subscription_id: "sub",
selector: { topic: "workspace_workers" },
snapshot: { topic: "workers", data: { workers: [subscriptionWorker] } },
},
},
};
}
function plan(state: string) {
return {
workspace_id: workspaceId,
runtime_id: runtimeId,
generated_at: "2026-10-06T00:00:00Z",
revision: state,
digest: state,
diagnostics: [],
workdirs: [],
workers: state === "stopped"
? [{
target_id: "target",
action: "worker_delete",
worker_id: "W-1",
runtime_worker_id: workerId,
runtime_id: runtimeId,
reason: "stopped",
pinned: false,
retention_state: "normal",
linked_workdir_ids: [],
running_linked: false,
}]
: [],
};
}
function response(state: string, summary?: ReturnType<typeof worker>) {
return Response.json({
workspace_id: workspaceId,
runtime_id: runtimeId,
worker_id: workerId,
result: { state, worker: summary, diagnostics: [] },
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => resolve = r);
return { promise, resolve };
}
function mount() {
const row = worker();
render(WorkersNavSection, {
workspaceId,
currentPath: `/w/${workspaceId}/workers`,
});
render(WorkersPage, {
params: { workspaceId },
data: {
workspaceId,
workers: { items: [row] },
workersError: null,
cleanupPlans: {},
cleanupPlanErrors: {},
} as unknown as ComponentProps<typeof WorkersPage>["data"],
});
}
async function sidebarRestore() {
await fireEvent.click(
await screen.findByRole("button", { name: "Actions for Team Worker" }),
);
return screen.findByRole("menuitem", { name: "Restore" });
}
function pageRestore() {
return screen.getByRole("button", { name: "Restore Team Worker" });
}
let current = worker();
let handleRestore: (init?: RequestInit) => Promise<Response>;
let requests: ReturnType<typeof vi.fn>;
beforeEach(() => {
transport.listener = null;
current = worker();
handleRestore = async () => response("accepted");
requests = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === restoreUrl) return handleRestore(init);
if (url.endsWith("/workers")) {
return Response.json({
workspace_id: workspaceId,
limit: 100,
source: "catalog",
items: [current],
diagnostics: [],
});
}
if (url.endsWith("/cleanup-plan")) {
return Response.json(plan(current.state));
}
if (url.endsWith("/working-directories")) {
return Response.json({
workspace_id: workspaceId,
items: [],
diagnostics: [],
});
}
throw new Error(`Unexpected request ${url}`);
});
vi.stubGlobal("fetch", requests);
clearWorkspaceAlerts();
});
afterEach(() => {
cleanup();
disposeWorkspaceWorkersStore(workspaceId);
vi.unstubAllGlobals();
clearWorkspaceAlerts();
});
async function ready() {
await waitFor(() => expect(transport.listener).not.toBeNull());
transport.listener!.onFrame(frame(current));
await waitFor(() =>
expect((pageRestore() as HTMLButtonElement).disabled).toBe(false)
);
await screen.findByRole("button", { name: "Delete Team Worker" });
}
test.each(["sidebar", "page"] as const)(
"%s Restore updates both actual components through the real store and invalidates cleanup without reload",
async (entrance) => {
const pending = deferred<Response>();
handleRestore = () => pending.promise;
mount();
await ready();
await fireEvent.click(
entrance === "sidebar" ? await sidebarRestore() : pageRestore(),
);
const operations = requests.mock.calls.filter(([url]) =>
String(url) === restoreUrl
);
expect(operations).toHaveLength(1);
expect(operations[0][1].method).toBe("POST");
expect(JSON.parse(operations[0][1].body)).toEqual({
expected_observation_token: "generation-1",
request_id: expect.any(String),
});
current = worker("idle", "generation-2");
transport.listener!.onFrame(frame(current));
await screen.findByRole("cell", { name: "idle" });
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Delete Team Worker" }))
.toBeNull()
);
// A late old response cannot put either component back into stopped/running.
pending.resolve(response("accepted", worker("stopped", "generation-1")));
await waitFor(() =>
expect((pageRestore() as HTMLButtonElement).disabled).toBe(true)
);
expect(screen.queryByRole("cell", { name: "stopped" })).toBeNull();
const menuRestore = await sidebarRestore();
expect((menuRestore as HTMLButtonElement).disabled).toBe(true);
expect(screen.getAllByRole("link", { name: /Team Worker/ })).toHaveLength(
2,
);
expect(requests.mock.calls.filter(([url]) => String(url) === restoreUrl))
.toHaveLength(1);
},
);
test("separate entrances remain independent while Runtime conflict triggers shared refresh, not automatic replay", async () => {
const first = deferred<Response>();
let count = 0;
handleRestore = async () =>
++count === 1 ? first.promise : Response.json({
error: "restore_observation_conflict",
message: "Observation changed",
}, { status: 409 });
const alerts: Array<unknown[]> = [];
const unsubscribe = workspaceAlerts.subscribe((value) => alerts.push(value));
mount();
await ready();
await fireEvent.click(await sidebarRestore());
expect((pageRestore() as HTMLButtonElement).disabled).toBe(false); // not a cross-screen lock
await fireEvent.click(pageRestore());
await waitFor(() =>
expect(JSON.stringify(alerts)).toContain("Worker observation changed")
);
const operations = requests.mock.calls.filter(([url]) =>
String(url) === restoreUrl
);
expect(operations).toHaveLength(2);
const [a, b] = operations.map(([, init]) => JSON.parse(init.body));
expect(a.expected_observation_token).toBe(b.expected_observation_token);
expect(a.request_id).not.toBe(b.request_id);
expect(screen.queryByRole("button", { name: "Retry Restore Team Worker" }))
.toBeNull();
current = worker("idle", "generation-2");
transport.listener!.onFrame(frame(current));
first.resolve(response("accepted"));
await screen.findByRole("cell", { name: "idle" });
await waitFor(() =>
expect((pageRestore() as HTMLButtonElement).disabled).toBe(true)
);
expect(count).toBe(2);
unsubscribe();
});
test.each(["http", "malformed"] as const)(
"%s Restore response never produces success in either entrance",
async (failure) => {
handleRestore = async () =>
failure === "http"
? Response.json({ message: "forbidden restore" }, { status: 403 })
: Response.json({
workspace_id: workspaceId,
runtime_id: runtimeId,
worker_id: workerId,
result: { state: "new-state" },
});
const alerts: Array<unknown[]> = [];
const unsubscribe = workspaceAlerts.subscribe((value) =>
alerts.push(value)
);
mount();
await ready();
await fireEvent.click(pageRestore());
await waitFor(() =>
expect(JSON.stringify(alerts)).toContain(
failure === "http" ? "forbidden restore" : "outcome unknown",
)
);
expect(JSON.stringify(alerts)).not.toContain("Worker restored");
expect(screen.getByRole("cell", { name: "stopped" })).toBeTruthy();
// The sidebar is a second explicit entrance, not an automatic retry.
await fireEvent.click(await sidebarRestore());
await waitFor(() =>
expect(requests.mock.calls.filter(([url]) => String(url) === restoreUrl))
.toHaveLength(2)
);
expect(JSON.stringify(alerts)).not.toContain("Worker restored");
unsubscribe();
},
);
@@ -199,3 +199,22 @@ Deno.test("Composer input uses boundary-aware Up and Down history navigation", a
assert(consoleSource.includes("historyScope={workspaceId}"));
assert(consoleSource.includes("composerInputElement?.recordHistory(value)"));
});
Deno.test("declarative invocation history persists typed values without sharing mutable arguments", () => {
const value: ComposerHistoryEntry = { preserveExactText: false, segments: [{ kind: "feature_invoke", invocation: {
invocation_id: "invoke-1", identity: "test/run", name: "run", arguments: [
{ name: "path", value: { kind: "string", value: "資料/a b" } },
{ name: "count", value: { kind: "integer", value: 2 } },
{ name: "enabled", value: { kind: "boolean", value: true } },
],
} }] };
const history = new ComposerHistory([value]);
const storage = memoryStorage();
saveComposerHistory(storage, "workspace-1", history);
const expected = [{ segments: value.segments, preserveExactText: false }];
assertEquals(loadComposerHistory(storage, "workspace-1").entries, expected);
const copy = history.entries[0].segments[0];
assert(copy.kind === "feature_invoke");
copy.invocation.arguments[0].value = { kind: "string", value: "modified" };
assertEquals(history.entries, expected);
});
@@ -1,7 +1,12 @@
import {
canDeleteSidebarWorker,
canRestoreWorker,
canStopSidebarWorker,
createRestoreRequest,
deleteSidebarWorker,
restoreErrorNotice,
restoreNotice,
restoreWorkspaceWorker,
stopSidebarWorker,
} from "../../src/lib/workspace/sidebar/worker-actions.ts";
import type { Worker } from "../../src/lib/workspace/sidebar/types.ts";
@@ -243,7 +248,176 @@ Deno.test("Worker navigation exposes an accessible hover action menu", async ()
assert(source.includes('aria-haspopup="menu"'));
assert(source.includes('role="menuitem"'));
assert(source.includes("stopSidebarWorker(workspaceId, worker)"));
assert(source.includes("deleteSidebarWorker(workspaceId, worker)"));
assert(source.includes("stopSidebarWorker"));
assert(source.includes("deleteSidebarWorker"));
assert(styles.includes(".worker-nav-item:hover .worker-actions-trigger"));
});
Deno.test("Restore eligibility uses observed catalog state and backend token, not foreground state", () => {
const stopped = {
availability: "observed" as const,
state: "unknown",
lifecycleState: "stopped",
restore_observation_token: "generation",
};
assert(canRestoreWorker(stopped));
assert(!canRestoreWorker({ ...stopped, availability: "unavailable" }));
assert(
!canRestoreWorker({ ...stopped, lifecycleState: "idle", state: "stopped" }),
);
assert(!canRestoreWorker({ ...stopped, restore_observation_token: null }));
assert(
canRestoreWorker({
availability: "observed",
state: "stopped",
restore_observation_token: "generation",
}),
);
});
Deno.test("Restore scoped POST encodes identities and sends immutable intent without assignment query", async () => {
const request = createRestoreRequest({
restore_observation_token: "generation",
});
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
return jsonResponse({
workspace_id: "team space",
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
result: { state: "accepted" },
});
}) as typeof fetch;
await restoreWorkspaceWorker("team space", worker, request, fetchFn);
await restoreWorkspaceWorker("team space", worker, request, fetchFn);
assertEquals(
requests[0].url,
"/api/w/team%20space/runtimes/runtime%20%2F/workers/worker%20%2F/restore",
);
assertEquals(requests[0].init?.method, "POST");
assertEquals(JSON.parse(String(requests[0].init?.body)), request);
assertEquals(requests[1].init?.body, requests[0].init?.body);
assert(request.request_id.length > 0);
});
Deno.test("Restore classification never promotes nonaccepted results and preserves unknown outcome recovery", async () => {
for (
const state of [
"accepted",
"rejected",
"rolled_back",
"reconciliation_required",
] as const
) {
const response = await restoreWorkspaceWorker(
"team",
worker,
{ expected_observation_token: "g", request_id: "id" },
(() =>
Promise.resolve(jsonResponse({
workspace_id: "team",
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
result: {
state,
diagnostics: [{
code: "result",
severity: "warning",
message: "backend reason",
}],
},
}))) as typeof fetch,
);
const notice = restoreNotice(response);
assertEquals(notice.level === "info", state === "accepted");
assertEquals(notice.retry, state === "reconciliation_required");
if (state !== "accepted") assert(notice.message.includes("backend reason"));
}
for (const status of [400, 403, 409, 500]) {
try {
await restoreWorkspaceWorker(
"team",
worker,
{ expected_observation_token: "g", request_id: "id" },
(() =>
Promise.resolve(
jsonResponse({ error: "conflict", message: "changed" }, status),
)) as typeof fetch,
);
throw new Error("must reject");
} catch (error) {
const notice = restoreErrorNotice(error);
assertEquals(notice.retry, status >= 500);
if (status === 409) {
assertEquals(notice.title, "Worker observation changed");
}
}
}
assert(restoreErrorNotice(new TypeError("network failed")).retry);
await assertRejects(
() =>
restoreWorkspaceWorker(
"team",
worker,
{ expected_observation_token: "g", request_id: "id" },
(() =>
Promise.resolve(
jsonResponse({
workspace_id: "other",
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
result: { state: "accepted" },
}),
)) as typeof fetch,
),
"requested target",
);
await assertRejects(
() =>
restoreWorkspaceWorker(
"team",
worker,
{ expected_observation_token: "g", request_id: "id" },
(() =>
Promise.resolve(
jsonResponse({
workspace_id: "team",
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
result: { state: "future" },
}),
)) as typeof fetch,
),
"unknown value",
);
});
Deno.test("Restore bounds the response before decoding and retains uncertain outcome instead of success", async () => {
let response = new Response("{}", {
headers: { "content-length": String(8 * 1024 * 1024 + 1) },
});
let caught: unknown;
try {
await restoreWorkspaceWorker("team", worker, {
expected_observation_token: "g",
request_id: "id",
}, (() => Promise.resolve(response)) as typeof fetch);
} catch (error) {
caught = error;
}
assert(caught instanceof Error);
assert(restoreErrorNotice(caught).retry);
response = new Response("{}", {
status: 409,
headers: { "content-length": String(64 * 1024 + 1) },
});
try {
await restoreWorkspaceWorker("team", worker, {
expected_observation_token: "g",
request_id: "id",
}, (() => Promise.resolve(response)) as typeof fetch);
} catch (error) {
caught = error;
}
assertEquals(restoreErrorNotice(caught).title, "Worker observation changed");
assertEquals(restoreErrorNotice(caught).retry, false);
});
+125 -11
View File
@@ -12,6 +12,7 @@ import {
parseSubjektivSubjectListResponse,
parseSubjektivSubjectResponse,
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
validateSubjektivSubjectCreateRequest,
} from "../src/lib/workspace/memory/api.ts";
@@ -61,9 +62,7 @@ async function assertSubjectCreateRejects(
assertEquals(error.status, expectedStatus);
if (!error.message.includes(expectedMessage)) {
throw new Error(
`expected ${JSON.stringify(error.message)} to include ${
JSON.stringify(expectedMessage)
}`,
`expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`,
);
}
return;
@@ -75,6 +74,8 @@ function subject(id = "subject-1") {
return {
id,
role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active",
store_revision: 12,
created_at: "2026-09-01T00:00:00Z",
@@ -82,6 +83,30 @@ function subject(id = "subject-1") {
};
}
function currentWorker() {
return {
runtime_id: "runtime-1",
worker_id: "worker-1",
host_id: "host-1",
display_name: "Release Worker",
label: "Release Worker",
profile: "builtin:companion",
singleton_key: "subjektiv:subject-1",
tags: [],
workspace: {
visibility: "workspace",
identity: "workspace-1",
workspace_id: "workspace-1",
},
state: "idle",
last_seen_at: "2026-09-02T00:00:00Z",
pinned: false,
retention_state: "retained",
implementation: { kind: "embedded", display_hint: "Embedded" },
diagnostics: [],
};
}
function queryItem(id = "memory-1") {
return {
id,
@@ -187,14 +212,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
() =>
parseSubjektivSubjectListResponse({
limit: 100,
items: Array.from({ length: 101 }, (_, index) =>
subject(`subject-${index}`)),
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
has_more: false,
}),
"bounded array",
);
});
Deno.test("Subject parser preserves live Worker connection state", () => {
assertEquals(
parseSubjektivSubjectResponse({
...subject(),
current_worker: currentWorker(),
}),
{ ...subject(), current_worker: currentWorker() },
);
assertEquals(
parseSubjektivSubjectResponse({ ...subject(), current_worker: null }),
{ ...subject(), current_worker: null },
);
assertThrows(
() =>
parseSubjektivSubjectResponse({
...subject(),
current_worker: { display_name: "Incomplete Worker" },
}),
"current worker.runtime_id",
);
});
Deno.test("Subject list parser preserves the exact bounded response", () => {
const response = { limit: 100, items: [subject()], has_more: false };
assertEquals(parseSubjektivSubjectListResponse(response), response);
@@ -228,7 +274,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
assertEquals(
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
{ role: " Release coordinator " },
{ role: " Release coordinator ", behavior_md: "" },
);
assertThrows(
() => validateSubjektivSubjectCreateRequest({ role: " " }),
@@ -242,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
"at most 256 bytes",
);
assertThrows(
() =>
validateSubjektivSubjectCreateRequest({
role: "Reviewer",
behavior_md: " ".repeat(3),
}),
"empty or contain non-whitespace",
);
assertThrows(
() =>
validateSubjektivSubjectCreateRequest({
role: "Reviewer",
behavior_md: "x".repeat(16 * 1024 + 1),
}),
"at most 16384 bytes",
);
});
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
const requests: Array<{ path: string; init?: RequestInit }> = [];
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
requests.push({ path: String(path), init });
return Response.json({
...subject("subject/one"),
behavior_md: "Line one.\nLine two.",
behavior_revision: 3,
});
}) as typeof fetch;
const updated = await updateSubjektivSubjectBehavior(
fetchFn,
"workspace one",
"subject/one",
{ expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." },
);
assertEquals(updated.behavior_revision, 3);
assertEquals(updated.behavior_md, "Line one.\nLine two.");
assertEquals(
requests[0].path,
"/api/w/workspace%20one/subjektiv/subjects/subject%2Fone/behavior",
);
assertEquals(requests[0].init?.method, "PATCH");
assertEquals(
requests[0].init?.body,
'{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}',
);
});
Deno.test("Subject behavior update reports conflicts without claiming Worker application", async () => {
const conflictFetch =
(async () =>
Response.json({ message: "revision conflict" }, {
status: 409,
})) as typeof fetch;
await assertSubjectCreateRejects(
() =>
updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", {
expected_behavior_revision: 1,
behavior_md: "new",
}),
"rejected",
"changed elsewhere",
409,
);
});
Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => {
@@ -251,20 +361,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the
return Response.json({
...subject("subject-created"),
role: " Release coordinator ",
behavior_md: "Stay concise.",
}, { status: 201 });
}) as typeof fetch;
const created = await createSubjektivSubject(fetchFn, "workspace one", {
role: " Release coordinator ",
behavior_md: "Stay concise.",
});
assertEquals(created, {
...subject("subject-created"),
role: " Release coordinator ",
behavior_md: "Stay concise.",
});
assertEquals(requests.length, 1);
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
assertEquals(requests[0].init?.method, "POST");
assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}');
assertEquals(
requests[0].init?.body,
'{"role":" Release coordinator ","behavior_md":"Stay concise."}',
);
});
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {
@@ -328,8 +444,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
throw new TypeError("connection reset");
}) as typeof fetch;
await assertSubjectCreateRejects(
() =>
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
"unknown_outcome",
"may have been created",
);
@@ -433,8 +548,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
assertThrows(
() =>
parseSubjektivMemoryQueryResponse({
items: Array.from({ length: 101 }, (_, index) =>
queryItem(`memory-${index}`)),
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
has_more: false,
}),
"bounded array",
+182 -6
View File
@@ -6,6 +6,7 @@ import {
parseRuntimeCleanupExecution,
parseRuntimeCleanupPlan,
parseRuntimeWorkerLifecycleResult,
parseWorkerRestoreResponse,
parseWorkerRetentionResponse,
} from "../src/lib/workspace/api/runtime-workers.ts";
@@ -19,7 +20,9 @@ function assertThrows(operation: () => unknown, expected: string): void {
} 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 error containing ${expected}, received ${message}`,
);
}
throw new Error("expected operation to throw");
}
@@ -67,7 +70,10 @@ function plan() {
Deno.test("Runtime cleanup parsers accept the generated exact response shapes", () => {
const parsedPlan = parseRuntimeCleanupPlan(plan());
assert(parsedPlan.workers[0]?.runtime_worker_id === "worker-1", "worker candidate missing");
assert(
parsedPlan.workers[0]?.runtime_worker_id === "worker-1",
"worker candidate missing",
);
const execution = parseRuntimeCleanupExecution({
workspace_id: "workspace-a",
@@ -82,7 +88,10 @@ Deno.test("Runtime cleanup parsers accept the generated exact response shapes",
plan_after: plan(),
diagnostics: [],
});
assert(execution.results[0]?.status === "deleted", "execution result missing");
assert(
execution.results[0]?.status === "deleted",
"execution result missing",
);
const lifecycle = parseRuntimeWorkerLifecycleResult({
state: "accepted",
@@ -108,15 +117,182 @@ Deno.test("Runtime cleanup parsers reject unknown fields and unsafe values", ()
"not part of the wire contract",
);
assertThrows(
() => parseRuntimeCleanupPlan({ ...plan(), workers: new Array(1_001).fill(plan().workers[0]) }),
() =>
parseRuntimeCleanupPlan({
...plan(),
workers: new Array(1_001).fill(plan().workers[0]),
}),
"at most 1000",
);
assertThrows(
() => parseRuntimeCleanupPlan({ ...plan(), workers: [{ ...plan().workers[0], estimated_reclaim_bytes: Number.MAX_SAFE_INTEGER + 1 }] }),
() =>
parseRuntimeCleanupPlan({
...plan(),
workers: [{
...plan().workers[0],
estimated_reclaim_bytes: Number.MAX_SAFE_INTEGER + 1,
}],
}),
"safe integer",
);
assertThrows(
() => parseRuntimeWorkerLifecycleResult({ state: "future", runtime_id: "runtime-a", worker_id: "worker-1", diagnostics: [] }),
() =>
parseRuntimeWorkerLifecycleResult({
state: "future",
runtime_id: "runtime-a",
worker_id: "worker-1",
diagnostics: [],
}),
"unknown value",
);
});
function restore(state = "accepted", worker?: unknown) {
return {
workspace_id: "workspace-a",
runtime_id: "runtime-a",
worker_id: "worker-1",
result: { state, diagnostics: [], worker },
};
}
function restoredWorker() {
return {
runtime_id: "runtime-a",
worker_id: "worker-1",
resource_key: "W-1",
host_id: "runtime-a",
label: "Worker",
availability: "observed",
state: "idle",
workspace: {
visibility: "workspace",
identity: "registered",
workspace_id: "workspace-a",
},
implementation: { kind: "runtime", display_hint: "Runtime" },
restore_observation_token: "generation-b",
};
}
Deno.test("Restore parser preserves all four states and optional Workspace summary", () => {
for (
const state of [
"accepted",
"rejected",
"rolled_back",
"reconciliation_required",
]
) {
assert(
parseWorkerRestoreResponse(restore(state)).result.state === state,
"state lost",
);
assert(
parseWorkerRestoreResponse(restore(state, null)).result.worker === null,
"null lost",
);
}
const parsed = parseWorkerRestoreResponse(
restore("accepted", restoredWorker()),
);
assert(
parsed.result.worker?.resource_key === "W-1",
"Workspace summary lost",
);
assert(parsed.result.worker?.state === "idle", "must not force running");
});
Deno.test("Restore parser bounds diagnostics, validates wrapper/summary identity and rejects malformed results", () => {
assertThrows(() => parseWorkerRestoreResponse(restore("accepted", {
...restoredWorker(), restore_observation_token: "😀".repeat(65),
})), "256 UTF-8 byte limit");
assertThrows(
() => parseWorkerRestoreResponse(restore("future")),
"unknown value",
);
assertThrows(
() => parseWorkerRestoreResponse({ ...restore(), runtime_id: null }),
"must be a string",
);
assertThrows(
() => parseWorkerRestoreResponse({ ...restore(), extra: true }),
"not part of the wire contract",
);
assertThrows(
() =>
parseWorkerRestoreResponse({
...restore(),
result: {
state: "accepted",
diagnostics: new Array(65).fill({
code: "e",
severity: "error",
message: "e",
}),
},
}),
"at most 64",
);
assertThrows(
() =>
parseWorkerRestoreResponse({
...restore(),
result: {
state: "rejected",
diagnostics: [{
code: "e",
severity: "error",
message: "x".repeat(4097),
}],
},
}),
"4096",
);
assertThrows(
() =>
parseWorkerRestoreResponse({
...restore(),
result: {
state: "rejected",
diagnostics: [{ code: "e", severity: "invalid", message: "e" }],
},
}),
"unknown value",
);
assertThrows(
() =>
parseWorkerRestoreResponse(
restore("accepted", { ...restoredWorker(), worker_id: "other" }),
),
"identity",
);
assertThrows(
() =>
parseWorkerRestoreResponse(
restore("accepted", {
...restoredWorker(),
workspace: {
visibility: "workspace",
identity: "registered",
workspace_id: "other",
},
}),
),
"identity",
);
assertThrows(
() =>
parseWorkerRestoreResponse(
restore("accepted", { worker_id: "worker-1", state: "idle" }),
),
"availability",
);
assertThrows(
() =>
parseWorkerRestoreResponse({
state: "accepted",
runtime_id: "runtime-a",
worker_id: "worker-1",
diagnostics: [],
}),
"wire contract",
);
});
+20
View File
@@ -23,6 +23,26 @@ const summary = {
},
};
Deno.test("logical config Workdir source preserves WIP metadata without filesystem authority", () => {
const source = { kind: "workspace_config", access: "read_only", content_path: "/workspace-config", purpose: "Workspace configuration" };
const response = (source: unknown) => ({ workspace_id: "workspace-a", item: {
working_directory_id: "config-1", source, materializer_kind: "logical_workspace_config", status: "active",
}, diagnostics: [] });
const detail = parseWorkingDirectoryDetailResponse(response(source));
if (JSON.stringify(detail.item.source) !== JSON.stringify(source) || detail.runtime_id !== undefined) {
throw new Error("logical config identity was lost or acquired a fake Runtime");
}
for (const invalid of [
{ ...source, access: "command" }, { ...source, content_path: "/home/config" },
{ ...source, grant_id: "external-grant" }, { ...source, repository_key: "main" },
{ ...source, token: "secret" }, { kind: "repository", repository_key: "main", access: "read_write" },
]) {
let rejected = false;
try { parseWorkingDirectoryDetailResponse(response(invalid)); } catch { rejected = true; }
if (!rejected) throw new Error("mixed or unsafe source metadata was accepted");
}
});
Deno.test("Workdir REST validation accepts the generated list and create contracts", () => {
const list = parseWorkingDirectoryListResponse({
workspace_id: "workspace-a",