chore: merge develop into hare/develop
This commit is contained in:
@@ -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
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+20
-9
@@ -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"
|
||||
|
||||
+269
-46
@@ -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 {
|
||||
|
||||
+283
-12
@@ -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();
|
||||
},
|
||||
);
|
||||
+314
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user