feat: replace revision counters with domain state contracts
Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts. Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
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,../../resources/config-schema --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-removal.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 src/lib/workspace/workers/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 src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --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 src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.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 src/lib/workspace/workers/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 src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
|
||||
@@ -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:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12)
|
||||
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
|
||||
// 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
|
||||
|
||||
@@ -10,7 +10,10 @@ export type DriveEntry = {
|
||||
parent: DriveEntryRef | null;
|
||||
name: string;
|
||||
kind: DriveEntryKind;
|
||||
revision: string;
|
||||
/**
|
||||
* Request ID of the last committed mutation, or empty for the immutable root.
|
||||
*/
|
||||
last_mutation_id: string;
|
||||
/**
|
||||
* File length in bytes; absent for folders.
|
||||
*/
|
||||
@@ -74,9 +77,9 @@ export type DriveReadChunkQuery = {
|
||||
entry_workspace_id: string;
|
||||
id: string;
|
||||
/**
|
||||
* Bind a multi-chunk read to the same file revision.
|
||||
* Require each chunk to match the same committed mutation observed in metadata.
|
||||
*/
|
||||
expected_revision: string;
|
||||
expected_mutation_id: string;
|
||||
offset: number;
|
||||
length: number;
|
||||
};
|
||||
@@ -84,7 +87,7 @@ export type DriveReadChunkQuery = {
|
||||
export type DriveDownloadQuery = {
|
||||
entry_workspace_id: string;
|
||||
id: string;
|
||||
expected_revision: string | null;
|
||||
expected_mutation_id: string | null;
|
||||
};
|
||||
|
||||
export type DriveMutation = {
|
||||
@@ -100,16 +103,16 @@ export type DriveMutation = {
|
||||
} | {
|
||||
"operation": "update_text";
|
||||
id: DriveEntryRef;
|
||||
expected_revision: string;
|
||||
expected_mutation_id: string;
|
||||
text: string;
|
||||
content_type: string;
|
||||
} | {
|
||||
"operation": "relocate";
|
||||
id: DriveEntryRef;
|
||||
expected_revision: string;
|
||||
expected_mutation_id: string;
|
||||
parent: DriveEntryRef;
|
||||
name: string;
|
||||
} | { "operation": "delete"; id: DriveEntryRef; expected_revision: string };
|
||||
} | { "operation": "delete"; id: DriveEntryRef; expected_mutation_id: string };
|
||||
|
||||
export type DriveMutationRequest = {
|
||||
request_id: string;
|
||||
@@ -133,7 +136,7 @@ export type DriveUploadQuery = {
|
||||
parent_id: string | null;
|
||||
name: string | null;
|
||||
id: string | null;
|
||||
expected_revision: string | null;
|
||||
expected_mutation_id: string | null;
|
||||
content_type: string;
|
||||
/**
|
||||
* Values above the file limit still decode as u32 so the service returns
|
||||
|
||||
@@ -24,7 +24,6 @@ export type WorkspaceRepositoryRecord = {
|
||||
provider: string | null;
|
||||
source: RepositorySource;
|
||||
default_ref: string | null;
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: RepositoryObservedStatus;
|
||||
observed_at: string | null;
|
||||
@@ -35,7 +34,6 @@ export type WorkspaceRepositoryRecord = {
|
||||
export type WorkspaceCreateResponse = {
|
||||
workspace: WorkspaceSummary;
|
||||
repository: WorkspaceRepositoryRecord | null;
|
||||
config_revision: number;
|
||||
request_fingerprint: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
@@ -92,9 +90,9 @@ export type WorkspaceDeletionPreflightResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
/**
|
||||
* Opaque persisted Workspace metadata revision used as a CAS fence.
|
||||
* Observed Workspace metadata update timestamp used as a CAS fence.
|
||||
*/
|
||||
expected_revision: string;
|
||||
expected_workspace_updated_at: string;
|
||||
can_delete: boolean;
|
||||
resources: WorkspaceDeletionResourceCounts;
|
||||
blockers: Array<WorkspaceDeletionBlocker>;
|
||||
@@ -102,7 +100,7 @@ export type WorkspaceDeletionPreflightResponse = {
|
||||
|
||||
export type WorkspaceDeletionRequest = {
|
||||
operation_id: string;
|
||||
expected_revision: string;
|
||||
expected_workspace_updated_at: string;
|
||||
confirmation: string;
|
||||
};
|
||||
|
||||
@@ -155,14 +153,14 @@ export type WorkspaceMetadataSettingsResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
revision: string;
|
||||
updated_at: string;
|
||||
source: string;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type UpdateWorkspaceMetadataRequest = {
|
||||
display_name: string;
|
||||
revision: string;
|
||||
expected_updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceMetadataMutationResponse = {
|
||||
@@ -178,7 +176,6 @@ export type WorkspaceSigningIdentityPublic = {
|
||||
algorithm: string;
|
||||
public_key?: string;
|
||||
public_key_fingerprint?: string;
|
||||
revision: number;
|
||||
state: WorkspaceSigningIdentityState;
|
||||
created_at: string;
|
||||
provisioned_at?: string;
|
||||
@@ -191,7 +188,6 @@ export type WorkspacePublicIdentityBundle = {
|
||||
algorithm: string;
|
||||
public_key: string;
|
||||
public_key_fingerprint: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
export type WorkspaceSigningIdentityResponse = {
|
||||
@@ -209,7 +205,6 @@ export type ConfigEntry = {
|
||||
};
|
||||
|
||||
export type ConfigTreeSnapshot = {
|
||||
revision: number;
|
||||
digest: string;
|
||||
entries: { [key in string]: ConfigEntry };
|
||||
};
|
||||
@@ -267,7 +262,6 @@ export type WorkspaceConfigTreeResponse = {
|
||||
};
|
||||
|
||||
export type ConfigCommitRequest = {
|
||||
base_revision: number;
|
||||
base_digest: string;
|
||||
changes: Array<ConfigTreeChange>;
|
||||
entrypoints: Array<string>;
|
||||
@@ -316,7 +310,6 @@ export type WorkspaceConfigNode = {
|
||||
export type WorkspaceConfigObserveResponse = {
|
||||
connection_id: string;
|
||||
validator: string;
|
||||
revision: number;
|
||||
digest: string;
|
||||
/**
|
||||
* Canonical commit entrypoints from this exact metadata snapshot.
|
||||
@@ -347,7 +340,6 @@ export type WorkspaceConfigCommitRequest = {
|
||||
|
||||
export type WorkspaceConfigCommitResponse = {
|
||||
validator: string;
|
||||
revision: number;
|
||||
digest: string;
|
||||
};
|
||||
|
||||
@@ -376,8 +368,6 @@ export type WorkspaceConfigGrantResponse = {
|
||||
|
||||
export type ProfileSettingsResponse = {
|
||||
workspace_id: string;
|
||||
registry_revision: string;
|
||||
config_revision?: number | null;
|
||||
tree_digest?: string | null;
|
||||
projection_digest?: string | null;
|
||||
default_profile?: string | null;
|
||||
@@ -406,7 +396,6 @@ export type WorkspaceProfileSourceSummary = {
|
||||
content_digest: string;
|
||||
provenance: WorkspaceProfileSourceProvenance;
|
||||
editable: boolean;
|
||||
revision: string;
|
||||
size_bytes: number;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
@@ -445,7 +434,7 @@ export type RepositorySshConnectionProbeResponse = {
|
||||
port: number;
|
||||
trust_state: RepositorySshConnectionTrustState;
|
||||
host_trust_id: string;
|
||||
expected_host_trust_revision: number | null;
|
||||
expected_host_key_fingerprint: string | null;
|
||||
candidates: Array<RepositorySshHostKeyCandidate>;
|
||||
};
|
||||
|
||||
@@ -453,7 +442,7 @@ export type ConfirmRepositorySshHostTrustRequest = {
|
||||
operation_id: string;
|
||||
runtime_id: string;
|
||||
host_key: string;
|
||||
expected_host_trust_revision: number | null;
|
||||
expected_host_key_fingerprint: string | null;
|
||||
};
|
||||
|
||||
export type RepositoryLogResponse = {
|
||||
|
||||
@@ -15,60 +15,242 @@ export const SUBJEKTIV_API_LIMITS = {
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
severity: DiagnosticSeverity;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, };
|
||||
export type MemoryDocumentResponse = {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
};
|
||||
|
||||
export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson";
|
||||
export type MemoryCandidateKind =
|
||||
| "preference"
|
||||
| "working_assumption"
|
||||
| "constraint"
|
||||
| "decision"
|
||||
| "open_question"
|
||||
| "lesson";
|
||||
|
||||
export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown";
|
||||
export type MemoryEvidenceOriginKind =
|
||||
| "human_input"
|
||||
| "worker_input"
|
||||
| "flow_instruction"
|
||||
| "backend_instruction"
|
||||
| "model_output"
|
||||
| "tool_output"
|
||||
| "derived_summary"
|
||||
| "legacy_unknown";
|
||||
|
||||
export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_revision?: number | null, };
|
||||
export type MemoryEvidenceOrigin = {
|
||||
kind: MemoryEvidenceOriginKind;
|
||||
account_id?: string | null;
|
||||
workspace_id?: string | null;
|
||||
runtime_id?: string | null;
|
||||
worker_id?: string | null;
|
||||
flow_selector?: string | null;
|
||||
flow_definition_id?: string | null;
|
||||
flow_definition_fingerprint?: number | null;
|
||||
};
|
||||
|
||||
export type MemorySourceRef = { segment_id: string, range: [number, number], };
|
||||
export type MemorySourceRef = { segment_id: string; range: [number, number] };
|
||||
|
||||
export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, };
|
||||
export type MemoryStagingEvidence = {
|
||||
id: string;
|
||||
kind: string;
|
||||
entry_range: [number, number] | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
excerpt: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, };
|
||||
export type MemorySourceEvidenceRef = {
|
||||
session_id: string | null;
|
||||
segment_id: string | null;
|
||||
entry_range: [number, number] | null;
|
||||
evidence_id: string | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
evidence_kind: string | null;
|
||||
label: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array<MemoryStagingEvidence>, source_refs: Array<MemorySourceEvidenceRef>, };
|
||||
export type MemoryStagingRecord = {
|
||||
schema_version: number;
|
||||
id: string;
|
||||
extract_run_id: string;
|
||||
source: MemorySourceRef;
|
||||
kind: MemoryCandidateKind;
|
||||
claim: string;
|
||||
why_useful: string;
|
||||
staleness: string | null;
|
||||
evidence: Array<MemoryStagingEvidence>;
|
||||
source_refs: Array<MemorySourceEvidenceRef>;
|
||||
};
|
||||
|
||||
export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, };
|
||||
export type MemoryStagingEntry = {
|
||||
id: string;
|
||||
byte_len: number;
|
||||
record: MemoryStagingRecord;
|
||||
};
|
||||
|
||||
export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array<MemoryStagingEntry>, diagnostics: Array<Diagnostic>, };
|
||||
export type MemoryStagingListResponse = {
|
||||
limit: number;
|
||||
returned_count: number;
|
||||
total_valid_count: number;
|
||||
invalid_count: number;
|
||||
truncated: boolean;
|
||||
order: string;
|
||||
record_authority: string;
|
||||
items: Array<MemoryStagingEntry>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
export type SubjektivSubjectCreateRequest = {
|
||||
role: string;
|
||||
behavior_md?: string;
|
||||
};
|
||||
|
||||
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
|
||||
export type SubjektivSubjectBehaviorUpdateRequest = {
|
||||
expected_behavior_md: string;
|
||||
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 SubjektivSubjectResponse = {
|
||||
id: string;
|
||||
role: string;
|
||||
behavior_md: string;
|
||||
state: SubjektivSubjectState;
|
||||
memory_fingerprint: string;
|
||||
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, };
|
||||
export type SubjektivSubjectListResponse = {
|
||||
limit: number;
|
||||
items: Array<SubjektivSubjectResponse>;
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
|
||||
|
||||
export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, };
|
||||
export type SubjektivMemoryChangeRef = { memory_id: string; change_id: string };
|
||||
|
||||
export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready";
|
||||
export type SubjektivResidentSurfaceAvailability =
|
||||
| "ungenerated"
|
||||
| "stale"
|
||||
| "failed"
|
||||
| "ready";
|
||||
|
||||
export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array<SubjektivMemoryRevisionRef>, built_from_store_revision: number, created_at: string, };
|
||||
export type SubjektivResidentSurfaceSnapshot = {
|
||||
snapshot_id: string;
|
||||
body_md: string;
|
||||
memory_refs: Array<SubjektivMemoryChangeRef>;
|
||||
built_from_memory_fingerprint: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, };
|
||||
export type SubjektivResidentSurfaceResponse = {
|
||||
subject_id: string;
|
||||
availability: SubjektivResidentSurfaceAvailability;
|
||||
snapshot?: SubjektivResidentSurfaceSnapshot | null;
|
||||
};
|
||||
|
||||
export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, };
|
||||
export type SubjektivMemoryQueryItem = {
|
||||
id: string;
|
||||
change_id: string;
|
||||
kind: MemoryCandidateKind;
|
||||
state: SubjektivMemoryState;
|
||||
claim: string;
|
||||
excerpt: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type SubjektivMemoryQueryResponse = { items: Array<SubjektivMemoryQueryItem>, next_cursor?: string | null, has_more: boolean, };
|
||||
export type SubjektivMemoryQueryResponse = {
|
||||
items: Array<SubjektivMemoryQueryItem>;
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, };
|
||||
export type SubjektivMemoryEvidence = {
|
||||
id: string;
|
||||
kind: string;
|
||||
entry_range?: [number, number] | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
excerpt?: string | null;
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, };
|
||||
export type SubjektivMemorySourceEvidenceRef = {
|
||||
session_id?: string | null;
|
||||
segment_id?: string | null;
|
||||
entry_range?: [number, number] | null;
|
||||
evidence_id?: string | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
evidence_kind?: string | null;
|
||||
label?: string | null;
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array<SubjektivMemoryEvidence>, evidence_total: number, evidence_truncated: boolean, source_refs: Array<SubjektivMemorySourceEvidenceRef>, source_refs_total: number, source_refs_truncated: boolean, };
|
||||
export type SubjektivMemoryEvidenceCandidate = {
|
||||
candidate_id: string;
|
||||
evidence: Array<SubjektivMemoryEvidence>;
|
||||
evidence_total: number;
|
||||
evidence_truncated: boolean;
|
||||
source_refs: Array<SubjektivMemorySourceEvidenceRef>;
|
||||
source_refs_total: number;
|
||||
source_refs_truncated: boolean;
|
||||
};
|
||||
|
||||
export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array<string>, source_candidates: Array<SubjektivMemoryEvidenceCandidate>, derived_from: Array<SubjektivMemoryRevisionRef>, evidence_next_cursor?: string | null, evidence_has_more: boolean, };
|
||||
export type SubjektivMemoryReadResponse = {
|
||||
memory_id: string;
|
||||
change_id: string;
|
||||
current_change_id: string;
|
||||
kind: MemoryCandidateKind;
|
||||
state: SubjektivMemoryState;
|
||||
claim: string;
|
||||
body_md: string;
|
||||
why_useful: string;
|
||||
staleness?: string | null;
|
||||
change_reason: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
body_offset: number;
|
||||
body_byte_offset: number;
|
||||
body_next_offset?: number | null;
|
||||
body_next_byte_offset?: number | null;
|
||||
body_truncated: boolean;
|
||||
source_candidate_ids: Array<string>;
|
||||
source_candidates: Array<SubjektivMemoryEvidenceCandidate>;
|
||||
derived_from: Array<SubjektivMemoryChangeRef>;
|
||||
evidence_next_cursor?: string | null;
|
||||
evidence_has_more: boolean;
|
||||
};
|
||||
|
||||
export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, };
|
||||
export type SubjektivMemoryChangeItem = {
|
||||
change_id: string;
|
||||
kind: MemoryCandidateKind;
|
||||
state: SubjektivMemoryState;
|
||||
claim: string;
|
||||
change_reason: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array<SubjektivMemoryRevisionItem>, next_cursor?: string | null, has_more: boolean, };
|
||||
export type SubjektivMemoryListChangesResponse = {
|
||||
memory_id: string;
|
||||
current_change_id: string;
|
||||
items: Array<SubjektivMemoryChangeItem>;
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -115,7 +115,7 @@ export type CommandEvent = { "kind": "started", command_id: string, tool_call_id
|
||||
|
||||
export type CompactionLifecycleState = "running" | "done" | "failed" | "interrupted";
|
||||
|
||||
export type CompactionLifecycle = { schema_version: number, compaction_id: string, revision: number, internal_worker?: InternalWorkerRef | null, state: CompactionLifecycleState,
|
||||
export type CompactionLifecycle = { schema_version: number, compaction_id: string, internal_worker?: InternalWorkerRef | null, state: CompactionLifecycleState,
|
||||
/**
|
||||
* Milliseconds since the Unix epoch.
|
||||
*/
|
||||
@@ -240,7 +240,7 @@ export type PendingSubmissionSummary = { submission_id: string,
|
||||
*/
|
||||
preview?: string | null, accepted_at_ms: number, segment_count: number, byte_len: number, };
|
||||
|
||||
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number,
|
||||
export type PendingSubmissionsSnapshot = { notification_count: number,
|
||||
/**
|
||||
* Ordered display-only previews of waiting notifications, each bounded like
|
||||
* PendingSubmissionSummary::preview. Older runtimes omit this field.
|
||||
@@ -281,7 +281,7 @@ export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } }
|
||||
|
||||
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
|
||||
|
||||
export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot,
|
||||
export type InternalWorkerSnapshot = { worker: InternalWorkerRef, session: SessionSnapshot,
|
||||
/**
|
||||
* Public execution metadata for this child. Older snapshots and service
|
||||
* workers may omit it; clients must never substitute the parent's values.
|
||||
@@ -416,9 +416,9 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip
|
||||
|
||||
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
|
||||
|
||||
export type Method = { "method": "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,
|
||||
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, } } | { "method": "clear_pending_submissions", "params": { expected_submission_ids: Array<string>, } } | { "method": "continue_pending", "params": { 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
|
||||
* Client query identity. 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, } };
|
||||
@@ -455,7 +455,7 @@ output?: string | null, disposition?: ToolResultDisposition | null, is_error: bo
|
||||
*/
|
||||
timestamp_ms?: number | null, input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "context_usage", "data": { usage?: ContextUsage | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting,
|
||||
/**
|
||||
* Full revisioned live execution state. `Stopped` remains Runtime
|
||||
* Full live execution state. `Stopped` remains Runtime
|
||||
* catalog authority and is deliberately not represented here.
|
||||
*/
|
||||
state: WorkerStateSnapshot,
|
||||
@@ -468,4 +468,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, 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" };
|
||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, } } | { "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" };
|
||||
|
||||
@@ -7,7 +7,6 @@ export type RepositorySshCredential = {
|
||||
name: string;
|
||||
public_key_algorithm: string;
|
||||
public_key_fingerprint: string;
|
||||
current_revision: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
rotated_at: string | null;
|
||||
@@ -30,7 +29,6 @@ export type GenerateRepositorySshCredentialRequest = {
|
||||
|
||||
export type RepositorySshPublicKey = {
|
||||
credential_id: string;
|
||||
current_revision: number;
|
||||
public_key_algorithm: string;
|
||||
public_key_fingerprint: string;
|
||||
public_key: string;
|
||||
@@ -38,14 +36,14 @@ export type RepositorySshPublicKey = {
|
||||
|
||||
export type RotateRepositorySshCredentialRequest = {
|
||||
operation_id: string;
|
||||
expected_revision: number;
|
||||
expected_public_key_fingerprint: string;
|
||||
private_key: string;
|
||||
passphrase: string | null;
|
||||
};
|
||||
|
||||
export type DeleteRepositorySshCredentialRequest = {
|
||||
operation_id: string;
|
||||
expected_revision: number;
|
||||
expected_public_key_fingerprint: string;
|
||||
};
|
||||
|
||||
export type RepositorySshHostTrust = {
|
||||
@@ -56,7 +54,6 @@ export type RepositorySshHostTrust = {
|
||||
key_algorithm: string;
|
||||
host_key: string;
|
||||
fingerprint: string;
|
||||
current_revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
referenced_repositories: Array<string>;
|
||||
@@ -68,12 +65,12 @@ export type PutRepositorySshHostTrustRequest = {
|
||||
hostname: string;
|
||||
port: number;
|
||||
host_key: string;
|
||||
expected_revision: number | null;
|
||||
expected_fingerprint: string | null;
|
||||
};
|
||||
|
||||
export type DeleteRepositorySshHostTrustRequest = {
|
||||
operation_id: string;
|
||||
expected_revision: number;
|
||||
expected_fingerprint: string;
|
||||
};
|
||||
|
||||
export type RepositoryAccessMode = "read_only" | "read_write";
|
||||
@@ -87,7 +84,6 @@ export type RepositorySshAccessBinding = {
|
||||
|
||||
export type RepositoryAccessProjection = {
|
||||
workspace_id: string;
|
||||
config_revision: number;
|
||||
projection_digest: string;
|
||||
bindings: Array<RepositorySshAccessBinding>;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12)
|
||||
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
|
||||
// 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
|
||||
|
||||
export type ConfirmRepositorySshHostTrustRequest = { expected_host_trust_revision?: number | null; host_key: string; operation_id: string; runtime_id: string; };
|
||||
export type ConfirmRepositorySshHostTrustRequest = { expected_host_key_fingerprint?: string | null; host_key: string; operation_id: string; runtime_id: string; };
|
||||
|
||||
export type CreateWorkspaceRepositoryRequest = { default_ref?: string | null; repository_key: string; source: string; };
|
||||
|
||||
@@ -47,15 +47,15 @@ export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "in
|
||||
|
||||
export type RepositorySshConnectionProbeRequest = { runtime_id: string; };
|
||||
|
||||
export type RepositorySshConnectionProbeResponse = { candidates: Array<RepositorySshHostKeyCandidate>; expected_host_trust_revision?: number | null; host_trust_id: string; hostname: string; port: number; repository_key: string; runtime_id: string; trust_state: RepositorySshConnectionTrustState; workspace_id: string; };
|
||||
export type RepositorySshConnectionProbeResponse = { candidates: Array<RepositorySshHostKeyCandidate>; expected_host_key_fingerprint?: string | null; host_trust_id: string; hostname: string; port: number; repository_key: string; runtime_id: string; trust_state: RepositorySshConnectionTrustState; workspace_id: string; };
|
||||
|
||||
export type RepositorySshConnectionTrustState = "untrusted" | "verified" | "changed";
|
||||
|
||||
export type RepositorySshHostKeyCandidate = { algorithm: string; fingerprint: string; host_key: string; };
|
||||
|
||||
export type RepositorySshHostTrust = { created_at: string; current_revision: number; fingerprint: string; host_key: string; host_trust_id: string; hostname: string; key_algorithm: string; port: number; referenced_repositories?: Array<string>; updated_at: string; workspace_id: string; };
|
||||
export type RepositorySshHostTrust = { created_at: string; fingerprint: string; host_key: string; host_trust_id: string; hostname: string; key_algorithm: string; port: number; referenced_repositories?: Array<string>; updated_at: string; workspace_id: string; };
|
||||
|
||||
export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array<RepositoryDiagnostic> | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; source_revision: number; };
|
||||
export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array<RepositoryDiagnostic> | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; };
|
||||
|
||||
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
|
||||
|
||||
|
||||
@@ -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:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12)
|
||||
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
|
||||
// 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
|
||||
@@ -25,13 +25,13 @@ export type CompletionEntry = { description?: string | null; invocation?: Featur
|
||||
|
||||
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 CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_binding_id?: string | null; public_bundle: RuntimePublicIdentityBundle; workspace_trust_id: string; };
|
||||
|
||||
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?: Array<string>; expected_plan_digest: string; expected_plan_revision: string; workdir_target_ids?: Array<string>; worker_target_ids?: Array<string>; };
|
||||
export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?: Array<string>; expected_plan_digest: string; workdir_target_ids?: Array<string>; worker_target_ids?: Array<string>; };
|
||||
|
||||
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
|
||||
|
||||
@@ -61,17 +61,17 @@ 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 RemoveRuntimeRequest = { expected_binding_id: string; operation_id: string; };
|
||||
|
||||
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
|
||||
|
||||
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number; };
|
||||
export type RevokeRuntimeTrustKeyRequest = { expected_binding_id: string; };
|
||||
|
||||
export type RuntimeCleanupExecutionResponse = { diagnostics: Array<Diagnostic>; executed_at: string; plan_after: RuntimeCleanupPlanResponse; results: Array<RuntimeCleanupExecutionResult>; runtime_id: string; workspace_id: string; };
|
||||
|
||||
export type RuntimeCleanupExecutionResult = { action: CleanupTargetKind; message: string; status: string; target_id: string; };
|
||||
|
||||
export type RuntimeCleanupPlanResponse = { diagnostics: Array<Diagnostic>; digest: string; generated_at: string; revision: string; runtime_id: string; workdirs: Array<CleanupWorkdirCandidate>; workers: Array<CleanupWorkerCandidate>; workspace_id: string; };
|
||||
export type RuntimeCleanupPlanResponse = { diagnostics: Array<Diagnostic>; digest: string; generated_at: string; runtime_id: string; workdirs: Array<CleanupWorkdirCandidate>; workers: Array<CleanupWorkerCandidate>; workspace_id: string; };
|
||||
|
||||
export type RuntimeConfigBundleResult = { availability?: unknown; diagnostics?: Array<Diagnostic>; state: WorkerOperationState; };
|
||||
|
||||
@@ -81,7 +81,7 @@ export type RuntimeConnectionDisplayState = "configured" | "verified" | "unavail
|
||||
|
||||
export type RuntimeConnectionTestFailureKind = "authentication" | "authorization" | "network_unreachable" | "timeout" | "tls_or_transport" | "malformed_response" | "protocol_version_mismatch" | "runtime_identity_mismatch" | "configuration";
|
||||
|
||||
export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_revision: number; checked_at: string; connection_state: RuntimeConnectionDisplayState; diagnostics?: Array<Diagnostic>; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; verification?: RuntimeVerificationEvidenceSummary | null; workspace_id: string; };
|
||||
export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_id: string; checked_at: string; connection_state: RuntimeConnectionDisplayState; diagnostics?: Array<Diagnostic>; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; verification?: RuntimeVerificationEvidenceSummary | null; workspace_id: string; };
|
||||
|
||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||
|
||||
@@ -89,7 +89,7 @@ export type RuntimeIdentityAuthority = "runtime_registry_projection" | "server_r
|
||||
|
||||
export type RuntimeListResponse = { diagnostics?: Array<Diagnostic>; items: Array<RuntimeSummary>; limit: number; source: string; workspace_id: string; };
|
||||
|
||||
export type RuntimeManagementApiError = { current_fingerprint?: string | null; current_revision?: number | null; diagnostics?: Array<Diagnostic>; error: string; message: string; };
|
||||
export type RuntimeManagementApiError = { current_binding_id?: string | null; current_fingerprint?: string | null; diagnostics?: Array<Diagnostic>; error: string; message: string; };
|
||||
|
||||
export type RuntimeManagementSummary = { binding?: WorkspaceRuntimeBindingSummary | null; built_in: boolean; config_managed: boolean; endpoint_configured: boolean; removable: boolean; token_ref_configured: boolean; };
|
||||
|
||||
@@ -113,15 +113,15 @@ export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "worker" | "re
|
||||
|
||||
export type RuntimeTrustAuditAction = "created" | "replaced" | "reactivated" | "revoked";
|
||||
|
||||
export type RuntimeTrustAuditEntry = { action: RuntimeTrustAuditAction; actor_account_id: string; at: string; new_fingerprint?: string | null; old_fingerprint?: string | null; revision: number; };
|
||||
export type RuntimeTrustAuditEntry = { action: RuntimeTrustAuditAction; actor_account_id: string; at: string; binding_id: string; new_fingerprint?: string | null; old_fingerprint?: string | null; };
|
||||
|
||||
export type RuntimeTrustKeyRevealResponse = { public_key: string; };
|
||||
|
||||
export type RuntimeTrustKeyState = { created_at?: string | null; fingerprint?: string | null; revision?: number | null; revoked_at?: string | null; status: RuntimeTrustKeyStatus; updated_at?: string | null; };
|
||||
export type RuntimeTrustKeyState = { binding_id?: string | null; created_at?: string | null; fingerprint?: string | null; revoked_at?: string | null; status: RuntimeTrustKeyStatus; updated_at?: string | null; };
|
||||
|
||||
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
|
||||
|
||||
export type RuntimeVerificationEvidenceSummary = { binding_revision: number; last_checked_at: string; last_outcome: RuntimeVerificationOutcome; runtime_identity_revision: number; runtime_public_key_fingerprint: string; verified_at?: string | null; workspace_identity_revision: number; workspace_key_id: string; workspace_trust_generation: number; };
|
||||
export type RuntimeVerificationEvidenceSummary = { binding_id: string; last_checked_at: string; last_outcome: RuntimeVerificationOutcome; runtime_public_key_fingerprint: string; verified_at?: string | null; workspace_key_id: string; workspace_public_key_fingerprint: string; workspace_trust_id: string; };
|
||||
|
||||
export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed";
|
||||
|
||||
@@ -219,7 +219,7 @@ 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; };
|
||||
export type WorkspaceRuntimeBindingSummary = { binding_id: string; connection_state: RuntimeConnectionDisplayState; state: WorkspaceRuntimeBindingState; verification?: RuntimeVerificationEvidenceSummary | null; workspace_key_id?: string | null; workspace_trust_id?: string | null; };
|
||||
|
||||
export type WorkspaceRuntimeDetail = { endpoint?: string | null; recent_audit?: Array<RuntimeTrustAuditEntry>; runtime: WorkspaceRuntimeResource; trust_key: RuntimeTrustKeyState; workspace_id: string; };
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ export type SkillProvenance = {
|
||||
kind: SkillSourceKind;
|
||||
id: string;
|
||||
virtual_path?: string;
|
||||
revision?: number;
|
||||
source_digest?: string;
|
||||
tree_digest?: string;
|
||||
};
|
||||
@@ -42,10 +41,7 @@ export type SkillActivationStatus = "active" | "inactive";
|
||||
|
||||
export type SkillProjectionStatus = "valid" | "invalid";
|
||||
|
||||
export type SkillProjectionIdentity = {
|
||||
config_revision: number;
|
||||
tree_digest: string;
|
||||
};
|
||||
export type SkillProjectionIdentity = { tree_digest: string };
|
||||
|
||||
export type SkillResourceRef = {
|
||||
kind: string;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12)
|
||||
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
|
||||
// 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
|
||||
|
||||
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
|
||||
|
||||
export type BrowserCloseTicketRequest = { expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; };
|
||||
export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; };
|
||||
|
||||
export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; };
|
||||
|
||||
@@ -19,7 +19,7 @@ export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "impleme
|
||||
|
||||
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
|
||||
|
||||
export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; };
|
||||
export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; };
|
||||
|
||||
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
|
||||
|
||||
@@ -51,7 +51,7 @@ export type MergeRequestReviewSubject = { merge_request_id: string; subject_ref:
|
||||
|
||||
export type MergeRequestState = "open" | "merged" | "closed";
|
||||
|
||||
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
|
||||
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
|
||||
|
||||
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
|
||||
|
||||
@@ -59,7 +59,7 @@ export type MergeStrategy = "fast_forward" | "merge";
|
||||
|
||||
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketWorkflowState | null; };
|
||||
|
||||
export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; revision: string; state: string; title: string; updated_at?: string | null; };
|
||||
export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; state: string; title: string; updated_at?: string | null; };
|
||||
|
||||
export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; };
|
||||
|
||||
@@ -91,7 +91,7 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri
|
||||
|
||||
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
|
||||
|
||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
||||
|
||||
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
|
||||
|
||||
|
||||
@@ -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:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12)
|
||||
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
|
||||
// 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
|
||||
|
||||
@@ -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:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12)
|
||||
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
|
||||
// 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
|
||||
|
||||
@@ -93,7 +93,6 @@ Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
|
||||
JSON.stringify({
|
||||
authority: "workspace-config-skills-v1",
|
||||
projection: {
|
||||
config_revision: 7,
|
||||
tree_digest: "tree-digest",
|
||||
},
|
||||
entries: [{
|
||||
@@ -105,7 +104,6 @@ Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
|
||||
kind: "workspace",
|
||||
id: "workspace:triage-errors",
|
||||
virtual_path: "skills/triage-errors/SKILL.md",
|
||||
revision: 7,
|
||||
source_digest: "source-digest",
|
||||
tree_digest: "tree-digest",
|
||||
},
|
||||
|
||||
@@ -36,7 +36,6 @@ export function parseRepositorySshCredential(
|
||||
"name",
|
||||
"public_key_algorithm",
|
||||
"public_key_fingerprint",
|
||||
"current_revision",
|
||||
"status",
|
||||
"created_at",
|
||||
"rotated_at",
|
||||
@@ -47,7 +46,6 @@ export function parseRepositorySshCredential(
|
||||
readString(record, "name", path);
|
||||
readString(record, "public_key_algorithm", path);
|
||||
readString(record, "public_key_fingerprint", path);
|
||||
readRevision(record, "current_revision", path);
|
||||
readString(record, "status", path);
|
||||
readString(record, "created_at", path);
|
||||
readNullableString(record, "rotated_at", path);
|
||||
@@ -61,13 +59,11 @@ export function parseRepositorySshPublicKey(
|
||||
): RepositorySshPublicKey {
|
||||
const record = readRecord(value, path, [
|
||||
"credential_id",
|
||||
"current_revision",
|
||||
"public_key_algorithm",
|
||||
"public_key_fingerprint",
|
||||
"public_key",
|
||||
]);
|
||||
readString(record, "credential_id", path);
|
||||
readRevision(record, "current_revision", path);
|
||||
readString(record, "public_key_algorithm", path);
|
||||
readString(record, "public_key_fingerprint", path);
|
||||
readString(record, "public_key", path);
|
||||
@@ -94,7 +90,6 @@ export function parseRepositorySshHostTrust(
|
||||
"key_algorithm",
|
||||
"host_key",
|
||||
"fingerprint",
|
||||
"current_revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"referenced_repositories",
|
||||
@@ -112,7 +107,6 @@ export function parseRepositorySshHostTrust(
|
||||
readString(record, "key_algorithm", path);
|
||||
readString(record, "host_key", path);
|
||||
readString(record, "fingerprint", path);
|
||||
readRevision(record, "current_revision", path);
|
||||
readString(record, "created_at", path);
|
||||
readString(record, "updated_at", path);
|
||||
readStringArray(record, "referenced_repositories", path);
|
||||
@@ -125,12 +119,10 @@ export function parseRepositoryAccessProjection(
|
||||
const path = "access_projection";
|
||||
const record = readRecord(value, path, [
|
||||
"workspace_id",
|
||||
"config_revision",
|
||||
"projection_digest",
|
||||
"bindings",
|
||||
]);
|
||||
readString(record, "workspace_id", path);
|
||||
readRevision(record, "config_revision", path);
|
||||
readString(record, "projection_digest", path);
|
||||
const bindings = readArray(record.bindings, `${path}.bindings`);
|
||||
bindings.forEach((binding, index) => {
|
||||
@@ -248,18 +240,3 @@ function readInteger(
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readRevision(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
): number {
|
||||
const revision = readInteger(record, key, path);
|
||||
if (revision < 0) {
|
||||
throw new RepositoryAccessSchemaError(
|
||||
`${path}.${key}`,
|
||||
"a non-negative safe integer",
|
||||
);
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
const RESPONSE_KEYS = [
|
||||
"workspace_id",
|
||||
"runtime_id",
|
||||
"binding_revision",
|
||||
"binding_id",
|
||||
"connection_state",
|
||||
"verification",
|
||||
"checked_at",
|
||||
@@ -107,15 +107,15 @@ export function parseRuntimeConnectionTestResponse(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const bindingRevision = value.binding_revision;
|
||||
const bindingId = value.binding_id;
|
||||
const connectionState = parseConnectionState(value.connection_state);
|
||||
const verification = parseVerificationEvidence(value.verification);
|
||||
if (
|
||||
!isSafeRevision(bindingRevision) ||
|
||||
!isBoundedString(bindingId) ||
|
||||
connectionState === null ||
|
||||
(value.verification !== null && verification === null) ||
|
||||
(verification !== null &&
|
||||
verification.binding_revision !== bindingRevision) ||
|
||||
verification.binding_id !== bindingId) ||
|
||||
(connectionState === "verified" && value.status !== "compatible") ||
|
||||
(connectionState === "verified" && verification !== null &&
|
||||
(verification.last_outcome !== "verified" ||
|
||||
@@ -126,7 +126,7 @@ export function parseRuntimeConnectionTestResponse(
|
||||
return {
|
||||
workspace_id: value.workspace_id,
|
||||
runtime_id: value.runtime_id,
|
||||
binding_revision: bindingRevision,
|
||||
binding_id: bindingId,
|
||||
connection_state: connectionState,
|
||||
verification,
|
||||
checked_at: value.checked_at,
|
||||
@@ -147,10 +147,6 @@ function parseConnectionState(
|
||||
: null;
|
||||
}
|
||||
|
||||
function isSafeRevision(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function parseVerificationEvidence(
|
||||
value: unknown,
|
||||
): RuntimeVerificationEvidenceSummary | null {
|
||||
@@ -159,12 +155,11 @@ function parseVerificationEvidence(
|
||||
"verified_at",
|
||||
"last_checked_at",
|
||||
"last_outcome",
|
||||
"binding_revision",
|
||||
"binding_id",
|
||||
"workspace_key_id",
|
||||
"workspace_identity_revision",
|
||||
"workspace_trust_generation",
|
||||
"workspace_public_key_fingerprint",
|
||||
"workspace_trust_id",
|
||||
"runtime_public_key_fingerprint",
|
||||
"runtime_identity_revision",
|
||||
] as const;
|
||||
if (!isRecord(value) || !hasExactKeys(value, keys)) return null;
|
||||
if (
|
||||
@@ -177,12 +172,11 @@ function parseVerificationEvidence(
|
||||
value.last_outcome !== "challenge_issued" &&
|
||||
value.last_outcome !== "verification_failed" &&
|
||||
value.last_outcome !== "connectivity_failed") ||
|
||||
!isSafeRevision(value.binding_revision) ||
|
||||
!isBoundedString(value.binding_id) ||
|
||||
!isBoundedString(value.workspace_key_id, 128) ||
|
||||
!isSafeRevision(value.workspace_identity_revision) ||
|
||||
!isSafeRevision(value.workspace_trust_generation) ||
|
||||
!isBoundedString(value.runtime_public_key_fingerprint, 128) ||
|
||||
!isSafeRevision(value.runtime_identity_revision)
|
||||
!isBoundedString(value.workspace_public_key_fingerprint) ||
|
||||
!isBoundedString(value.workspace_trust_id) ||
|
||||
!isBoundedString(value.runtime_public_key_fingerprint, 128)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -190,12 +184,11 @@ function parseVerificationEvidence(
|
||||
verified_at: value.verified_at,
|
||||
last_checked_at: value.last_checked_at,
|
||||
last_outcome: value.last_outcome,
|
||||
binding_revision: value.binding_revision,
|
||||
binding_id: value.binding_id,
|
||||
workspace_key_id: value.workspace_key_id,
|
||||
workspace_identity_revision: value.workspace_identity_revision,
|
||||
workspace_trust_generation: value.workspace_trust_generation,
|
||||
workspace_public_key_fingerprint: value.workspace_public_key_fingerprint,
|
||||
workspace_trust_id: value.workspace_trust_id,
|
||||
runtime_public_key_fingerprint: value.runtime_public_key_fingerprint,
|
||||
runtime_identity_revision: value.runtime_identity_revision,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,16 +27,20 @@ import type {
|
||||
} from "#lib/generated/runtime-api.ts";
|
||||
import { workspaceApiPath } from "./http.ts";
|
||||
|
||||
export type WorkspaceRuntimeList = Omit<WorkspaceRuntimeListResponse, "diagnostics"> & {
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
export type ValidatedWorkspaceRuntimeDetail = Omit<
|
||||
WorkspaceRuntimeDetail,
|
||||
"recent_audit"
|
||||
> & {
|
||||
recent_audit: RuntimeTrustAuditEntry[];
|
||||
};
|
||||
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
|
||||
export type WorkspaceRuntimeList =
|
||||
& Omit<WorkspaceRuntimeListResponse, "diagnostics">
|
||||
& {
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
export type ValidatedWorkspaceRuntimeDetail =
|
||||
& Omit<
|
||||
WorkspaceRuntimeDetail,
|
||||
"recent_audit"
|
||||
>
|
||||
& {
|
||||
recent_audit: RuntimeTrustAuditEntry[];
|
||||
};
|
||||
export type RuntimeTrustConflictKind = "stale_binding" | "fingerprint_in_use";
|
||||
export type RuntimeTrustConflictResponse = RuntimeManagementApiError & {
|
||||
error: RuntimeTrustConflictKind;
|
||||
};
|
||||
@@ -83,7 +87,7 @@ const AUDIT_ACTIONS = new Set<RuntimeTrustAuditAction>([
|
||||
"revoked",
|
||||
]);
|
||||
const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
|
||||
"stale_revision",
|
||||
"stale_binding",
|
||||
"fingerprint_in_use",
|
||||
]);
|
||||
const BINDING_STATES = new Set<WorkspaceRuntimeBindingState>([
|
||||
@@ -226,8 +230,8 @@ function safeInteger(value: unknown, path: string, minimum = 0): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeRevision(value: unknown, path: string): number {
|
||||
return safeInteger(value, path, 1);
|
||||
function boundedIdentity(value: unknown, path: string): string {
|
||||
return boundedString(value, path, LIMITS.idBytes);
|
||||
}
|
||||
|
||||
function optionalNullableString(
|
||||
@@ -240,20 +244,20 @@ function optionalNullableString(
|
||||
return boundedString(value, path, maxBytes, allowEmpty);
|
||||
}
|
||||
|
||||
function optionalRevision(
|
||||
function optionalIdentity(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): number | undefined {
|
||||
): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return safeRevision(value, path);
|
||||
return boundedIdentity(value, path);
|
||||
}
|
||||
|
||||
function optionalNullableRevision(
|
||||
function optionalNullableIdentity(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): number | null | undefined {
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return safeRevision(value, path);
|
||||
return boundedIdentity(value, path);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, path: string): string {
|
||||
@@ -333,12 +337,11 @@ function runtimeVerification(
|
||||
"verified_at",
|
||||
"last_checked_at",
|
||||
"last_outcome",
|
||||
"binding_revision",
|
||||
"binding_id",
|
||||
"workspace_key_id",
|
||||
"workspace_identity_revision",
|
||||
"workspace_trust_generation",
|
||||
"workspace_public_key_fingerprint",
|
||||
"workspace_trust_id",
|
||||
"runtime_public_key_fingerprint",
|
||||
"runtime_identity_revision",
|
||||
],
|
||||
[],
|
||||
path,
|
||||
@@ -366,32 +369,28 @@ function runtimeVerification(
|
||||
128,
|
||||
),
|
||||
last_outcome: lastOutcome,
|
||||
binding_revision: safeRevision(
|
||||
item.binding_revision,
|
||||
`${path}.binding_revision`,
|
||||
binding_id: boundedIdentity(
|
||||
item.binding_id,
|
||||
`${path}.binding_id`,
|
||||
),
|
||||
workspace_key_id: boundedString(
|
||||
item.workspace_key_id,
|
||||
`${path}.workspace_key_id`,
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
workspace_identity_revision: safeRevision(
|
||||
item.workspace_identity_revision,
|
||||
`${path}.workspace_identity_revision`,
|
||||
workspace_public_key_fingerprint: boundedIdentity(
|
||||
item.workspace_public_key_fingerprint,
|
||||
`${path}.workspace_public_key_fingerprint`,
|
||||
),
|
||||
workspace_trust_generation: safeRevision(
|
||||
item.workspace_trust_generation,
|
||||
`${path}.workspace_trust_generation`,
|
||||
workspace_trust_id: boundedIdentity(
|
||||
item.workspace_trust_id,
|
||||
`${path}.workspace_trust_id`,
|
||||
),
|
||||
runtime_public_key_fingerprint: boundedString(
|
||||
item.runtime_public_key_fingerprint,
|
||||
`${path}.runtime_public_key_fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
runtime_identity_revision: safeRevision(
|
||||
item.runtime_identity_revision,
|
||||
`${path}.runtime_identity_revision`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -403,8 +402,8 @@ function runtimeBinding(
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
["state", "connection_state", "revision"],
|
||||
["workspace_key_id", "workspace_key_generation", "verification"],
|
||||
["state", "connection_state", "binding_id"],
|
||||
["workspace_key_id", "workspace_trust_id", "verification"],
|
||||
path,
|
||||
);
|
||||
const workspaceKeyId = optionalNullableString(
|
||||
@@ -412,15 +411,15 @@ function runtimeBinding(
|
||||
`${path}.workspace_key_id`,
|
||||
LIMITS.idBytes,
|
||||
);
|
||||
const workspaceKeyGeneration = optionalNullableRevision(
|
||||
item.workspace_key_generation,
|
||||
`${path}.workspace_key_generation`,
|
||||
const workspaceTrustId = optionalNullableIdentity(
|
||||
item.workspace_trust_id,
|
||||
`${path}.workspace_trust_id`,
|
||||
);
|
||||
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
|
||||
if (
|
||||
requiresWorkspaceIdentity &&
|
||||
state !== "revoked" &&
|
||||
(workspaceKeyId == null || workspaceKeyGeneration == null)
|
||||
(workspaceKeyId == null || workspaceTrustId == null)
|
||||
) {
|
||||
return fail(path, "requires Workspace signing key identity metadata");
|
||||
}
|
||||
@@ -429,14 +428,14 @@ function runtimeBinding(
|
||||
`${path}.connection_state`,
|
||||
CONNECTION_STATES,
|
||||
);
|
||||
const revision = safeRevision(item.revision, `${path}.revision`);
|
||||
const binding_id = boundedIdentity(item.binding_id, `${path}.binding_id`);
|
||||
const verification = item.verification === undefined
|
||||
? undefined
|
||||
: runtimeVerification(item.verification, `${path}.verification`);
|
||||
if (
|
||||
verification !== undefined && verification.binding_revision !== revision
|
||||
verification !== undefined && verification.binding_id !== binding_id
|
||||
) {
|
||||
return fail(path, "verification must match the current binding revision");
|
||||
return fail(path, "verification must match the current binding binding_id");
|
||||
}
|
||||
if (
|
||||
requiresWorkspaceIdentity &&
|
||||
@@ -453,13 +452,13 @@ function runtimeBinding(
|
||||
return {
|
||||
state,
|
||||
connection_state: connectionState,
|
||||
revision,
|
||||
binding_id,
|
||||
...(workspaceKeyId === undefined
|
||||
? {}
|
||||
: { workspace_key_id: workspaceKeyId }),
|
||||
...(workspaceKeyGeneration === undefined
|
||||
...(workspaceTrustId === undefined
|
||||
? {}
|
||||
: { workspace_key_generation: workspaceKeyGeneration }),
|
||||
: { workspace_trust_id: workspaceTrustId }),
|
||||
...(verification === undefined ? {} : { verification }),
|
||||
};
|
||||
}
|
||||
@@ -566,7 +565,7 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
|
||||
exactKeys(
|
||||
item,
|
||||
["status"],
|
||||
["fingerprint", "revision", "created_at", "updated_at", "revoked_at"],
|
||||
["fingerprint", "binding_id", "created_at", "updated_at", "revoked_at"],
|
||||
path,
|
||||
);
|
||||
const result: RuntimeTrustKeyState = {
|
||||
@@ -576,7 +575,7 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
|
||||
`${path}.fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
revision: optionalNullableRevision(item.revision, `${path}.revision`),
|
||||
binding_id: optionalNullableIdentity(item.binding_id, `${path}.binding_id`),
|
||||
created_at: optionalNullableTimestamp(
|
||||
item.created_at,
|
||||
`${path}.created_at`,
|
||||
@@ -594,12 +593,12 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
|
||||
const hasBinding = result.status !== "unconfigured";
|
||||
if (
|
||||
hasBinding &&
|
||||
(result.fingerprint == null || result.revision == null ||
|
||||
(result.fingerprint == null || result.binding_id == null ||
|
||||
result.created_at == null || result.updated_at == null)
|
||||
) {
|
||||
fail(
|
||||
path,
|
||||
"must include fingerprint, revision, created_at, and updated_at",
|
||||
"must include fingerprint, binding_id, created_at, and updated_at",
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -623,7 +622,7 @@ function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
["action", "actor_account_id", "revision", "at"],
|
||||
["action", "actor_account_id", "binding_id", "at"],
|
||||
["old_fingerprint", "new_fingerprint"],
|
||||
path,
|
||||
);
|
||||
@@ -644,7 +643,7 @@ function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry {
|
||||
`${path}.new_fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
revision: safeRevision(item.revision, `${path}.revision`),
|
||||
binding_id: boundedIdentity(item.binding_id, `${path}.binding_id`),
|
||||
at: timestamp(item.at, `${path}.at`),
|
||||
};
|
||||
}
|
||||
@@ -764,7 +763,7 @@ export function parseRuntimeTrustConflict(
|
||||
exactKeys(
|
||||
response,
|
||||
["error", "message"],
|
||||
["current_revision", "current_fingerprint"],
|
||||
["current_binding_id", "current_fingerprint"],
|
||||
"Runtime trust conflict",
|
||||
);
|
||||
return {
|
||||
@@ -778,9 +777,9 @@ export function parseRuntimeTrustConflict(
|
||||
"Runtime trust conflict.message",
|
||||
LIMITS.conflictMessageBytes,
|
||||
),
|
||||
current_revision: optionalRevision(
|
||||
response.current_revision,
|
||||
"Runtime trust conflict.current_revision",
|
||||
current_binding_id: optionalIdentity(
|
||||
response.current_binding_id,
|
||||
"Runtime trust conflict.current_binding_id",
|
||||
),
|
||||
current_fingerprint: optionalNullableString(
|
||||
response.current_fingerprint,
|
||||
@@ -867,16 +866,6 @@ export function parseRuntimeRemovalOperationResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function revisionForJson(revision: number | null): number | null {
|
||||
if (revision === null) return null;
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust revision is not a safe integer",
|
||||
);
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength !== null) {
|
||||
@@ -959,22 +948,25 @@ function requestErrorFrom(
|
||||
}
|
||||
}
|
||||
|
||||
function throwRuntimeTrustConflict(payload: unknown): never {
|
||||
let conflict: RuntimeTrustConflictResponse;
|
||||
try {
|
||||
conflict = parseRuntimeTrustConflict(payload);
|
||||
} catch {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust conflict response was invalid",
|
||||
);
|
||||
}
|
||||
throw new RuntimeTrustConflictError(conflict);
|
||||
}
|
||||
|
||||
async function finishMutation(
|
||||
response: Response,
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<WorkspaceRuntimeDetail> {
|
||||
const payload = await readBoundedJson(response);
|
||||
if (response.status === 409) {
|
||||
try {
|
||||
throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload));
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeTrustConflictError) throw error;
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust conflict response was invalid",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.status === 409) throwRuntimeTrustConflict(payload);
|
||||
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||
let detail: WorkspaceRuntimeDetail;
|
||||
try {
|
||||
@@ -998,15 +990,17 @@ export async function createRemoteRuntime(
|
||||
request: CreateRemoteRuntimeRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<WorkspaceRuntimeResource> {
|
||||
const enrollmentId = boundedIdentity(request.workspace_trust_id, "workspace_trust_id");
|
||||
const response = await fetchImpl(
|
||||
workspaceApiPath(workspaceId, "/runtimes"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
body: JSON.stringify({ ...request, workspace_trust_id: enrollmentId }),
|
||||
},
|
||||
);
|
||||
const payload = await readBoundedJson(response);
|
||||
if (response.status === 409) throwRuntimeTrustConflict(payload);
|
||||
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||
const runtime = runtimeResource(payload, "Runtime create response");
|
||||
if (runtime.runtime_id !== request.public_bundle.identity_id) {
|
||||
@@ -1173,7 +1167,10 @@ export async function revokeRuntimeTrustKey(
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expected_revision: revisionForJson(request.expected_revision),
|
||||
expected_binding_id: boundedIdentity(
|
||||
request.expected_binding_id,
|
||||
"expected_binding_id",
|
||||
),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -406,7 +406,6 @@ export function parseRuntimeCleanupPlan(
|
||||
"workspace_id",
|
||||
"runtime_id",
|
||||
"generated_at",
|
||||
"revision",
|
||||
"digest",
|
||||
"workers",
|
||||
"workdirs",
|
||||
@@ -428,7 +427,6 @@ export function parseRuntimeCleanupPlan(
|
||||
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(
|
||||
|
||||
@@ -151,7 +151,7 @@ function ticketDetailFixture() {
|
||||
},
|
||||
id: "ticket-id",
|
||||
implementation_reports: [],
|
||||
item_revision: "revision-1",
|
||||
content_digest: "a".repeat(64),
|
||||
linked_objectives: [],
|
||||
merge_request: null,
|
||||
merge_requests: [],
|
||||
@@ -410,7 +410,7 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
|
||||
event_id: "event-1",
|
||||
sequence: 1,
|
||||
subject_ref: "abc123",
|
||||
ticket_item_revision: "ticket-revision",
|
||||
ticket_content_digest: "b".repeat(64),
|
||||
ticket_merge_request_subjects: [
|
||||
{ merge_request_id: "mr-1", subject_ref: "abc123" },
|
||||
],
|
||||
|
||||
@@ -737,7 +737,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
"evidence",
|
||||
"id",
|
||||
"implementation_reports",
|
||||
"item_revision",
|
||||
"content_digest",
|
||||
"linked_objectives",
|
||||
"merge_request",
|
||||
"merge_requests",
|
||||
@@ -792,7 +792,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
`${label}.implementation_reports`,
|
||||
parseTicketEvidenceEvent,
|
||||
),
|
||||
item_revision: string(item.item_revision, `${label}.item_revision`),
|
||||
content_digest: string(item.content_digest, `${label}.content_digest`),
|
||||
linked_objectives: array(
|
||||
item.linked_objectives,
|
||||
`${label}.linked_objectives`,
|
||||
@@ -986,7 +986,6 @@ export function parseObjectiveDetail(value: unknown): ObjectiveDetail {
|
||||
"record_source",
|
||||
"resource_key",
|
||||
"resources",
|
||||
"revision",
|
||||
"state",
|
||||
"title",
|
||||
"updated_at",
|
||||
@@ -1011,7 +1010,6 @@ export function parseObjectiveDetail(value: unknown): ObjectiveDetail {
|
||||
`${label}.resources`,
|
||||
parseObjectiveResource,
|
||||
),
|
||||
revision: string(item.revision, `${label}.revision`),
|
||||
state: string(item.state, `${label}.state`),
|
||||
title: string(item.title, `${label}.title`),
|
||||
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
|
||||
@@ -1138,7 +1136,7 @@ function parseMergeRequestThreadEvent(
|
||||
"requested_by",
|
||||
"reviewer",
|
||||
"subject_ref",
|
||||
"ticket_item_revision",
|
||||
"ticket_content_digest",
|
||||
"ticket_merge_request_subjects",
|
||||
],
|
||||
label,
|
||||
@@ -1152,9 +1150,9 @@ function parseMergeRequestThreadEvent(
|
||||
),
|
||||
reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`),
|
||||
subject_ref: string(item.subject_ref, `${label}.subject_ref`),
|
||||
ticket_item_revision: string(
|
||||
item.ticket_item_revision,
|
||||
`${label}.ticket_item_revision`,
|
||||
ticket_content_digest: string(
|
||||
item.ticket_content_digest,
|
||||
`${label}.ticket_content_digest`,
|
||||
),
|
||||
ticket_merge_request_subjects: array(
|
||||
item.ticket_merge_request_subjects,
|
||||
@@ -1172,7 +1170,7 @@ function parseMergeRequestThreadEvent(
|
||||
"request_event_id",
|
||||
"reviewer",
|
||||
"subject_ref",
|
||||
"ticket_item_revision",
|
||||
"ticket_content_digest",
|
||||
"ticket_merge_request_subjects",
|
||||
], label);
|
||||
const decision = string(item.decision, `${label}.decision`);
|
||||
@@ -1191,9 +1189,9 @@ function parseMergeRequestThreadEvent(
|
||||
),
|
||||
reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`),
|
||||
subject_ref: string(item.subject_ref, `${label}.subject_ref`),
|
||||
ticket_item_revision: string(
|
||||
item.ticket_item_revision,
|
||||
`${label}.ticket_item_revision`,
|
||||
ticket_content_digest: string(
|
||||
item.ticket_content_digest,
|
||||
`${label}.ticket_content_digest`,
|
||||
),
|
||||
ticket_merge_request_subjects: array(
|
||||
item.ticket_merge_request_subjects,
|
||||
|
||||
@@ -14,7 +14,6 @@ export const currentInitialRepository = {
|
||||
provider: "git",
|
||||
source: { kind: "https", uri: "https://example.test/current.git" },
|
||||
default_ref: "main",
|
||||
source_revision: 2,
|
||||
source_fingerprint: "sha256:current",
|
||||
observed_status: "unverified",
|
||||
observed_at: null,
|
||||
@@ -26,7 +25,6 @@ export function creationResponse(repository: unknown = null, replayed = false) {
|
||||
return {
|
||||
workspace: createdWorkspace,
|
||||
repository,
|
||||
config_revision: 1,
|
||||
request_fingerprint: "sha256:creation",
|
||||
replayed,
|
||||
};
|
||||
|
||||
@@ -258,7 +258,6 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
|
||||
"kind",
|
||||
"provider",
|
||||
"source",
|
||||
"source_revision",
|
||||
"source_fingerprint",
|
||||
"observed_status",
|
||||
"observed_at",
|
||||
@@ -291,10 +290,6 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
|
||||
kind: repositoryString(item.kind, `${path}.kind`),
|
||||
provider: repositoryString(item.provider, `${path}.provider`),
|
||||
source: repositorySource(item.source, `${path}.source`),
|
||||
source_revision: repositorySourceRevision(
|
||||
item.source_revision,
|
||||
`${path}.source_revision`,
|
||||
),
|
||||
source_fingerprint: repositoryString(
|
||||
item.source_fingerprint,
|
||||
`${path}.source_fingerprint`,
|
||||
@@ -360,7 +355,6 @@ function workspaceRepositoryRecord(
|
||||
"provider",
|
||||
"source",
|
||||
"default_ref",
|
||||
"source_revision",
|
||||
"source_fingerprint",
|
||||
"observed_status",
|
||||
"observed_at",
|
||||
@@ -383,10 +377,6 @@ function workspaceRepositoryRecord(
|
||||
provider: nullableString(item.provider, `${path}.provider`),
|
||||
source: repositorySource(item.source, `${path}.source`),
|
||||
default_ref: nullableString(item.default_ref, `${path}.default_ref`),
|
||||
source_revision: repositorySourceRevision(
|
||||
item.source_revision,
|
||||
`${path}.source_revision`,
|
||||
),
|
||||
source_fingerprint: repositoryString(
|
||||
item.source_fingerprint,
|
||||
`${path}.source_fingerprint`,
|
||||
@@ -558,7 +548,6 @@ export function parseWorkspaceCreateResponse(
|
||||
[
|
||||
"workspace",
|
||||
"repository",
|
||||
"config_revision",
|
||||
"request_fingerprint",
|
||||
"replayed",
|
||||
],
|
||||
@@ -573,10 +562,6 @@ export function parseWorkspaceCreateResponse(
|
||||
response.repository,
|
||||
"workspace create response.repository",
|
||||
),
|
||||
config_revision: integer(
|
||||
response.config_revision,
|
||||
"workspace create response.config_revision",
|
||||
),
|
||||
request_fingerprint: string(
|
||||
response.request_fingerprint,
|
||||
"workspace create response.request_fingerprint",
|
||||
@@ -789,7 +774,7 @@ export function parseRepositorySshConnectionProbeResponse(
|
||||
"port",
|
||||
"trust_state",
|
||||
"host_trust_id",
|
||||
"expected_host_trust_revision",
|
||||
"expected_host_key_fingerprint",
|
||||
"candidates",
|
||||
],
|
||||
"repository SSH connection probe response",
|
||||
@@ -829,11 +814,10 @@ export function parseRepositorySshConnectionProbeResponse(
|
||||
response.host_trust_id,
|
||||
"repository SSH connection probe response.host_trust_id",
|
||||
),
|
||||
expected_host_trust_revision: response.expected_host_trust_revision === null
|
||||
? null
|
||||
: integer(
|
||||
response.expected_host_trust_revision,
|
||||
"repository SSH connection probe response.expected_host_trust_revision",
|
||||
expected_host_key_fingerprint:
|
||||
response.expected_host_key_fingerprint === null ? null : string(
|
||||
response.expected_host_key_fingerprint,
|
||||
"repository SSH connection probe response.expected_host_key_fingerprint",
|
||||
),
|
||||
candidates: array(
|
||||
response.candidates,
|
||||
@@ -980,7 +964,7 @@ export function parseWorkspaceDeletionPreflightResponse(
|
||||
exactKeys(item, [
|
||||
"workspace_id",
|
||||
"display_name",
|
||||
"expected_revision",
|
||||
"expected_workspace_updated_at",
|
||||
"can_delete",
|
||||
"resources",
|
||||
"blockers",
|
||||
@@ -994,9 +978,9 @@ export function parseWorkspaceDeletionPreflightResponse(
|
||||
item.display_name,
|
||||
"Workspace deletion preflight.display_name",
|
||||
),
|
||||
expected_revision: deletionBoundedString(
|
||||
item.expected_revision,
|
||||
"Workspace deletion preflight.expected_revision",
|
||||
expected_workspace_updated_at: deletionBoundedString(
|
||||
item.expected_workspace_updated_at,
|
||||
"Workspace deletion preflight.expected_workspace_updated_at",
|
||||
WORKSPACE_DELETION_MAX_REVISION_BYTES,
|
||||
),
|
||||
can_delete: boolean(
|
||||
@@ -1116,7 +1100,8 @@ export function parseHostListResponse(value: unknown): HostListResponse {
|
||||
),
|
||||
limit,
|
||||
items: repositoryArray(response.items, "host list response.items").map(
|
||||
(item, index) => parseHostSummary(item, `host list response.items[${index}]`),
|
||||
(item, index) =>
|
||||
parseHostSummary(item, `host list response.items[${index}]`),
|
||||
),
|
||||
source: repositoryString(response.source, "host list response.source"),
|
||||
diagnostics: repositoryArray(
|
||||
|
||||
@@ -10,6 +10,9 @@ const toolchain = vi.hoisted(() => ({
|
||||
analyze: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
close: vi.fn(),
|
||||
applyChanges: vi.fn(),
|
||||
changesBetween: vi.fn(),
|
||||
format: vi.fn(),
|
||||
}));
|
||||
vi.mock("./toolchain.ts", () => ({
|
||||
ConfigSourceToolchain: class {
|
||||
@@ -20,13 +23,15 @@ vi.mock("./toolchain.ts", () => ({
|
||||
analyze = toolchain.analyze;
|
||||
complete = toolchain.complete;
|
||||
close = toolchain.close;
|
||||
applyChanges = toolchain.applyChanges;
|
||||
changesBetween = toolchain.changesBetween;
|
||||
format = toolchain.format;
|
||||
},
|
||||
}));
|
||||
|
||||
const schemaSource = "schema body\n".repeat(6000);
|
||||
const tree = {
|
||||
snapshot: {
|
||||
revision: 7,
|
||||
digest: "sha256:tree",
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
@@ -219,7 +224,6 @@ test("unmount during schema setup closes the toolchain", async () => {
|
||||
test("builtin diagnostics show read-only paths without adding editable workspace files", async () => {
|
||||
toolchain.analyze.mockResolvedValue([{
|
||||
path: "$builtin/profiles/companion.dcdl",
|
||||
revision: 7,
|
||||
tree_digest: "sha256:tree",
|
||||
kind: "constraint_violation",
|
||||
span: { start_byte: 1, end_byte: 4 },
|
||||
@@ -243,7 +247,6 @@ test("builtin diagnostics show read-only paths without adding editable workspace
|
||||
test("unknown builtin diagnostics retain the workspace importer path", async () => {
|
||||
toolchain.analyze.mockResolvedValue([{
|
||||
path: "main.dcdl",
|
||||
revision: 7,
|
||||
tree_digest: "sha256:tree",
|
||||
kind: "import",
|
||||
span: { start_byte: 8, end_byte: 40 },
|
||||
@@ -261,6 +264,36 @@ test("unknown builtin diagnostics retain the workspace importer path", async ()
|
||||
expect(view.getByRole("navigation", { name: "Virtual configuration paths" }).textContent).not.toContain("$builtin/");
|
||||
});
|
||||
|
||||
test.each(["resolve", "reject"] as const)(
|
||||
"superseded analysis %s cannot replace the current diagnostics or status",
|
||||
async (settlement) => {
|
||||
let resolveOld!: (value: unknown[]) => void;
|
||||
let rejectOld!: (error: Error) => void;
|
||||
const oldAnalysis = new Promise<unknown[]>((resolve, reject) => {
|
||||
resolveOld = resolve;
|
||||
rejectOld = reject;
|
||||
});
|
||||
toolchain.analyze.mockReturnValueOnce(oldAnalysis);
|
||||
const view = renderEditor();
|
||||
await expectReady(view);
|
||||
await waitFor(() => expect(toolchain.analyze).toHaveBeenCalledOnce());
|
||||
await fireEvent.click(view.getByRole("button", { name: "Create local source" }));
|
||||
await waitFor(() => expect(toolchain.analyze).toHaveBeenCalledTimes(2));
|
||||
if (settlement === "resolve") {
|
||||
resolveOld([{
|
||||
path: "main.dcdl", tree_digest: tree.snapshot.digest, kind: "import", span: { start_byte: 0, end_byte: 1 },
|
||||
message: "Stale diagnostic", labels: [], notes: [],
|
||||
}]);
|
||||
} else rejectOld(new Error("Stale analysis failure"));
|
||||
await oldAnalysis.catch(() => {});
|
||||
await waitFor(() => {
|
||||
expect(view.container.textContent).toContain("Creating local source module.dcdl");
|
||||
expect(view.container.textContent).not.toContain("Stale diagnostic");
|
||||
expect(view.container.textContent).not.toContain("Stale analysis failure");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("unmount during fetch does not start a toolchain", async () => {
|
||||
let finishFetch!: (response: Response) => void;
|
||||
fetcher.mockReturnValueOnce(
|
||||
@@ -274,3 +307,70 @@ test("unmount during fetch does not start a toolchain", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(toolchain.construct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
test("diagnostics from another tree digest are not displayed", async () => {
|
||||
toolchain.analyze.mockResolvedValue([
|
||||
{
|
||||
path: "main.dcdl", tree_digest: "sha256:old-tree", kind: "syntax",
|
||||
span: { start_byte: 0, end_byte: 1 }, message: "Wrong tree diagnostic",
|
||||
labels: [], notes: [],
|
||||
},
|
||||
{
|
||||
path: "main.dcdl", tree_digest: tree.snapshot.digest, kind: "syntax",
|
||||
span: { start_byte: 0, end_byte: 1 }, message: "Current tree diagnostic",
|
||||
labels: [], notes: [],
|
||||
},
|
||||
]);
|
||||
const view = renderEditor();
|
||||
await expectReady(view);
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Current tree diagnostic"));
|
||||
expect(view.container.textContent).not.toContain("Wrong tree diagnostic");
|
||||
});
|
||||
|
||||
test.each([409, 422])("failed commit %s preserves the local draft and authoritative base digest for retry", async (status) => {
|
||||
const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" };
|
||||
const candidate = {
|
||||
digest: "sha256:local-draft",
|
||||
entries: {
|
||||
...tree.snapshot.entries,
|
||||
"module.dcdl": {
|
||||
path: change.path,
|
||||
content_type: change.content_type,
|
||||
content: change.content,
|
||||
content_digest: "sha256:module",
|
||||
},
|
||||
},
|
||||
};
|
||||
toolchain.applyChanges.mockResolvedValue(candidate);
|
||||
toolchain.changesBetween.mockResolvedValue([change]);
|
||||
toolchain.format.mockImplementation(async (source: string) => source);
|
||||
const view = renderEditor();
|
||||
await expectReady(view);
|
||||
await fireEvent.click(view.getByRole("button", { name: "Create local source" }));
|
||||
const commit = view.getByRole("button", { name: "Commit" });
|
||||
expect(commit).toBeInstanceOf(HTMLButtonElement);
|
||||
fetcher.mockResolvedValueOnce(new Response(
|
||||
status === 409 ? "base content digest mismatch" : "candidate evaluation failed",
|
||||
{ status },
|
||||
));
|
||||
await fireEvent.click(commit);
|
||||
await waitFor(() => expect(view.container.textContent).toContain(
|
||||
status === 409 ? "base content digest mismatch" : "candidate evaluation failed",
|
||||
));
|
||||
const expectedRequest = {
|
||||
base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"],
|
||||
};
|
||||
expect(JSON.parse(fetcher.mock.calls[1][1].body)).toEqual(expectedRequest);
|
||||
expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
|
||||
expect(commit).toHaveProperty("disabled", false);
|
||||
expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
|
||||
if (status === 409) expect(view.getByRole("alert").textContent).toContain("Discard local candidate");
|
||||
|
||||
fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ ...tree, snapshot: candidate })));
|
||||
await fireEvent.click(commit);
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
|
||||
expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual(expectedRequest);
|
||||
expect(toolchain.setSnapshot).toHaveBeenLastCalledWith(candidate, tree.contract.schema_bundle);
|
||||
expect(commit).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
@@ -25,14 +25,13 @@
|
||||
let mounted = false;
|
||||
let busy = $state(false);
|
||||
let workingChanges = $state<ConfigTreeChange[]>([]);
|
||||
let baseRevision = $state(0);
|
||||
let baseDigest = $state("");
|
||||
let renamePath = $state("");
|
||||
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||
let conflict = $state(false);
|
||||
let toolchain = $state.raw<ConfigSourceToolchain | null>(null);
|
||||
let analysisReady = $state(false);
|
||||
let analysisGeneration = 0;
|
||||
let analysisRequest: AbortController | null = null;
|
||||
|
||||
const paths = $derived(
|
||||
treeState ? Object.keys(treeState.snapshot.entries).toSorted() : [],
|
||||
@@ -48,6 +47,7 @@
|
||||
void reload();
|
||||
return () => {
|
||||
mounted = false;
|
||||
analysisRequest?.abort();
|
||||
toolchain?.close();
|
||||
};
|
||||
});
|
||||
@@ -57,20 +57,23 @@
|
||||
const path = selectedPath;
|
||||
const value = source;
|
||||
const ready = analysisReady;
|
||||
const generation = ++analysisGeneration;
|
||||
const digest = treeState?.snapshot.digest;
|
||||
analysisRequest?.abort();
|
||||
const request = new AbortController();
|
||||
analysisRequest = request;
|
||||
diagnostics = [];
|
||||
if (!analyzer || !path || !ready) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
void analyzer.analyze(path, value).then((result) => {
|
||||
if (generation === analysisGeneration) diagnostics = result;
|
||||
if (!request.signal.aborted) diagnostics = result.filter((diagnostic) => diagnostic.tree_digest === digest);
|
||||
}).catch((error) => {
|
||||
if (generation === analysisGeneration) status = `Analyze failed: ${String(error)}`;
|
||||
if (!request.signal.aborted) status = `Analyze failed: ${String(error)}`;
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (generation === analysisGeneration) analysisGeneration += 1;
|
||||
request.abort();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -78,7 +81,7 @@
|
||||
loadState = "loading";
|
||||
status = "Loading source tree…";
|
||||
analysisReady = false;
|
||||
analysisGeneration += 1;
|
||||
analysisRequest?.abort();
|
||||
try {
|
||||
const remote = await fetchConfigTree(workspaceId);
|
||||
if (!mounted) return;
|
||||
@@ -94,14 +97,13 @@
|
||||
}
|
||||
source = selectedPath ? treeState.snapshot.entries[selectedPath].content : "";
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
analysisReady = true;
|
||||
workingChanges = [];
|
||||
renamePath = selectedPath;
|
||||
diagnostics = [];
|
||||
conflict = false;
|
||||
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||
status = `Tree digest ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||
loadState = "ready";
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
@@ -197,7 +199,7 @@
|
||||
|
||||
function recordCommitError(error: unknown) {
|
||||
const message = String(error);
|
||||
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||
conflict = message.includes("conflict") || message.includes("base content digest mismatch");
|
||||
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||
}
|
||||
|
||||
@@ -211,7 +213,6 @@
|
||||
return;
|
||||
}
|
||||
treeState = await commitConfigTree(workspaceId, {
|
||||
base_revision: baseRevision,
|
||||
base_digest: baseDigest,
|
||||
changes: workingChanges,
|
||||
entrypoints: entrypoints(),
|
||||
@@ -219,12 +220,11 @@
|
||||
workingChanges = [];
|
||||
conflict = false;
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
source = treeState.snapshot.entries[selectedPath]?.content ?? "";
|
||||
diagnostics = [];
|
||||
status = `Committed formatted revision ${treeState.snapshot.revision}.`;
|
||||
status = `Committed formatted source tree · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||
} catch (error) {
|
||||
recordCommitError(error);
|
||||
} finally {
|
||||
@@ -241,7 +241,6 @@
|
||||
const localChanges = [...workingChanges];
|
||||
const remote = await fetchConfigTree(workspaceId);
|
||||
baseSnapshot = structuredClone(remote.snapshot);
|
||||
baseRevision = remote.snapshot.revision;
|
||||
baseDigest = remote.snapshot.digest;
|
||||
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||
try {
|
||||
@@ -251,10 +250,10 @@
|
||||
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
||||
conflict = false;
|
||||
status = "Local changes reapplied to the latest revision. Commit to persist them.";
|
||||
status = "Local changes reapplied to the latest source tree. Commit to persist them.";
|
||||
} catch (error) {
|
||||
conflict = true;
|
||||
status = `Local changes conflict with the latest revision: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
|
||||
status = `Local changes conflict with the latest source tree: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ function parseToolchainContract(value: unknown): ToolchainContract {
|
||||
}
|
||||
|
||||
export function parseConfigTreeSnapshot(value: unknown): ConfigTreeSnapshot {
|
||||
const item = record(value, ["revision", "digest", "entries"]);
|
||||
const item = record(value, ["digest", "entries"]);
|
||||
const entriesRecord = objectValue(item.entries);
|
||||
const entries = Object.entries(entriesRecord);
|
||||
if (entries.length > MAX_ENTRY_COUNT) throw schemaError();
|
||||
@@ -253,7 +253,6 @@ export function parseConfigTreeSnapshot(value: unknown): ConfigTreeSnapshot {
|
||||
parsedEntries[boundedPath] = parsed;
|
||||
}
|
||||
return {
|
||||
revision: safeInteger(item.revision),
|
||||
digest: boundedString(item.digest, MAX_DIGEST_BYTES),
|
||||
entries: parsedEntries,
|
||||
};
|
||||
@@ -299,13 +298,13 @@ export async function fetchConfigEntry(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchConfigRevision(
|
||||
export async function fetchConfigHistory(
|
||||
workspaceId: string,
|
||||
revision: number,
|
||||
contentDigest: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<ConfigTreeSnapshot> {
|
||||
return await readJson(
|
||||
await fetcher(`${sourceTreeUrl(workspaceId)}/revisions/${revision}`, {
|
||||
await fetcher(`${sourceTreeUrl(workspaceId)}/history/${encodeURIComponent(contentDigest)}`, {
|
||||
headers: { accept: "application/json" },
|
||||
}),
|
||||
parseConfigTreeSnapshot,
|
||||
|
||||
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ConfigTreeChange } from "./ConfigTreeChange";
|
||||
import type { VirtualPath } from "./VirtualPath";
|
||||
|
||||
export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
|
||||
@@ -3,4 +3,4 @@ import type { ConfigDiagnosticLabel } from "./ConfigDiagnosticLabel";
|
||||
import type { ConfigSpan } from "./ConfigSpan";
|
||||
import type { VirtualPath } from "./VirtualPath";
|
||||
|
||||
export type ConfigDiagnostic = { path: VirtualPath, revision: number, tree_digest: string, kind: string, span: ConfigSpan, message: string, labels: Array<ConfigDiagnosticLabel>, notes: Array<string>, };
|
||||
export type ConfigDiagnostic = { path: VirtualPath, tree_digest: string, kind: string, span: ConfigSpan, message: string, labels: Array<ConfigDiagnosticLabel>, notes: Array<string>, };
|
||||
|
||||
@@ -2,4 +2,11 @@
|
||||
import type { ConfigEntry } from "./ConfigEntry";
|
||||
import type { VirtualPath } from "./VirtualPath";
|
||||
|
||||
export type ConfigTreeSnapshot = { revision: number, digest: string, entries: { [key in VirtualPath]: ConfigEntry }, };
|
||||
export type ConfigTreeSnapshot = {
|
||||
/**
|
||||
* SHA-256 of ordered virtual paths, content types and complete source bytes.
|
||||
* Identifies tree content, not an edit sequence or freshness counter.
|
||||
*/
|
||||
digest: string;
|
||||
entries: { [key in VirtualPath]: ConfigEntry };
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ function reconnectSnapshot(text: string): Event {
|
||||
session: {
|
||||
entries: [],
|
||||
pending_submissions: {
|
||||
revision: 0,
|
||||
|
||||
head_id: null,
|
||||
notification_count: 0,
|
||||
submissions: [],
|
||||
|
||||
@@ -1506,7 +1506,7 @@ Deno.test("legacy compaction lifecycle events are inert", () => {
|
||||
const running = {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-1",
|
||||
revision: 1,
|
||||
|
||||
internal_worker: null,
|
||||
state: "running",
|
||||
started_at_ms: 1_000,
|
||||
@@ -1530,7 +1530,7 @@ Deno.test("legacy compaction lifecycle events are inert", () => {
|
||||
data: {
|
||||
lifecycle: {
|
||||
...running,
|
||||
revision: 2,
|
||||
|
||||
state: "done",
|
||||
ended_at_ms: 4_000,
|
||||
summary: "accepted summary",
|
||||
@@ -1546,7 +1546,7 @@ Deno.test("legacy compaction lifecycle events are inert", () => {
|
||||
data: {
|
||||
lifecycle: {
|
||||
...running,
|
||||
revision: 3,
|
||||
|
||||
state: "failed",
|
||||
ended_at_ms: 5_000,
|
||||
error: "legacy failure",
|
||||
@@ -1580,7 +1580,7 @@ Deno.test("snapshot restores running compaction without staged content", () => {
|
||||
parent_session_id: "parent-session",
|
||||
kind: { service: { kind: "compaction" } },
|
||||
},
|
||||
revision: 1,
|
||||
|
||||
session: canonicalSession([]),
|
||||
status: "running",
|
||||
in_flight: {
|
||||
@@ -1678,7 +1678,7 @@ Deno.test("snapshot does not guess between multiple compaction services", () =>
|
||||
parent_session_id: "parent-session",
|
||||
kind: { service: { kind: "compaction" } },
|
||||
},
|
||||
revision: 1,
|
||||
|
||||
session: canonicalSession([]),
|
||||
status: "running" as const,
|
||||
in_flight: {
|
||||
@@ -1737,7 +1737,7 @@ Deno.test("live compaction keeps its first service binding until terminal progre
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker: workerA,
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "tool_call_start",
|
||||
data: { id: "read-a", name: "ReadEntry" },
|
||||
@@ -1751,7 +1751,7 @@ Deno.test("live compaction keeps its first service binding until terminal progre
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker: workerB,
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "tool_call_start",
|
||||
data: { id: "search-b", name: "SearchSessionEntries" },
|
||||
@@ -1797,7 +1797,7 @@ Deno.test("compaction summary candidate stays private", () => {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "tool_call_start",
|
||||
data: { id: "call-1", name: "write_summary" },
|
||||
@@ -1811,7 +1811,7 @@ Deno.test("compaction summary candidate stays private", () => {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 2,
|
||||
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
@@ -1866,7 +1866,7 @@ Deno.test("runtime compaction progress nests service activity and clears it", ()
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
@@ -1883,7 +1883,7 @@ Deno.test("runtime compaction progress nests service activity and clears it", ()
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 2,
|
||||
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
@@ -1915,7 +1915,7 @@ Deno.test("runtime compaction progress nests service activity and clears it", ()
|
||||
eventId: "compaction-service-removed",
|
||||
event: {
|
||||
event: "internal_worker_removed",
|
||||
data: { worker, revision: 3 },
|
||||
data: { worker },
|
||||
} satisfies Event,
|
||||
}]);
|
||||
assertEquals(projection.internalWorkers, []);
|
||||
@@ -1946,7 +1946,7 @@ Deno.test("snapshot excludes storage-only compaction extension records", () => {
|
||||
payload: {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-orphaned",
|
||||
revision: 2,
|
||||
|
||||
internal_worker: {
|
||||
session_id: "missing-service",
|
||||
name: "Compaction",
|
||||
@@ -2023,7 +2023,7 @@ Deno.test("back-to-back compactions do not retain prior activity", () => {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: { id: "read-1", name: "ReadEntry", arguments: "{}" },
|
||||
@@ -2035,7 +2035,7 @@ Deno.test("back-to-back compactions do not retain prior activity", () => {
|
||||
eventId: "first-removed",
|
||||
event: {
|
||||
event: "internal_worker_removed",
|
||||
data: { worker, revision: 2 },
|
||||
data: { worker },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
@@ -3025,7 +3025,7 @@ Deno.test("projectConsole mirrors live TaskCreate and TaskUpdate calls", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
||||
Deno.test("Internal Worker output stays separate and follows parent stream arrival order", () => {
|
||||
const worker = {
|
||||
session_id: "child-session",
|
||||
name: "research",
|
||||
@@ -3039,7 +3039,7 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 2,
|
||||
|
||||
event: { event: "text_done", data: { text: "child output" } },
|
||||
},
|
||||
},
|
||||
@@ -3057,12 +3057,12 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 1,
|
||||
event: { event: "text_done", data: { text: "stale" } },
|
||||
|
||||
event: { event: "text_done", data: { text: "next output" } },
|
||||
},
|
||||
},
|
||||
}]);
|
||||
assertEquals(projection.internalWorkers[0].console.lines.length, 1);
|
||||
assertEquals(projection.internalWorkers[0].console.lines.map((line) => line.body), ["child output", "next output"]);
|
||||
|
||||
const views = consoleWorkerViews(projection);
|
||||
assertEquals(views.map((view) => [view.sessionId, view.label]), [
|
||||
@@ -3095,7 +3095,7 @@ Deno.test("live-created Internal Worker receives own metadata without reconnect"
|
||||
eventId: "child-snapshot",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: { worker, revision: 1, event: childSnapshot },
|
||||
data: { worker, event: childSnapshot },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -3104,7 +3104,7 @@ Deno.test("live-created Internal Worker receives own metadata without reconnect"
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 2,
|
||||
|
||||
event: {
|
||||
event: "context_usage",
|
||||
data: { usage: { tokens: 14_000, source: "estimated" } },
|
||||
@@ -3137,7 +3137,7 @@ Deno.test("Internal Worker snapshot output continues without entering the parent
|
||||
};
|
||||
snapshot.data.internal_workers = [{
|
||||
worker,
|
||||
revision: 4,
|
||||
|
||||
session: canonicalSession([]),
|
||||
greeting: {
|
||||
worker_name: "research",
|
||||
@@ -3185,7 +3185,7 @@ Deno.test("Internal Worker snapshot output continues without entering the parent
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 5,
|
||||
|
||||
event: { event: "text_delta", data: { text: "ld**" } },
|
||||
},
|
||||
},
|
||||
@@ -3216,7 +3216,7 @@ Deno.test("console Worker views expose only direct Internal Workers", () => {
|
||||
parent_session_id: "parent-session",
|
||||
kind: "sub_worker",
|
||||
},
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
@@ -3226,7 +3226,7 @@ Deno.test("console Worker views expose only direct Internal Workers", () => {
|
||||
parent_session_id: "child-session",
|
||||
kind: "sub_worker",
|
||||
},
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "worker_state",
|
||||
data: { snapshot: workerState("running") },
|
||||
@@ -3248,7 +3248,7 @@ Deno.test("console Worker views expose only direct Internal Workers", () => {
|
||||
parent_session_id: "parent-session",
|
||||
kind: "sub_worker",
|
||||
},
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "worker_state",
|
||||
data: { snapshot: workerState("idle") },
|
||||
@@ -3298,7 +3298,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
|
||||
parent_session_id: "parent-session",
|
||||
kind: "sub_worker",
|
||||
},
|
||||
revision: 4,
|
||||
|
||||
session: canonicalSession([{
|
||||
kind: "assistant_item",
|
||||
ts: 1,
|
||||
@@ -3343,7 +3343,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
|
||||
parent_session_id: "parent-session",
|
||||
kind: "sub_worker",
|
||||
},
|
||||
revision: 1,
|
||||
|
||||
event: {
|
||||
event: "worker_state",
|
||||
data: { snapshot: workerState("running") },
|
||||
@@ -3384,12 +3384,12 @@ Deno.test("terminal Internal Worker removal drops descendants and fences late ev
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 2,
|
||||
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker: nestedWorker,
|
||||
revision: 1,
|
||||
|
||||
event: { event: "text_done", data: { text: "nested" } },
|
||||
},
|
||||
},
|
||||
@@ -3406,7 +3406,7 @@ Deno.test("terminal Internal Worker removal drops descendants and fences late ev
|
||||
eventId: "removed",
|
||||
event: {
|
||||
event: "internal_worker_removed",
|
||||
data: { worker, revision: 3 },
|
||||
data: { worker },
|
||||
},
|
||||
}, {
|
||||
eventId: "late",
|
||||
@@ -3414,7 +3414,7 @@ Deno.test("terminal Internal Worker removal drops descendants and fences late ev
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 4,
|
||||
|
||||
event: { event: "text_done", data: { text: "must stay removed" } },
|
||||
},
|
||||
},
|
||||
@@ -3427,7 +3427,7 @@ Deno.test("terminal Internal Worker removal drops descendants and fences late ev
|
||||
assertEquals(projection.removedInternalWorkers, {});
|
||||
});
|
||||
|
||||
Deno.test("stale Internal Worker removal cannot discard a newer projection", () => {
|
||||
Deno.test("Internal Worker removal follows arrival order without a synthetic freshness counter", () => {
|
||||
const worker = {
|
||||
session_id: "child-session",
|
||||
name: "child",
|
||||
@@ -3441,21 +3441,21 @@ Deno.test("stale Internal Worker removal cannot discard a newer projection", ()
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 4,
|
||||
|
||||
event: { event: "text_done", data: { text: "current" } },
|
||||
},
|
||||
},
|
||||
}]);
|
||||
|
||||
const projection = projector.append([{
|
||||
eventId: "stale-removal",
|
||||
eventId: "removal",
|
||||
event: {
|
||||
event: "internal_worker_removed",
|
||||
data: { worker, revision: 3 },
|
||||
data: { worker },
|
||||
},
|
||||
}]);
|
||||
assertEquals(projection.internalWorkers.length, 1);
|
||||
assertEquals(projection.internalWorkers[0].revision, 4);
|
||||
assertEquals(projection.internalWorkers, []);
|
||||
assertEquals(projection.removedInternalWorkers, { "child-session": true });
|
||||
});
|
||||
|
||||
Deno.test("snapshot restores TaskStore state from system history", () => {
|
||||
@@ -3465,7 +3465,7 @@ Deno.test("snapshot restores TaskStore state from system history", () => {
|
||||
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
|
||||
event.data.session = {
|
||||
pending_submissions: {
|
||||
revision: 0,
|
||||
|
||||
notification_count: 0,
|
||||
head_id: null,
|
||||
submissions: [],
|
||||
@@ -3717,7 +3717,7 @@ Deno.test("snapshot entry replay is idempotent by identity, not text, including
|
||||
const child = { session_id: "child-session", name: "reviewer", kind: "sub_worker" as const };
|
||||
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
|
||||
snapshot.data.internal_workers = [{
|
||||
worker: child, revision: 5,
|
||||
worker: child,
|
||||
session: { ...snapshot.data.session, entries }, status: "idle",
|
||||
in_flight: { blocks: [] }, internal_workers: [],
|
||||
}];
|
||||
@@ -3725,13 +3725,13 @@ Deno.test("snapshot entry replay is idempotent by identity, not text, including
|
||||
for (const [index, entry] of [user, assistant, system].entries()) {
|
||||
events.push({ eventId: `parent-${index}`, event: {
|
||||
event: "internal_worker", data: {
|
||||
worker: child, revision: 6 + index,
|
||||
worker: child,
|
||||
event: { event: "session_entry_committed", data: { entry } },
|
||||
},
|
||||
} });
|
||||
}
|
||||
events.push({ eventId: "replayed-user", event: {
|
||||
event: "internal_worker", data: { worker: child, revision: 9, event: {
|
||||
event: "internal_worker", data: { worker: child, event: {
|
||||
event: "user_message", data: { entry_id: user.entry_id, segments: user.segments },
|
||||
} },
|
||||
} });
|
||||
|
||||
@@ -114,7 +114,6 @@ export type ConsoleLine = {
|
||||
|
||||
export type InternalWorkerProjection = {
|
||||
worker: InternalWorkerRef;
|
||||
revision: number;
|
||||
console: ConsoleProjection;
|
||||
};
|
||||
|
||||
@@ -194,7 +193,7 @@ export type ConsoleProjection = {
|
||||
lastEventId: string | null;
|
||||
internalWorkers: InternalWorkerProjection[];
|
||||
/** Terminal child-session fences, reset only by an authoritative snapshot. */
|
||||
removedInternalWorkers: Record<string, number>;
|
||||
removedInternalWorkers: Record<string, true>;
|
||||
};
|
||||
|
||||
export type WorkerTarget = {
|
||||
@@ -907,7 +906,7 @@ function projectInternalWorkerSnapshot(
|
||||
if (snapshot.in_flight?.compaction) {
|
||||
console = applyInFlightCompaction(console, snapshot.in_flight.compaction);
|
||||
}
|
||||
return { worker: snapshot.worker, revision: snapshot.revision, console };
|
||||
return { worker: snapshot.worker, console };
|
||||
}
|
||||
|
||||
const RUNTIME_COMPACTION_LINE_ID = "compaction-runtime";
|
||||
@@ -1247,16 +1246,13 @@ export function applyProtocolEvent(
|
||||
? next.internalWorkers[existingIndex]
|
||||
: {
|
||||
worker: event.data.worker,
|
||||
revision: 0,
|
||||
console: emptyConsoleProjection(),
|
||||
};
|
||||
if (event.data.revision <= existing.revision) break;
|
||||
const updated: InternalWorkerProjection = {
|
||||
worker: event.data.worker,
|
||||
revision: event.data.revision,
|
||||
console: applyProtocolEvent(existing.console, {
|
||||
eventId:
|
||||
`${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`,
|
||||
`${envelope.eventId}:internal:${event.data.worker.session_id}`,
|
||||
event: event.data.event,
|
||||
observedAtMs: envelope.observedAtMs,
|
||||
}),
|
||||
@@ -1269,12 +1265,7 @@ export function applyProtocolEvent(
|
||||
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
||||
worker.worker.session_id === event.data.worker.session_id
|
||||
);
|
||||
const existingRevision = existingIndex >= 0
|
||||
? next.internalWorkers[existingIndex].revision
|
||||
: 0;
|
||||
if (event.data.revision <= existingRevision) break;
|
||||
next.removedInternalWorkers[event.data.worker.session_id] =
|
||||
event.data.revision;
|
||||
next.removedInternalWorkers[event.data.worker.session_id] = true;
|
||||
if (existingIndex >= 0) next.internalWorkers.splice(existingIndex, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ Deno.test("new invoke and legacy snapshot reset run activity", () => {
|
||||
entries: [],
|
||||
greeting: { text: "", profile: "" },
|
||||
state: {
|
||||
revision: 0,
|
||||
|
||||
last_command_id: 0,
|
||||
state: { kind: "idle" },
|
||||
},
|
||||
|
||||
@@ -504,7 +504,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
subjectIndexPage.includes(">Subjects</h1>") &&
|
||||
subjectIndexPage.includes("Worker connection") &&
|
||||
subjectIndexPage.includes("subject.current_worker") &&
|
||||
!subjectIndexPage.includes("subject.store_revision") &&
|
||||
!subjectIndexPage.includes("subject.memory_fingerprint") &&
|
||||
subjectIndexPage.includes('data-memory-view="subjects"'),
|
||||
"Memory product entry should list explicit typed subjects",
|
||||
);
|
||||
@@ -513,7 +513,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
||||
subjectPage.includes("DocumentMarkdown from") &&
|
||||
subjectPage.includes("Resident context") &&
|
||||
subjectPage.includes("Subject store revision") &&
|
||||
subjectPage.includes("Memory fingerprint") &&
|
||||
subjectPage.includes("Current Memories") &&
|
||||
subjectPage.includes("surface?.availability === 'ready'") &&
|
||||
subjectPage.includes("surface?.availability === 'ungenerated'") &&
|
||||
@@ -524,15 +524,15 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
|
||||
);
|
||||
assert(
|
||||
memoryDetailLoad.includes("`${memoryPath}/revisions`") &&
|
||||
memoryDetailLoad.includes("`${memoryPath}/changes`") &&
|
||||
memoryDetailLoad.includes("parseSubjektivMemoryReadResponse") &&
|
||||
memoryDetailPage.includes("Candidate provenance") &&
|
||||
memoryDetailPage.includes("Source refs") &&
|
||||
memoryDetailPage.includes("Derived from") &&
|
||||
memoryDetailPage.includes("Revision history") &&
|
||||
memoryDetailPage.includes("Change history") &&
|
||||
memoryDetailPage.includes("DocumentMarkdown from") &&
|
||||
!memoryDetailPage.includes("overflow-y"),
|
||||
"Committed Memory detail should preserve bounded provenance and immutable revisions without mutation controls",
|
||||
"Committed Memory detail should preserve bounded provenance and immutable changes without mutation controls",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ test("A late image decode error cannot replace the newly selected image preview"
|
||||
expect(revoke).toHaveBeenCalledWith("blob:http://localhost/image-1");
|
||||
});
|
||||
|
||||
test("Refreshing metadata never silently rebases an open rename form onto another writer revision", async () => {
|
||||
let revision = "1";
|
||||
test("Refreshing metadata never silently rebases an open rename form onto another writer’s saved content", async () => {
|
||||
let last_mutation_id = "1";
|
||||
const mutations: DriveMutationRequest[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -71,7 +71,7 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe
|
||||
return Response.json({
|
||||
code: "conflict",
|
||||
classification: "not_committed",
|
||||
message: "Revision conflict",
|
||||
message: "Content changed",
|
||||
}, { status: 409 });
|
||||
}
|
||||
if (url.pathname.endsWith("/list")) {
|
||||
@@ -79,7 +79,7 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe
|
||||
}
|
||||
if (url.pathname.endsWith("/read-text")) {
|
||||
return Response.json({
|
||||
entry: entry("2", "alpha", revision),
|
||||
entry: entry("2", "alpha", last_mutation_id),
|
||||
text: "new",
|
||||
truncated: false,
|
||||
});
|
||||
@@ -88,7 +88,7 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe
|
||||
entry(
|
||||
id,
|
||||
"alpha",
|
||||
id === "1" ? "1" : revision,
|
||||
id === "1" ? "1" : last_mutation_id,
|
||||
id === "1" ? "folder" : "file",
|
||||
),
|
||||
);
|
||||
@@ -102,7 +102,7 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe
|
||||
await fireEvent.input(view.getByLabelText("Name"), {
|
||||
target: { value: "my-name.txt" },
|
||||
});
|
||||
revision = "2";
|
||||
last_mutation_id = "2";
|
||||
await fireEvent.click(
|
||||
view.getByRole("button", { name: "Refresh" }),
|
||||
);
|
||||
@@ -113,14 +113,14 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe
|
||||
),
|
||||
).toBe(false)
|
||||
);
|
||||
expect(view.container.textContent).toContain("Expected revision 1.");
|
||||
expect(view.container.textContent).toContain("Changes by another writer will not be overwritten.");
|
||||
await fireEvent.click(
|
||||
view.getByRole("button", { name: "Apply rename / move" }),
|
||||
);
|
||||
await waitFor(() => expect(mutations).toHaveLength(1));
|
||||
expect(mutations[0].mutation).toMatchObject({
|
||||
operation: "relocate",
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
name: "my-name.txt",
|
||||
id: { workspace_id: "alpha", node_id: "2" },
|
||||
});
|
||||
@@ -147,7 +147,7 @@ test("Folder picker blocks failed reads, filters files and self, and follows pag
|
||||
return Response.json({
|
||||
code: "conflict",
|
||||
classification: "not_committed",
|
||||
message: "Revision conflict",
|
||||
message: "Content changed",
|
||||
}, { status: 409 });
|
||||
}
|
||||
if (url.pathname.endsWith("/list")) {
|
||||
@@ -209,7 +209,7 @@ test("Folder picker blocks failed reads, filters files and self, and follows pag
|
||||
await waitFor(() => expect(mutations).toHaveLength(1));
|
||||
expect(mutations[0].mutation).toMatchObject({
|
||||
operation: "relocate",
|
||||
expected_revision: "7",
|
||||
expected_mutation_id: "7",
|
||||
id: { workspace_id: "alpha", node_id: "2" },
|
||||
parent: { workspace_id: "alpha", node_id: "5" },
|
||||
});
|
||||
|
||||
@@ -170,13 +170,13 @@
|
||||
if (!file) throw new Error('Choose a file.');
|
||||
if (file.size > DRIVE_FILE_MAX_BYTES) throw new Error('Upload exceeds 16 MiB.');
|
||||
requestId = await controller.upload(file, selectedForm === 'replace' ? {
|
||||
operation: 'update', id: current.entry, expected_revision: current.revision, content_type: file.type || 'application/octet-stream',
|
||||
operation: 'update', id: current.entry, expected_mutation_id: current.last_mutation_id, content_type: file.type || 'application/octet-stream',
|
||||
} : {
|
||||
operation: 'create', parent: current.entry, name: file.name, content_type: file.type || 'application/octet-stream',
|
||||
});
|
||||
} else if (selectedForm === 'relocate') {
|
||||
if (!destination || destinationBusy || destinationError) throw new Error('Choose an available destination folder.');
|
||||
requestId = await controller.mutate({ operation: 'relocate', id: current.entry, expected_revision: current.revision, parent: { workspace_id: workspaceId, node_id: parseDriveDecimal(parentId) }, name });
|
||||
requestId = await controller.mutate({ operation: 'relocate', id: current.entry, expected_mutation_id: current.last_mutation_id, parent: { workspace_id: workspaceId, node_id: parseDriveDecimal(parentId) }, name });
|
||||
} else {
|
||||
requestId = await controller.mutate(selectedForm === 'folder' ? { operation: 'create_folder', parent: current.entry, name } : { operation: 'create_text', parent: current.entry, name, text: newText, content_type: 'text/markdown' });
|
||||
}
|
||||
@@ -193,7 +193,7 @@
|
||||
if (!current || !canWrite || !current.parent) return;
|
||||
if (!confirm(`Delete “${current.name}”? This cannot be undone. Folders must be empty; contents are never recursively deleted.`)) return;
|
||||
await action(async () => {
|
||||
await controller.mutate({ operation: 'delete', id: current.entry, expected_revision: current.revision });
|
||||
await controller.mutate({ operation: 'delete', id: current.entry, expected_mutation_id: current.last_mutation_id });
|
||||
// Stay on this stable URL; a subsequent lookup correctly reports NotFound.
|
||||
if (controller.state.receipts.at(-1)?.state === 'committed') await controller.refresh();
|
||||
});
|
||||
@@ -283,7 +283,7 @@
|
||||
<button class="primary" type="button" onclick={() => openForm('upload')}><DriveIcon name="upload" />Upload</button>
|
||||
{/if}
|
||||
{#if entry?.parent}
|
||||
{#if entry.kind === 'file'}<a class="drive-button" href={driveDownloadUrl(workspaceId, entry.entry, entry.revision)} download={entry.name}><DriveIcon name="download" />Download</a>{/if}
|
||||
{#if entry.kind === 'file'}<a class="drive-button" href={driveDownloadUrl(workspaceId, entry.entry, entry.last_mutation_id)} download={entry.name}><DriveIcon name="download" />Download</a>{/if}
|
||||
{#if canEdit}<button class="primary" type="button" onclick={() => editing = !editing}><DriveIcon name="edit" />{editing ? 'View' : 'Edit text'}</button>{/if}
|
||||
<details class="drive-menu" bind:open={actionsMenu}>
|
||||
<summary class="icon-button" aria-label="More actions" title="More actions"><DriveIcon name="more" /></summary>
|
||||
@@ -352,17 +352,17 @@
|
||||
{:else if kind === 'markdown' || kind === 'text'}
|
||||
{#if $controller.truncated}<p class="drive-state" role="status">Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.</p>{/if}
|
||||
{#if draft?.conflict}
|
||||
<div class="drive-conflict" role="alert"><strong>Revision conflict — your draft is retained.</strong><p>Another writer changed this file. Refresh displays the latest content without changing your draft or its expected revision. Copy your draft before explicitly discarding it; it is never automatically retried.</p></div>
|
||||
<div class="drive-conflict" role="alert"><strong>Content changed — your draft is retained.</strong><p>Another writer changed this file. Refresh displays the latest content without changing your draft or the saved content it was based on. Copy your draft before explicitly discarding it; it is never automatically retried.</p></div>
|
||||
{/if}
|
||||
<div class="drive-document" class:is-editing={editing && draft !== null}>
|
||||
{#if editing && draft}
|
||||
<div class="drive-editor">
|
||||
<label>Draft (expected revision {draft.expectedRevision})
|
||||
<label>Draft
|
||||
<textarea value={draft.text} oninput={(event) => controller.edit(event.currentTarget.value)} readonly={denied} spellcheck="false"></textarea>
|
||||
</label>
|
||||
<div class="drive-toolbar">
|
||||
{#if canWrite}<button class="primary" type="button" disabled={!dirty || draft.conflict || new TextEncoder().encode(draft.text).length > DRIVE_TEXT_MAX_BYTES} onclick={() => action(() => controller.save())}>Save</button>{/if}
|
||||
<button type="button" disabled={unresolved} onclick={() => { if (confirm('Discard your draft and load the current Backend revision?')) void action(() => controller.discardDraft()); }}>Discard draft / load latest</button>
|
||||
<button type="button" disabled={unresolved} onclick={() => { if (confirm('Discard your draft and load the latest saved content?')) void action(() => controller.discardDraft()); }}>Discard draft / load latest</button>
|
||||
<span class="drive-note" role="status">{dirty ? 'Unsaved draft' : 'Saved content'} · {formatBytes(new TextEncoder().encode(draft.text).length)} / 64 KiB</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,11 +383,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if kind === 'download'}
|
||||
<div class="drive-empty drive-download-preview"><span class="drive-empty-icon"><DriveIcon name="download" /></span><h2>No preview available</h2><p class="drive-note">This file type is download-only. HTML, SVG and other active content are never rendered here.</p><a class="drive-button" href={driveDownloadUrl(workspaceId, entry.entry, entry.revision)} download={entry.name}>Download file</a></div>
|
||||
<div class="drive-empty drive-download-preview"><span class="drive-empty-icon"><DriveIcon name="download" /></span><h2>No preview available</h2><p class="drive-note">This file type is download-only. HTML, SVG and other active content are never rendered here.</p><a class="drive-button" href={driveDownloadUrl(workspaceId, entry.entry, entry.last_mutation_id)} download={entry.name}>Download file</a></div>
|
||||
{/if}
|
||||
{#if entry.parent}
|
||||
<details class="drive-details"><summary><DriveIcon name="info" />Details</summary>
|
||||
<dl><div><dt>Updated</dt><dd>{formatDate(entry.updated_at)}</dd></div><div><dt>By</dt><dd>{entry.updated_by}</dd></div><div><dt>Revision</dt><dd>{entry.revision}</dd></div><div><dt>Latest URL</dt><dd><a href={driveHref(entry.entry)}>{driveHref(entry.entry)}</a></dd></div></dl>
|
||||
<dl><div><dt>Updated</dt><dd>{formatDate(entry.updated_at)}</dd></div><div><dt>By</dt><dd>{entry.updated_by}</dd></div><div><dt>Last change request</dt><dd>{entry.last_mutation_id}</dd></div><div><dt>Latest URL</dt><dd><a href={driveHref(entry.entry)}>{driveHref(entry.entry)}</a></dd></div></dl>
|
||||
<p class="drive-note">Saving replaces the current content. There is no version history.</p>
|
||||
</details>
|
||||
{/if}
|
||||
@@ -426,7 +426,7 @@
|
||||
{/if}
|
||||
{#if form === 'text'}<label>Markdown<textarea bind:value={newText} disabled={pending} placeholder="Write your document…"></textarea></label>{/if}
|
||||
{/if}
|
||||
{#if form === 'replace' || form === 'relocate'}<p class="drive-note">Expected revision {formEntry?.revision}. Changes by another writer will not be overwritten.</p>{/if}
|
||||
{#if form === 'replace' || form === 'relocate'}<p class="drive-note">Changes by another writer will not be overwritten.</p>{/if}
|
||||
{#if form === 'replace'}<p class="drive-note">This replaces the current file. There is no version history.</p>{/if}
|
||||
<footer class="drive-dialog-actions">
|
||||
<button type="button" disabled={pending} onclick={closeForm}>Cancel form</button>
|
||||
|
||||
@@ -44,13 +44,13 @@ Deno.test("Drive metadata rejects cross-workspace refs and unsafe latest links",
|
||||
const good = entry();
|
||||
const parsed = parseDriveEntry(good, "alpha");
|
||||
equal(parsed.entry, good.entry);
|
||||
equal(parsed.revision, good.revision);
|
||||
equal(parsed.last_mutation_id, good.last_mutation_id);
|
||||
equal(parsed.latest_url, good.latest_url);
|
||||
for (
|
||||
const patch of [
|
||||
{ entry: ref("2", "beta") },
|
||||
{ parent: ref("1", "beta") },
|
||||
{ revision: 4 },
|
||||
{ last_mutation_id: 4 },
|
||||
{ kind: "directory" },
|
||||
{ size: -1 },
|
||||
{ size: DRIVE_FILE_MAX_BYTES + 1 },
|
||||
@@ -200,7 +200,7 @@ Deno.test("Drive client uses current routes with same-origin authentication and
|
||||
assert(called);
|
||||
equal(
|
||||
driveDownloadUrl("alpha", ref("9007199254740993"), "9007199254740995"),
|
||||
"/api/w/alpha/drive/download?entry_workspace_id=alpha&id=9007199254740993&expected_revision=9007199254740995",
|
||||
"/api/w/alpha/drive/download?entry_workspace_id=alpha&id=9007199254740993&expected_mutation_id=9007199254740995",
|
||||
);
|
||||
});
|
||||
Deno.test("Drive upload over 16 MiB is rejected before fetching", async () => {
|
||||
@@ -244,7 +244,7 @@ Deno.test("Drive mutation network abort or malformed reply remains unknown after
|
||||
client.mutate("alpha", "req", {
|
||||
operation: "delete",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
expected_mutation_id: "9007199254740993",
|
||||
}),
|
||||
(error) => {
|
||||
assert(error instanceof DriveRequestError);
|
||||
@@ -268,7 +268,7 @@ Deno.test("Drive typed conflict proves not committed and read responses cannot t
|
||||
client.mutate("alpha", "req", {
|
||||
operation: "delete",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
}),
|
||||
(error) => {
|
||||
assert(error instanceof DriveRequestError);
|
||||
@@ -285,7 +285,10 @@ Deno.test("Drive typed conflict proves not committed and read responses cannot t
|
||||
Deno.test("Drive image preview fetches authenticated bounded binary rather than metadata URL", async () => {
|
||||
const image = { ...entry(), content_type: "image/png" };
|
||||
const client = createDriveClient((url, init) => {
|
||||
equal(String(url), driveDownloadUrl("alpha", ref(), image.revision));
|
||||
equal(
|
||||
String(url),
|
||||
driveDownloadUrl("alpha", ref(), image.last_mutation_id),
|
||||
);
|
||||
equal((init as RequestInit | undefined)?.credentials, "same-origin");
|
||||
equal((init as RequestInit | undefined)?.cache, "no-store");
|
||||
return Promise.resolve(new Response(new Uint8Array([0, 1, 2])));
|
||||
@@ -310,7 +313,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin
|
||||
mutation: {
|
||||
operation: "update_text",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
text: "abc",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
@@ -320,7 +323,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin
|
||||
mutation: {
|
||||
operation: "update_text",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
text: "abc",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
@@ -330,7 +333,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin
|
||||
mutation: {
|
||||
operation: "relocate",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
parent: ref("4"),
|
||||
name: "moved.txt",
|
||||
},
|
||||
@@ -340,7 +343,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin
|
||||
mutation: {
|
||||
operation: "relocate",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
parent: ref("4"),
|
||||
name: "moved.txt",
|
||||
},
|
||||
@@ -350,7 +353,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin
|
||||
mutation: {
|
||||
operation: "relocate",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
parent: ref("4"),
|
||||
name: "moved.txt",
|
||||
},
|
||||
@@ -392,14 +395,14 @@ Deno.test("Drive malformed mutation response target parent name or operation kin
|
||||
mutation: {
|
||||
operation: "update_text",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
text: "abc",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: null,
|
||||
},
|
||||
{
|
||||
mutation: { operation: "delete", id: ref(), expected_revision: "1" },
|
||||
mutation: { operation: "delete", id: ref(), expected_mutation_id: "1" },
|
||||
response: entry(),
|
||||
},
|
||||
];
|
||||
@@ -422,7 +425,7 @@ Deno.test("Drive malformed upload update and create identity remains unknown aft
|
||||
target: {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("3"),
|
||||
@@ -431,7 +434,7 @@ Deno.test("Drive malformed upload update and create identity remains unknown aft
|
||||
target: {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("2", "alpha", "2", "folder"),
|
||||
@@ -467,7 +470,7 @@ Deno.test("Drive malformed upload update and create identity remains unknown aft
|
||||
target: {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
expected_mutation_id: "1",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: null,
|
||||
@@ -508,3 +511,22 @@ Deno.test("Drive bounded text roundtrip accepts worst-case JSON escaping without
|
||||
);
|
||||
equal(result.entry.size, 64 * 1024);
|
||||
});
|
||||
|
||||
Deno.test("Drive committed request identity remains UTF-8 and URL encoded rather than numeric", () => {
|
||||
const id = 'write/資料 ?"#&=+';
|
||||
const value = { ...entry(), last_mutation_id: id };
|
||||
equal(parseDriveEntry(value, "alpha").last_mutation_id, id);
|
||||
const url = new URL(
|
||||
driveDownloadUrl("alpha", value.entry, id),
|
||||
"https://example.test",
|
||||
);
|
||||
equal(url.searchParams.get("expected_mutation_id"), id);
|
||||
equal(url.searchParams.size, 3);
|
||||
equal(url.hash, "");
|
||||
for (
|
||||
const bad of ["", "\nrequest", "\x7f", "é".repeat(65), "x".repeat(129)]
|
||||
) {
|
||||
throws(() => parseDriveEntry({ ...value, last_mutation_id: bad }, "alpha"));
|
||||
throws(() => driveDownloadUrl("alpha", value.entry, bad));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,6 +68,14 @@ export function parseDriveDecimal(value: unknown): string {
|
||||
) invalid();
|
||||
return result;
|
||||
}
|
||||
function parseMutationId(value: unknown, allowRoot = false): string {
|
||||
const result = string(value, 128);
|
||||
if (
|
||||
(!allowRoot && !result) || new TextEncoder().encode(result).length > 128 ||
|
||||
/[\x00-\x1f\x7f-\x9f]/.test(result)
|
||||
) invalid();
|
||||
return result;
|
||||
}
|
||||
function hasControl(value: string, includeSpace = false): boolean {
|
||||
return Array.from(value).some((char) =>
|
||||
char.charCodeAt(0) <= (includeSpace ? 32 : 31)
|
||||
@@ -158,7 +166,7 @@ export function parseDriveEntry(
|
||||
parent,
|
||||
kind: v.kind,
|
||||
name: string(v.name, 255),
|
||||
revision: parseDriveDecimal(v.revision),
|
||||
last_mutation_id: parseMutationId(v.last_mutation_id, parent === null),
|
||||
size: size as number | null,
|
||||
content_type: v.content_type === null ? null : mime(v.content_type),
|
||||
updated_by: string(v.updated_by),
|
||||
@@ -293,7 +301,7 @@ export type DriveUploadTarget =
|
||||
| {
|
||||
operation: "update";
|
||||
id: DriveEntryRef;
|
||||
expected_revision: string;
|
||||
expected_mutation_id: string;
|
||||
content_type: string;
|
||||
};
|
||||
export type DrivePageOptions = { after?: string | null; limit?: number };
|
||||
@@ -349,14 +357,14 @@ export interface DriveClient {
|
||||
export function driveDownloadUrl(
|
||||
workspaceId: string,
|
||||
ref: DriveEntryRef,
|
||||
revision?: string,
|
||||
last_mutation_id?: string,
|
||||
): string {
|
||||
return `${base(workspaceId)}/download${
|
||||
query({
|
||||
...refQuery(ref, workspaceId),
|
||||
expected_revision: revision === undefined
|
||||
expected_mutation_id: last_mutation_id === undefined
|
||||
? null
|
||||
: parseDriveDecimal(revision),
|
||||
: parseMutationId(last_mutation_id),
|
||||
})
|
||||
}`;
|
||||
}
|
||||
@@ -375,7 +383,7 @@ function name(value: string): string {
|
||||
function validateMutation(value: DriveMutation, workspaceId: string): void {
|
||||
if ("id" in value) {
|
||||
parseDriveEntryRef(value.id, workspaceId);
|
||||
parseDriveDecimal(value.expected_revision);
|
||||
parseMutationId(value.expected_mutation_id);
|
||||
}
|
||||
if ("parent" in value) parseDriveEntryRef(value.parent, workspaceId);
|
||||
if ("name" in value) name(value.name);
|
||||
@@ -514,7 +522,7 @@ export function createDriveClient(fetchFn: typeof fetch = fetch): DriveClient {
|
||||
entry.size === null || entry.size > DRIVE_RESPONSE_MAX_BYTES
|
||||
) throw new DriveRequestError("limit", "not_committed");
|
||||
const res = await response(
|
||||
driveDownloadUrl(ws, entry.entry, entry.revision),
|
||||
driveDownloadUrl(ws, entry.entry, entry.last_mutation_id),
|
||||
{ signal },
|
||||
);
|
||||
await checked(res);
|
||||
@@ -578,7 +586,7 @@ export function createDriveClient(fetchFn: typeof fetch = fetch): DriveClient {
|
||||
}
|
||||
: {
|
||||
id: parseDriveEntryRef(target.id, ws).node_id,
|
||||
expected_revision: parseDriveDecimal(target.expected_revision),
|
||||
expected_mutation_id: parseMutationId(target.expected_mutation_id),
|
||||
};
|
||||
signal?.throwIfAborted();
|
||||
const digest = await crypto.subtle.digest(
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
export type DriveDraft = {
|
||||
text: string;
|
||||
baseText: string;
|
||||
expectedRevision: string;
|
||||
expectedMutationId: string;
|
||||
contentType: string;
|
||||
conflict: boolean;
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export type DriveControllerState = {
|
||||
};
|
||||
type ReceiptRecord = {
|
||||
receipt: DriveReceipt;
|
||||
saved?: { text: string; revision: string };
|
||||
saved?: { text: string; last_mutation_id: string };
|
||||
};
|
||||
const empty = (): DriveControllerState => ({
|
||||
workspaceId: null,
|
||||
@@ -208,7 +208,7 @@ export class DriveController {
|
||||
if (!this.current(generation)) return;
|
||||
let draft = this.drafts.get(k) ?? null;
|
||||
// Clean acknowledged drafts may refresh, but dirty/conflicted drafts
|
||||
// and unresolved requests must keep their original CAS revision.
|
||||
// and unresolved requests must keep their original CAS last_mutation_id.
|
||||
const unresolved = this.currentReceipts(k).some((receipt) =>
|
||||
receipt.state === "pending" || receipt.state === "unknown"
|
||||
);
|
||||
@@ -225,7 +225,7 @@ export class DriveController {
|
||||
draft = {
|
||||
text: result.text,
|
||||
baseText: result.text,
|
||||
expectedRevision: result.entry.revision,
|
||||
expectedMutationId: result.entry.last_mutation_id,
|
||||
contentType: result.entry.content_type ?? "text/plain",
|
||||
conflict: false,
|
||||
};
|
||||
@@ -354,11 +354,11 @@ export class DriveController {
|
||||
this.client.mutate(s.ws, id, {
|
||||
operation: "update_text",
|
||||
id: s.entry.entry,
|
||||
expected_revision: draft.expectedRevision,
|
||||
expected_mutation_id: draft.expectedMutationId,
|
||||
text: draft.text,
|
||||
content_type: draft.contentType,
|
||||
}, signal),
|
||||
{ text: draft.text, revision: draft.expectedRevision },
|
||||
{ text: draft.text, last_mutation_id: draft.expectedMutationId },
|
||||
);
|
||||
}
|
||||
async mutate(mutation: DriveMutation): Promise<string> {
|
||||
@@ -453,13 +453,13 @@ export class DriveController {
|
||||
const draft = this.drafts.get(k);
|
||||
if (
|
||||
record.saved && draft && entry &&
|
||||
draft.expectedRevision === record.saved.revision &&
|
||||
draft.expectedMutationId === record.saved.last_mutation_id &&
|
||||
entry.entry.node_id === this.value.entry?.entry.node_id
|
||||
) {
|
||||
const updated = {
|
||||
...draft,
|
||||
baseText: record.saved.text,
|
||||
expectedRevision: entry.revision,
|
||||
expectedMutationId: entry.last_mutation_id,
|
||||
conflict: false,
|
||||
};
|
||||
this.drafts.set(k, updated);
|
||||
|
||||
@@ -110,12 +110,12 @@ Deno.test("Drive A to B to A selection rejects the first A response and late rea
|
||||
equal(c.state.text, "fresh");
|
||||
equal(c.state.error, null);
|
||||
});
|
||||
Deno.test("Drive drafts survive workspace and node switches without revision rebasing", async () => {
|
||||
let revision = "9007199254740993";
|
||||
Deno.test("Drive drafts survive workspace and node switches without last_mutation_id rebasing", async () => {
|
||||
let last_mutation_id = "9007199254740993";
|
||||
const c = controller({
|
||||
readText: (ws, target) =>
|
||||
Promise.resolve({
|
||||
entry: entry(target.node_id, ws, revision),
|
||||
entry: entry(target.node_id, ws, last_mutation_id),
|
||||
text: "server",
|
||||
truncated: false,
|
||||
}),
|
||||
@@ -124,22 +124,22 @@ Deno.test("Drive drafts survive workspace and node switches without revision reb
|
||||
c.edit("local alpha");
|
||||
await c.select("beta", ref("2", "beta"));
|
||||
c.edit("local beta");
|
||||
revision = "9007199254740999";
|
||||
last_mutation_id = "9007199254740999";
|
||||
await c.select("alpha", ref("3"));
|
||||
await c.select("alpha", ref());
|
||||
equal(c.state.draft?.text, "local alpha");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.entry?.revision, "9007199254740999");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740993");
|
||||
equal(c.state.entry?.last_mutation_id, "9007199254740999");
|
||||
await c.select("beta", ref("2", "beta"));
|
||||
equal(c.state.draft?.text, "local beta");
|
||||
});
|
||||
Deno.test("Drive conflict keeps local text and old CAS revision across switching without retry", async () => {
|
||||
Deno.test("Drive conflict keeps local text and old CAS last_mutation_id across switching without retry", async () => {
|
||||
let mutations = 0;
|
||||
const c = controller({
|
||||
mutate: (_, __, mutation) => {
|
||||
++mutations;
|
||||
assert("expected_revision" in mutation);
|
||||
equal(mutation.expected_revision, "9007199254740993");
|
||||
assert("expected_mutation_id" in mutation);
|
||||
equal(mutation.expected_mutation_id, "9007199254740993");
|
||||
return Promise.reject(new DriveRequestError("conflict", "not_committed"));
|
||||
},
|
||||
});
|
||||
@@ -151,7 +151,7 @@ Deno.test("Drive conflict keeps local text and old CAS revision across switching
|
||||
await c.select("alpha", ref("3"));
|
||||
await c.select("alpha", ref());
|
||||
equal(c.state.draft?.conflict, true);
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740993");
|
||||
equal(mutations, 1);
|
||||
});
|
||||
Deno.test("Drive acknowledged save preserves edits made while the request was in flight", async () => {
|
||||
@@ -168,7 +168,7 @@ Deno.test("Drive acknowledged save preserves edits made while the request was in
|
||||
await saving;
|
||||
equal(c.state.draft?.text, "new edit");
|
||||
equal(c.state.draft?.baseText, "sent");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740994");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740994");
|
||||
});
|
||||
Deno.test("Drive late mutation and upload success cannot affect a newly selected identity", async () => {
|
||||
for (const operation of ["mutation", "upload"] as const) {
|
||||
@@ -184,7 +184,7 @@ Deno.test("Drive late mutation and upload success cannot affect a newly selected
|
||||
: c.upload(new Blob(["abc"]), {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
expected_mutation_id: "9007199254740993",
|
||||
content_type: "text/plain",
|
||||
});
|
||||
await c.select("beta", ref("2", "beta"));
|
||||
@@ -195,11 +195,11 @@ Deno.test("Drive late mutation and upload success cannot affect a newly selected
|
||||
await sending;
|
||||
equal(c.state.workspaceId, "beta");
|
||||
equal(c.state.receipts, []);
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740993");
|
||||
await c.select("alpha", ref());
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.draft?.text, "sent");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740993");
|
||||
}
|
||||
});
|
||||
Deno.test("Drive unknown upload reconciles by request id and never blindly retransmits", async () => {
|
||||
@@ -234,7 +234,7 @@ Deno.test("Drive unknown upload reconciles by request id and never blindly retra
|
||||
const target = {
|
||||
operation: "update" as const,
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
expected_mutation_id: "9007199254740993",
|
||||
content_type: "application/octet-stream",
|
||||
};
|
||||
const id = await c.upload(new Blob(["abc"]), target);
|
||||
@@ -273,7 +273,7 @@ Deno.test("Drive late status response is fenced even when the original node is r
|
||||
});
|
||||
await checking;
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740993");
|
||||
equal(c.state.draft?.text, "local");
|
||||
});
|
||||
Deno.test("Drive truncated text has no editable draft and disposal rejects late publication", async () => {
|
||||
@@ -460,7 +460,7 @@ Deno.test("Drive cancelled writes retain draft and receipt and ignore late compl
|
||||
: c.upload(new Blob(["abc"]), {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
expected_mutation_id: "9007199254740993",
|
||||
content_type: "text/plain",
|
||||
});
|
||||
equal(c.state.receipts[0].state, "pending");
|
||||
@@ -470,7 +470,7 @@ Deno.test("Drive cancelled writes retain draft and receipt and ignore late compl
|
||||
equal(c.state.receipts[0].requestId, id);
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.draft?.text, "sent draft");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740993");
|
||||
await rejects(() => c.save());
|
||||
await c.reconcile(id);
|
||||
assert(
|
||||
@@ -495,7 +495,7 @@ Deno.test("Drive cancelled writes retain draft and receipt and ignore late compl
|
||||
equal(await sending, id);
|
||||
assert(c.state === snapshot);
|
||||
equal(sends, 1);
|
||||
equal(c.state.entry?.revision, "9007199254740994");
|
||||
equal(c.state.entry?.last_mutation_id, "9007199254740994");
|
||||
}
|
||||
});
|
||||
Deno.test("Drive cancellation does not abort or fence active search reads", async () => {
|
||||
@@ -555,14 +555,14 @@ Deno.test("Drive concurrent writes are blocked before transmission and receipt c
|
||||
c.mutate({
|
||||
operation: "delete",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
expected_mutation_id: "9007199254740993",
|
||||
})
|
||||
);
|
||||
await rejects(() =>
|
||||
c.upload(new Blob(["abc"]), {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
expected_mutation_id: "9007199254740993",
|
||||
content_type: "text/plain",
|
||||
})
|
||||
);
|
||||
@@ -574,13 +574,13 @@ Deno.test("Drive concurrent writes are blocked before transmission and receipt c
|
||||
});
|
||||
await saving;
|
||||
});
|
||||
Deno.test("Drive clean draft refresh adopts latest revision unless a request remains unresolved", async () => {
|
||||
let revision = "9007199254740993";
|
||||
Deno.test("Drive clean draft refresh adopts latest last_mutation_id unless a request remains unresolved", async () => {
|
||||
let last_mutation_id = "9007199254740993";
|
||||
let text = "abc";
|
||||
const c = controller({
|
||||
readText: () =>
|
||||
Promise.resolve({
|
||||
entry: entry("2", "alpha", revision),
|
||||
entry: entry("2", "alpha", last_mutation_id),
|
||||
text,
|
||||
truncated: false,
|
||||
}),
|
||||
@@ -588,19 +588,19 @@ Deno.test("Drive clean draft refresh adopts latest revision unless a request rem
|
||||
Promise.reject(new DriveRequestError("outcome_unknown", "unknown")),
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
revision = "9007199254740994";
|
||||
last_mutation_id = "9007199254740994";
|
||||
text = "new";
|
||||
await c.refresh();
|
||||
equal(c.state.draft?.text, "new");
|
||||
equal(c.state.draft?.baseText, "new");
|
||||
equal(c.state.draft?.expectedRevision, revision);
|
||||
equal(c.state.draft?.expectedMutationId, last_mutation_id);
|
||||
await c.save();
|
||||
revision = "9007199254740995";
|
||||
last_mutation_id = "9007199254740995";
|
||||
text = "unknown change";
|
||||
await c.refresh();
|
||||
equal(c.state.text, "unknown change");
|
||||
equal(c.state.draft?.text, "new");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740994");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740994");
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
await rejects(() => c.save());
|
||||
});
|
||||
@@ -688,13 +688,13 @@ Deno.test("Drive cancelled late success stays unknown until explicit committed s
|
||||
});
|
||||
const id = await saving;
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740993");
|
||||
equal(c.state.draft?.text, "local");
|
||||
equal(statusCalls, 0);
|
||||
await rejects(() => c.save());
|
||||
await c.reconcile(id);
|
||||
equal(c.state.receipts[0].state, "committed");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740994");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740994");
|
||||
equal(c.state.draft?.baseText, "local");
|
||||
});
|
||||
Deno.test("Drive archived dirty draft cannot enable editing after MIME changes to active bytes", async () => {
|
||||
@@ -723,14 +723,14 @@ Deno.test("Drive archived dirty draft cannot enable editing after MIME changes t
|
||||
contentType = "text/plain";
|
||||
await c.refresh();
|
||||
equal(c.state.draft?.text, "retain me");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.expectedMutationId, "9007199254740993");
|
||||
});
|
||||
Deno.test("Drive conflicted clean draft stays fixed while clean truncated reread removes editor", async () => {
|
||||
let revision = "9007199254740993";
|
||||
let last_mutation_id = "9007199254740993";
|
||||
let truncated = false;
|
||||
const readText = () =>
|
||||
Promise.resolve({
|
||||
entry: entry("2", "alpha", revision),
|
||||
entry: entry("2", "alpha", last_mutation_id),
|
||||
text: "abc",
|
||||
truncated,
|
||||
});
|
||||
@@ -741,12 +741,12 @@ Deno.test("Drive conflicted clean draft stays fixed while clean truncated reread
|
||||
});
|
||||
await conflict.select("alpha", ref());
|
||||
await conflict.save();
|
||||
revision = "9007199254740994";
|
||||
last_mutation_id = "9007199254740994";
|
||||
await conflict.refresh();
|
||||
equal(conflict.state.draft?.text, "abc");
|
||||
equal(conflict.state.draft?.baseText, "abc");
|
||||
equal(conflict.state.draft?.conflict, true);
|
||||
equal(conflict.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(conflict.state.draft?.expectedMutationId, "9007199254740993");
|
||||
const clean = controller({ readText });
|
||||
await clean.select("alpha", ref());
|
||||
assert(clean.state.draft);
|
||||
|
||||
@@ -46,7 +46,7 @@ export function ref(id = "2", ws = "alpha"): DriveEntryRef {
|
||||
export function entry(
|
||||
id = "2",
|
||||
ws = "alpha",
|
||||
revision = "9007199254740993",
|
||||
last_mutation_id = "9007199254740993",
|
||||
kind: "file" | "folder" = "file",
|
||||
): DriveEntry {
|
||||
return {
|
||||
@@ -54,7 +54,7 @@ export function entry(
|
||||
parent: id === "1" ? null : ref("1", ws),
|
||||
name: id === "1" ? "" : "note.txt",
|
||||
kind,
|
||||
revision,
|
||||
last_mutation_id,
|
||||
size: kind === "file" ? 3 : null,
|
||||
content_type: kind === "file" ? "text/plain" : null,
|
||||
updated_by: "user:test",
|
||||
|
||||
@@ -33,7 +33,7 @@ export function fixtureDetail(key = "T-101") {
|
||||
const { workspace_action_priority: _, ...ticket } = summary;
|
||||
return {
|
||||
...ticket,
|
||||
item_revision: "revision-1",
|
||||
content_digest: "a".repeat(64),
|
||||
created_at: updated(1),
|
||||
readiness: null,
|
||||
body: "Implementation in progress.",
|
||||
@@ -155,7 +155,6 @@ export function dashboardFixture(
|
||||
...detail,
|
||||
body: "# Objective detail\n\nHome dashboard navigation destination.",
|
||||
body_truncated: false,
|
||||
revision: "revision-1",
|
||||
linked_ticket_summaries: [],
|
||||
resources: [],
|
||||
events: [],
|
||||
|
||||
@@ -18,12 +18,12 @@ import type {
|
||||
MemoryStagingRecord,
|
||||
SubjektivMemoryEvidence,
|
||||
SubjektivMemoryEvidenceCandidate,
|
||||
SubjektivMemoryListRevisionsResponse,
|
||||
SubjektivMemoryListChangesResponse,
|
||||
SubjektivMemoryQueryItem,
|
||||
SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse,
|
||||
SubjektivMemoryRevisionItem,
|
||||
SubjektivMemoryRevisionRef,
|
||||
SubjektivMemoryChangeItem,
|
||||
SubjektivMemoryChangeRef,
|
||||
SubjektivMemorySourceEvidenceRef,
|
||||
SubjektivMemoryState,
|
||||
SubjektivResidentSurfaceAvailability,
|
||||
@@ -79,7 +79,7 @@ const memoryStates = new Set<SubjektivMemoryState>([
|
||||
]);
|
||||
const MAX_SUBJECTS = 100;
|
||||
const MAX_CURRENT_MEMORIES = 100;
|
||||
const MAX_REVISIONS = 100;
|
||||
const MAX_CHANGES = 100;
|
||||
const MAX_MEMORY_REFS = MEMORY_API_LIMITS.maxCollectionItems;
|
||||
|
||||
export const MEMORY_API_LOAD_POLICY = {
|
||||
@@ -221,15 +221,7 @@ export async function updateSubjektivSubjectBehavior(
|
||||
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.",
|
||||
);
|
||||
}
|
||||
validateSubjectBehavior(request.expected_behavior_md);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(
|
||||
@@ -494,7 +486,6 @@ function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin {
|
||||
"worker_id",
|
||||
"flow_selector",
|
||||
"flow_definition_id",
|
||||
"flow_definition_revision",
|
||||
] as const;
|
||||
const record = strictRecord(
|
||||
value,
|
||||
@@ -528,14 +519,6 @@ function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin {
|
||||
result[key] = text;
|
||||
}
|
||||
}
|
||||
if ("flow_definition_revision" in record) {
|
||||
result.flow_definition_revision = record.flow_definition_revision === null
|
||||
? null
|
||||
: nonNegativeInteger(
|
||||
record.flow_definition_revision,
|
||||
"flow_definition_revision",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -549,9 +532,8 @@ export function parseSubjektivSubjectResponse(
|
||||
"id",
|
||||
"role",
|
||||
"behavior_md",
|
||||
"behavior_revision",
|
||||
"state",
|
||||
"store_revision",
|
||||
"memory_fingerprint",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"current_worker",
|
||||
@@ -573,9 +555,8 @@ export function parseSubjektivSubjectResponse(
|
||||
"behavior_md",
|
||||
MAX_SUBJECT_BEHAVIOR_BYTES,
|
||||
),
|
||||
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
|
||||
state,
|
||||
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
||||
memory_fingerprint: requiredIdentifier(record, "memory_fingerprint"),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
updated_at: requiredString(record, "updated_at"),
|
||||
};
|
||||
@@ -665,7 +646,7 @@ function parseResidentSurfaceSnapshot(
|
||||
"snapshot_id",
|
||||
"body_md",
|
||||
"memory_refs",
|
||||
"built_from_store_revision",
|
||||
"built_from_memory_fingerprint",
|
||||
"created_at",
|
||||
],
|
||||
"Resident surface snapshot",
|
||||
@@ -681,10 +662,10 @@ function parseResidentSurfaceSnapshot(
|
||||
record.memory_refs,
|
||||
MAX_MEMORY_REFS,
|
||||
"memory_refs",
|
||||
).map(parseMemoryRevisionRef),
|
||||
built_from_store_revision: requiredNonNegativeInteger(
|
||||
).map(parseMemoryChangeRef),
|
||||
built_from_memory_fingerprint: requiredIdentifier(
|
||||
record,
|
||||
"built_from_store_revision",
|
||||
"built_from_memory_fingerprint",
|
||||
),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
};
|
||||
@@ -716,12 +697,12 @@ export function parseSubjektivMemoryQueryResponse(
|
||||
function parseMemoryQueryItem(value: unknown): SubjektivMemoryQueryItem {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["id", "revision", "kind", "state", "claim", "excerpt", "updated_at"],
|
||||
["id", "change_id", "kind", "state", "claim", "excerpt", "updated_at"],
|
||||
"Current Memory item",
|
||||
);
|
||||
return {
|
||||
id: requiredIdentifier(record, "id"),
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
change_id: requiredIdentifier(record, "change_id"),
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
@@ -738,8 +719,8 @@ export function parseSubjektivMemoryReadResponse(
|
||||
value,
|
||||
[
|
||||
"memory_id",
|
||||
"revision",
|
||||
"current_revision",
|
||||
"change_id",
|
||||
"current_change_id",
|
||||
"kind",
|
||||
"state",
|
||||
"claim",
|
||||
@@ -772,9 +753,8 @@ export function parseSubjektivMemoryReadResponse(
|
||||
if (expectedMemoryId !== undefined && memoryId !== expectedMemoryId) {
|
||||
invalid("Memory detail identity does not match the requested Memory");
|
||||
}
|
||||
const revision = requiredPositiveInteger(record, "revision");
|
||||
const currentRevision = requiredPositiveInteger(record, "current_revision");
|
||||
if (revision > currentRevision) invalid("revision exceeds current_revision");
|
||||
const changeId = requiredIdentifier(record, "change_id");
|
||||
const currentChangeId = requiredIdentifier(record, "current_change_id");
|
||||
const bodyTruncated = requiredBoolean(record, "body_truncated");
|
||||
const bodyNextOffset = optionalNonNegativeInteger(record, "body_next_offset");
|
||||
const bodyNextByteOffset = optionalNonNegativeInteger(
|
||||
@@ -814,8 +794,8 @@ export function parseSubjektivMemoryReadResponse(
|
||||
assertCursorInvariant(evidenceHasMore, evidenceNextCursor, "Memory evidence");
|
||||
const result: SubjektivMemoryReadResponse = {
|
||||
memory_id: memoryId,
|
||||
revision,
|
||||
current_revision: currentRevision,
|
||||
change_id: changeId,
|
||||
current_change_id: currentChangeId,
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
@@ -837,7 +817,7 @@ export function parseSubjektivMemoryReadResponse(
|
||||
record.derived_from,
|
||||
MAX_MEMORY_REFS,
|
||||
"derived_from",
|
||||
).map(parseMemoryRevisionRef),
|
||||
).map(parseMemoryChangeRef),
|
||||
evidence_has_more: evidenceHasMore,
|
||||
};
|
||||
if ("staleness" in record) {
|
||||
@@ -984,37 +964,34 @@ function parseMemorySourceEvidenceRef(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSubjektivMemoryListRevisionsResponse(
|
||||
export function parseSubjektivMemoryListChangesResponse(
|
||||
value: unknown,
|
||||
expectedMemoryId?: string,
|
||||
): SubjektivMemoryListRevisionsResponse {
|
||||
): SubjektivMemoryListChangesResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["memory_id", "current_revision", "items", "next_cursor", "has_more"],
|
||||
"Memory revisions response",
|
||||
["memory_id", "current_change_id", "items", "next_cursor", "has_more"],
|
||||
"Memory changes response",
|
||||
["next_cursor"],
|
||||
);
|
||||
const memoryId = requiredIdentifier(record, "memory_id");
|
||||
if (expectedMemoryId !== undefined && memoryId !== expectedMemoryId) {
|
||||
invalid("Memory revisions identity does not match the requested Memory");
|
||||
invalid("Memory changes identity does not match the requested Memory");
|
||||
}
|
||||
const currentRevision = requiredPositiveInteger(record, "current_revision");
|
||||
const items = boundedArray(record.items, MAX_REVISIONS, "items").map(
|
||||
parseMemoryRevisionItem,
|
||||
const currentChangeId = requiredIdentifier(record, "current_change_id");
|
||||
const items = boundedArray(record.items, MAX_CHANGES, "items").map(
|
||||
parseMemoryChangeItem,
|
||||
);
|
||||
if (items.some((item) => item.revision > currentRevision)) {
|
||||
invalid("revision history contains a future revision");
|
||||
}
|
||||
assertUnique(
|
||||
items.map((item) => String(item.revision)),
|
||||
"Memory revisions",
|
||||
items.map((item) => item.change_id),
|
||||
"Memory changes",
|
||||
);
|
||||
const hasMore = requiredBoolean(record, "has_more");
|
||||
const nextCursor = optionalNullableString(record, "next_cursor");
|
||||
assertCursorInvariant(hasMore, nextCursor, "Memory revisions");
|
||||
const result: SubjektivMemoryListRevisionsResponse = {
|
||||
assertCursorInvariant(hasMore, nextCursor, "Memory changes");
|
||||
const result: SubjektivMemoryListChangesResponse = {
|
||||
memory_id: memoryId,
|
||||
current_revision: currentRevision,
|
||||
current_change_id: currentChangeId,
|
||||
items,
|
||||
has_more: hasMore,
|
||||
};
|
||||
@@ -1022,14 +999,14 @@ export function parseSubjektivMemoryListRevisionsResponse(
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemoryRevisionItem(value: unknown): SubjektivMemoryRevisionItem {
|
||||
function parseMemoryChangeItem(value: unknown): SubjektivMemoryChangeItem {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["revision", "kind", "state", "claim", "change_reason", "updated_at"],
|
||||
"Memory revision item",
|
||||
["change_id", "kind", "state", "claim", "change_reason", "updated_at"],
|
||||
"Memory change item",
|
||||
);
|
||||
return {
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
change_id: requiredIdentifier(record, "change_id"),
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
@@ -1038,15 +1015,15 @@ function parseMemoryRevisionItem(value: unknown): SubjektivMemoryRevisionItem {
|
||||
};
|
||||
}
|
||||
|
||||
function parseMemoryRevisionRef(value: unknown): SubjektivMemoryRevisionRef {
|
||||
function parseMemoryChangeRef(value: unknown): SubjektivMemoryChangeRef {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["memory_id", "revision"],
|
||||
"Memory revision ref",
|
||||
["memory_id", "change_id"],
|
||||
"Memory change ref",
|
||||
);
|
||||
return {
|
||||
memory_id: requiredIdentifier(record, "memory_id"),
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
change_id: requiredIdentifier(record, "change_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1198,13 +1175,6 @@ function requiredNonNegativeInteger(
|
||||
return nonNegativeInteger(record[key], key);
|
||||
}
|
||||
|
||||
function requiredPositiveInteger(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): number {
|
||||
return positiveInteger(record[key], key);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
const result = nonNegativeInteger(value, label);
|
||||
if (result === 0) invalid(`${label} must be a positive safe integer`);
|
||||
|
||||
@@ -48,7 +48,7 @@ Deno.test('pending submission previews accept new and legacy runtime payloads',
|
||||
snapshot: { topic: 'worker_protocol', data: {
|
||||
worker_id: 'worker-1',
|
||||
events: [{ event: 'pending_submissions_changed', data: { pending: {
|
||||
revision: 1, notification_count: 0, head_id: 'queued-1',
|
||||
notification_count: 0, head_id: 'queued-1',
|
||||
submissions: [{ submission_id: 'queued-1', accepted_at_ms: 1,
|
||||
segment_count: 1, byte_len: 10, ...(preview === undefined ? {} : { preview }) }],
|
||||
} } }],
|
||||
@@ -65,7 +65,7 @@ Deno.test('pending notification previews accept new and legacy runtime payloads'
|
||||
snapshot: { topic: 'worker_protocol', data: {
|
||||
worker_id: 'worker-1',
|
||||
events: [{ event: 'pending_submissions_changed', data: { pending: {
|
||||
revision: 1, notification_count: 1, head_id: 'notification-head', submissions: [],
|
||||
notification_count: 1, head_id: 'notification-head', submissions: [],
|
||||
...(previews === undefined ? {} : { notification_previews: previews }),
|
||||
} } }],
|
||||
} },
|
||||
|
||||
@@ -17,7 +17,7 @@ function assert(condition: boolean, message: string): void {
|
||||
|
||||
const snapshot: SessionSnapshot = {
|
||||
pending_submissions: {
|
||||
revision: 0,
|
||||
|
||||
notification_count: 0,
|
||||
head_id: null,
|
||||
submissions: [],
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
nameError = nameNotice = null;
|
||||
try {
|
||||
const response = await updateWorkspaceMetadata(workspaceId, {
|
||||
display_name: displayNameDraft, revision: workspaceMetadata.revision,
|
||||
display_name: displayNameDraft, expected_updated_at: workspaceMetadata.updated_at,
|
||||
});
|
||||
if (disposed) return;
|
||||
workspaceMetadata = response.workspace;
|
||||
@@ -203,13 +203,13 @@
|
||||
if (typeof value !== 'object' || value === null) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
|
||||
Object.keys(record).sort().join(',') !== 'confirmation,expected_workspace_updated_at,operation_id' ||
|
||||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
|
||||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
|
||||
typeof record.expected_workspace_updated_at !== 'string' || record.expected_workspace_updated_at.length > 128 ||
|
||||
typeof record.confirmation !== 'string' || record.confirmation !== workspaceName || record.confirmation.length > 256
|
||||
) return null;
|
||||
return { operation_id: record.operation_id, expected_revision: record.expected_revision, confirmation: record.confirmation };
|
||||
return { operation_id: record.operation_id, expected_workspace_updated_at: record.expected_workspace_updated_at, confirmation: record.confirmation };
|
||||
} catch { return null; }
|
||||
}
|
||||
onMount(() => {
|
||||
@@ -232,7 +232,7 @@
|
||||
deletionError = null;
|
||||
try {
|
||||
const request = deletionRequest ?? {
|
||||
operation_id: crypto.randomUUID(), expected_revision: deletionPreflight!.expected_revision, confirmation: deletionConfirmation,
|
||||
operation_id: crypto.randomUUID(), expected_workspace_updated_at: deletionPreflight!.expected_workspace_updated_at, confirmation: deletionConfirmation,
|
||||
};
|
||||
deletionRequest = request;
|
||||
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
|
||||
@@ -285,7 +285,6 @@
|
||||
<summary>Technical details</summary>
|
||||
<dl>
|
||||
<div><dt>Source</dt><dd><code>{workspaceMetadata.source}</code></dd></div>
|
||||
<div><dt>Revision</dt><dd><code>{workspaceMetadata.revision}</code></dd></div>
|
||||
</dl>
|
||||
{#each diagnostics.filter((item) => item.severity === 'info') as diagnostic}<p>{diagnostic.message}</p>{/each}
|
||||
</details>
|
||||
@@ -316,7 +315,6 @@
|
||||
<dl>
|
||||
<div><dt>Key</dt><dd><code>{signingIdentity.identity.key_id}</code></dd></div>
|
||||
<div><dt>Fingerprint</dt><dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd></div>
|
||||
<div><dt>Revision</dt><dd><code>{signingIdentity.identity.revision}</code></dd></div>
|
||||
</dl>
|
||||
<label>Public identity bundle<textarea readonly rows="9" value={identityBundleText} spellcheck="false"></textarea></label>
|
||||
</details>
|
||||
|
||||
@@ -6,7 +6,7 @@ export function metadataFixture(workspaceId = "home-owner") {
|
||||
? "Workspace with a long name — international documentation and distributed development"
|
||||
: "Workspace Settings Review",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
revision: "2026-01-02T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
source: "server_db",
|
||||
diagnostics: [],
|
||||
};
|
||||
@@ -16,7 +16,6 @@ export function identityFixture(workspaceId = "home-owner", pending = false) {
|
||||
workspace_id: workspaceId,
|
||||
key_id: "fixture-workspace-key",
|
||||
algorithm: "ed25519",
|
||||
revision: 1,
|
||||
state: pending ? "pending_provisioning" : "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
@@ -37,7 +36,6 @@ export function identityFixture(workspaceId = "home-owner", pending = false) {
|
||||
algorithm: identity.algorithm,
|
||||
public_key: publicKey,
|
||||
public_key_fingerprint: fingerprint,
|
||||
revision: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -45,7 +43,7 @@ export function deletionFixture(workspaceId = "home-owner") {
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
display_name: metadataFixture(workspaceId).display_name,
|
||||
expected_revision: metadataFixture(workspaceId).revision,
|
||||
expected_workspace_updated_at: metadataFixture(workspaceId).updated_at,
|
||||
can_delete: true,
|
||||
blockers: [],
|
||||
resources: {
|
||||
@@ -70,7 +68,7 @@ export function settingsFixtureHandler() {
|
||||
const current = names.get(id) ?? metadataFixture(id);
|
||||
if (request.method === "PUT") {
|
||||
const body = await request.json();
|
||||
if (body.revision !== current.revision) {
|
||||
if (body.expected_updated_at !== current.updated_at) {
|
||||
return new Response(
|
||||
"Workspace metadata changed. Reload the saved name.",
|
||||
{ status: 409 },
|
||||
@@ -79,7 +77,7 @@ export function settingsFixtureHandler() {
|
||||
const next = {
|
||||
...current,
|
||||
display_name: String(body.display_name).trim(),
|
||||
revision: String(Number.parseInt(current.revision, 10) + 1),
|
||||
updated_at: new Date(Date.parse(current.updated_at) + 1).toISOString(),
|
||||
};
|
||||
names.set(id, next);
|
||||
return Response.json({ workspace: next, diagnostics: [] });
|
||||
@@ -115,7 +113,7 @@ export function settingsFixtureHandler() {
|
||||
...(names.has(deletion[1])
|
||||
? {
|
||||
display_name: names.get(deletion[1])!.display_name,
|
||||
expected_revision: names.get(deletion[1])!.revision,
|
||||
expected_workspace_updated_at: names.get(deletion[1])!.updated_at,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
@@ -49,8 +49,8 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
"Edit the Server-owned virtual Decodal source tree through one native/WASM toolchain contract.",
|
||||
bullets: [
|
||||
"Virtual paths and imports resolve inside the committed Workspace tree, never from browser or Server host paths.",
|
||||
"Browser analysis is advisory; Server evaluation is required before an atomic revision commit.",
|
||||
"Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.",
|
||||
"Browser analysis is advisory; Server evaluation is required before an atomic content-digest CAS commit.",
|
||||
"Profile, Skill, Prompt, and Plugin data are projected from the active immutable source tree.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -82,11 +82,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
label: "Profile Sources",
|
||||
status: "editable",
|
||||
summary:
|
||||
"Inspect the Profile launch projection derived from the active Workspace configuration revision.",
|
||||
"Inspect the Profile launch projection derived from the active Workspace configuration tree.",
|
||||
bullets: [
|
||||
"Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.",
|
||||
"Profile declarations and sources are edited only through the shared Workspace configuration editor and evaluate-before-commit contract.",
|
||||
"Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.",
|
||||
"Launch candidates and Profile archives carry the same active tree digest and projection digest.",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -146,7 +146,7 @@ export function parseWorkspaceMetadataSettingsResponse(
|
||||
"workspace_id",
|
||||
"display_name",
|
||||
"created_at",
|
||||
"revision",
|
||||
"updated_at",
|
||||
"source",
|
||||
"diagnostics",
|
||||
],
|
||||
@@ -157,7 +157,7 @@ export function parseWorkspaceMetadataSettingsResponse(
|
||||
workspace_id: stringValue(item.workspace_id, "Workspace metadata"),
|
||||
display_name: stringValue(item.display_name, "Workspace metadata"),
|
||||
created_at: stringValue(item.created_at, "Workspace metadata"),
|
||||
revision: stringValue(item.revision, "Workspace metadata"),
|
||||
updated_at: stringValue(item.updated_at, "Workspace metadata"),
|
||||
source: stringValue(item.source, "Workspace metadata"),
|
||||
diagnostics: arrayValue(
|
||||
item.diagnostics,
|
||||
@@ -237,7 +237,6 @@ function parseWorkspaceProfileSourceSummary(
|
||||
"content_digest",
|
||||
"provenance",
|
||||
"editable",
|
||||
"revision",
|
||||
"size_bytes",
|
||||
"diagnostics",
|
||||
],
|
||||
@@ -269,7 +268,6 @@ function parseWorkspaceProfileSourceSummary(
|
||||
content_digest: stringValue(item.content_digest, "Profile source catalog"),
|
||||
provenance: provenance as WorkspaceProfileSourceProvenance,
|
||||
editable: booleanValue(item.editable, "Profile source catalog"),
|
||||
revision: stringValue(item.revision, "Profile source catalog"),
|
||||
size_bytes: sizeBytes,
|
||||
diagnostics: arrayValue(
|
||||
item.diagnostics,
|
||||
@@ -285,14 +283,12 @@ export function parseProfileSettingsResponse(
|
||||
const item = record(value, "Profile settings");
|
||||
exactKeys(
|
||||
item,
|
||||
["workspace_id", "registry_revision", "profiles", "sources", "diagnostics"],
|
||||
["config_revision", "tree_digest", "projection_digest", "default_profile"],
|
||||
["workspace_id", "profiles", "sources", "diagnostics"],
|
||||
["tree_digest", "projection_digest", "default_profile"],
|
||||
"Profile settings",
|
||||
);
|
||||
return {
|
||||
workspace_id: stringValue(item.workspace_id, "Profile settings"),
|
||||
registry_revision: stringValue(item.registry_revision, "Profile settings"),
|
||||
config_revision: optionalRevision(item.config_revision, "Profile settings"),
|
||||
tree_digest: optionalString(item.tree_digest, "Profile settings"),
|
||||
projection_digest: optionalString(
|
||||
item.projection_digest,
|
||||
@@ -330,7 +326,7 @@ export function parseWorkspaceSigningIdentityResponse(
|
||||
const identityItem = record(item.identity, "Workspace signing identity");
|
||||
exactKeys(
|
||||
identityItem,
|
||||
["workspace_id", "key_id", "algorithm", "revision", "state", "created_at"],
|
||||
["workspace_id", "key_id", "algorithm", "state", "created_at"],
|
||||
["public_key", "public_key_fingerprint", "provisioned_at"],
|
||||
"Workspace signing identity",
|
||||
);
|
||||
@@ -345,16 +341,6 @@ export function parseWorkspaceSigningIdentityResponse(
|
||||
502,
|
||||
);
|
||||
}
|
||||
const revision = optionalRevision(
|
||||
identityItem.revision,
|
||||
"Workspace signing identity",
|
||||
);
|
||||
if (revision === undefined || revision === null || revision < 1) {
|
||||
throw new ProfileApiError(
|
||||
"Workspace signing identity returned an invalid response.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
const publicKey = optionalBoundedString(
|
||||
identityItem.public_key,
|
||||
"Workspace signing identity",
|
||||
@@ -392,7 +378,6 @@ export function parseWorkspaceSigningIdentityResponse(
|
||||
...(fingerprint === undefined || fingerprint === null
|
||||
? {}
|
||||
: { public_key_fingerprint: fingerprint }),
|
||||
revision,
|
||||
state: state as WorkspaceSigningIdentityState,
|
||||
created_at: boundedStringValue(
|
||||
identityItem.created_at,
|
||||
@@ -419,24 +404,10 @@ export function parseWorkspaceSigningIdentityResponse(
|
||||
"algorithm",
|
||||
"public_key",
|
||||
"public_key_fingerprint",
|
||||
"revision",
|
||||
],
|
||||
[],
|
||||
"Workspace public identity bundle",
|
||||
);
|
||||
const bundleRevision = optionalRevision(
|
||||
bundle.revision,
|
||||
"Workspace public identity bundle",
|
||||
);
|
||||
if (
|
||||
bundleRevision === undefined || bundleRevision === null ||
|
||||
bundleRevision < 1
|
||||
) {
|
||||
throw new ProfileApiError(
|
||||
"Workspace public identity bundle returned an invalid response.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
publicBundle = {
|
||||
workspace_id: boundedStringValue(
|
||||
bundle.workspace_id,
|
||||
@@ -468,7 +439,6 @@ export function parseWorkspaceSigningIdentityResponse(
|
||||
"Workspace public identity bundle",
|
||||
128,
|
||||
),
|
||||
revision: bundleRevision,
|
||||
};
|
||||
}
|
||||
if (
|
||||
@@ -478,8 +448,7 @@ export function parseWorkspaceSigningIdentityResponse(
|
||||
publicBundle.key_id !== identity.key_id ||
|
||||
publicBundle.algorithm !== identity.algorithm ||
|
||||
publicBundle.public_key !== identity.public_key ||
|
||||
publicBundle.public_key_fingerprint !== identity.public_key_fingerprint ||
|
||||
publicBundle.revision !== identity.revision
|
||||
publicBundle.public_key_fingerprint !== identity.public_key_fingerprint
|
||||
)
|
||||
) {
|
||||
throw new ProfileApiError(
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import type { WorkingDirectorySummary } from "../sidebar/types.ts";
|
||||
import { formatCurrentWorkdirRevision } from "./workdir-revision.ts";
|
||||
import { formatCurrentWorkdirCheckout } from "./workdir-checkout.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
@@ -32,7 +32,7 @@ function workdir(
|
||||
|
||||
Deno.test("Git detached Workdir shows only its current ref", () => {
|
||||
assertEquals(
|
||||
formatCurrentWorkdirRevision(
|
||||
formatCurrentWorkdirCheckout(
|
||||
workdir(null, "0123456789abcdef0123456789abcdef01234567"),
|
||||
"git",
|
||||
),
|
||||
@@ -42,7 +42,7 @@ Deno.test("Git detached Workdir shows only its current ref", () => {
|
||||
|
||||
Deno.test("Git Workdir with a selector shows selector at current ref", () => {
|
||||
assertEquals(
|
||||
formatCurrentWorkdirRevision(
|
||||
formatCurrentWorkdirCheckout(
|
||||
workdir("feature/current", "fedcba9876543210fedcba9876543210fedcba98"),
|
||||
"git",
|
||||
),
|
||||
@@ -52,7 +52,7 @@ Deno.test("Git Workdir with a selector shows selector at current ref", () => {
|
||||
|
||||
Deno.test("non-Git Workdir does not receive Git hash formatting", () => {
|
||||
assertEquals(
|
||||
formatCurrentWorkdirRevision(
|
||||
formatCurrentWorkdirCheckout(
|
||||
workdir("snapshot", "revision-value"),
|
||||
"archive",
|
||||
),
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { WorkingDirectorySummary } from "../sidebar/types.ts";
|
||||
|
||||
export function formatCurrentWorkdirRevision(
|
||||
export function formatCurrentWorkdirCheckout(
|
||||
workdir: WorkingDirectorySummary,
|
||||
repositoryProvider: string | null | undefined,
|
||||
): string {
|
||||
@@ -239,7 +239,7 @@ export async function deleteSidebarWorker(
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expected_plan_revision: plan.revision,
|
||||
|
||||
expected_plan_digest: plan.digest,
|
||||
worker_target_ids: [candidate.target_id],
|
||||
workdir_target_ids: [],
|
||||
|
||||
@@ -138,10 +138,11 @@ describe("shared catalog observation refresh", () => {
|
||||
latest = state
|
||||
);
|
||||
mock.handlers.onFrame(snapshot());
|
||||
const before = latest.observationVersion;
|
||||
const before = latest.catalogRequest;
|
||||
const old = pending.at(-1)!;
|
||||
mock.handlers.onFrame(snapshot("idle", "generation-2"));
|
||||
expect(latest.observationVersion).toBeGreaterThan(before);
|
||||
expect(latest.catalogRequest).not.toBe(before);
|
||||
expect(before?.aborted).toBe(true);
|
||||
expect(latest.workers[0].lifecycleState).toBe("idle");
|
||||
pending.at(-1)!(response("idle", "generation-2"));
|
||||
await settle();
|
||||
@@ -163,9 +164,10 @@ describe("shared catalog observation refresh", () => {
|
||||
);
|
||||
mock.handlers.onFrame(snapshot());
|
||||
await settle();
|
||||
const before = latest.observationVersion;
|
||||
const before = latest.catalogRequest;
|
||||
await refreshWorkspaceWorkers("team");
|
||||
expect(latest.observationVersion).toBeGreaterThan(before);
|
||||
expect(latest.catalogRequest).not.toBe(before);
|
||||
expect(before?.aborted).toBe(true);
|
||||
expect(fetchMock.mock.calls.every(([url]) => !url.endsWith("/restore")))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
@@ -369,7 +369,7 @@ test("sidebar Delete cannot proceed to execution when its cleanup plan arrives a
|
||||
workspace_id: "stop-test",
|
||||
runtime_id: "runtime-a",
|
||||
generated_at: "2026-10-06T00:00:00Z",
|
||||
revision: "old-plan",
|
||||
|
||||
digest: "old-digest",
|
||||
workdirs: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -34,8 +34,8 @@ export type WorkspaceWorkersState = {
|
||||
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;
|
||||
/** Actual catalog GET identity; changes before dependent cleanup plans can settle. */
|
||||
catalogRequest: AbortSignal | null;
|
||||
catalogRefreshing: boolean;
|
||||
};
|
||||
|
||||
@@ -108,7 +108,7 @@ export function workspaceWorkersStore(
|
||||
loading: true,
|
||||
workers: [],
|
||||
catalogWorkers: null,
|
||||
observationVersion: 0,
|
||||
catalogRequest: null,
|
||||
catalogRefreshing: true,
|
||||
},
|
||||
(set) => {
|
||||
@@ -117,7 +117,7 @@ export function workspaceWorkersStore(
|
||||
loading: false,
|
||||
workers: [],
|
||||
catalogWorkers: [],
|
||||
observationVersion: 0,
|
||||
catalogRequest: null,
|
||||
catalogRefreshing: false,
|
||||
});
|
||||
return;
|
||||
@@ -130,9 +130,7 @@ export function workspaceWorkersStore(
|
||||
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;
|
||||
@@ -144,7 +142,7 @@ export function workspaceWorkersStore(
|
||||
loading,
|
||||
workers,
|
||||
catalogWorkers,
|
||||
observationVersion,
|
||||
catalogRequest: catalogAbort?.signal ?? null,
|
||||
catalogRefreshing,
|
||||
});
|
||||
return true;
|
||||
@@ -160,7 +158,7 @@ export function workspaceWorkersStore(
|
||||
loading: false,
|
||||
workers,
|
||||
catalogWorkers,
|
||||
observationVersion,
|
||||
catalogRequest: catalogAbort?.signal ?? null,
|
||||
catalogRefreshing,
|
||||
});
|
||||
return false;
|
||||
@@ -168,8 +166,6 @@ export function workspaceWorkersStore(
|
||||
};
|
||||
const refreshCatalog = async () => {
|
||||
if (disposed) return;
|
||||
const sequence = ++refreshSequence;
|
||||
const version = ++observationVersion;
|
||||
catalogAbort?.abort();
|
||||
const abort = new AbortController();
|
||||
catalogAbort = abort;
|
||||
@@ -183,8 +179,7 @@ export function workspaceWorkersStore(
|
||||
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
||||
);
|
||||
if (
|
||||
disposed || sequence !== refreshSequence ||
|
||||
version !== observationVersion
|
||||
disposed || abort.signal.aborted || catalogAbort !== abort
|
||||
) return;
|
||||
if (!result.data || result.data.workspace_id !== workspaceId) {
|
||||
reportWorkerFailure(
|
||||
|
||||
@@ -167,14 +167,10 @@ function parseCatalogEntry(
|
||||
function parseProjection(value: unknown): SkillProjectionIdentity {
|
||||
const record = strictObject(
|
||||
value,
|
||||
["config_revision", "tree_digest"],
|
||||
["tree_digest"],
|
||||
"Skill projection identity",
|
||||
);
|
||||
return {
|
||||
config_revision: safeInteger(
|
||||
record.config_revision,
|
||||
"Skill config revision",
|
||||
),
|
||||
tree_digest: boundedString(
|
||||
record.tree_digest,
|
||||
"Skill tree digest",
|
||||
@@ -205,14 +201,12 @@ function parseProvenance(
|
||||
"kind",
|
||||
"id",
|
||||
"virtual_path",
|
||||
"revision",
|
||||
"source_digest",
|
||||
"tree_digest",
|
||||
],
|
||||
"Skill provenance",
|
||||
[
|
||||
"virtual_path",
|
||||
"revision",
|
||||
"source_digest",
|
||||
"tree_digest",
|
||||
],
|
||||
@@ -239,9 +233,6 @@ function parseProvenance(
|
||||
"Skill provenance tree digest",
|
||||
SKILL_API_LIMITS.maxDigestBytes,
|
||||
);
|
||||
const revision = record.revision === undefined
|
||||
? undefined
|
||||
: safeInteger(record.revision, "Skill provenance revision");
|
||||
|
||||
if (
|
||||
!id.startsWith(`${kind}:`) || virtualPath === undefined ||
|
||||
@@ -250,17 +241,14 @@ function parseProvenance(
|
||||
throw contractError("invalid Skill provenance");
|
||||
}
|
||||
if (kind === "builtin") {
|
||||
if (revision !== undefined || treeDigest !== undefined) {
|
||||
if (treeDigest !== undefined) {
|
||||
throw contractError("invalid built-in Skill provenance");
|
||||
}
|
||||
} else {
|
||||
if (revision === undefined || treeDigest === undefined) {
|
||||
if (treeDigest === undefined) {
|
||||
throw contractError("incomplete Workspace Skill provenance");
|
||||
}
|
||||
if (
|
||||
revision !== projection.config_revision ||
|
||||
treeDigest !== projection.tree_digest
|
||||
) {
|
||||
if (treeDigest !== projection.tree_digest) {
|
||||
throw contractError("stale Workspace Skill projection");
|
||||
}
|
||||
}
|
||||
@@ -269,7 +257,6 @@ function parseProvenance(
|
||||
kind,
|
||||
id,
|
||||
virtual_path: virtualPath,
|
||||
revision,
|
||||
source_digest: sourceDigest,
|
||||
tree_digest: treeDigest,
|
||||
};
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
<details class="disclosure">
|
||||
<summary>Technical details</summary>
|
||||
<dl class="technical-values">
|
||||
<div><dt>Revision</dt><dd><code>rev_01J8WZ7C8AV6P3Y2N0M4KQ9B1F</code></dd></div>
|
||||
<div><dt>Observed</dt><dd><time datetime="2026-10-09T14:32:00Z">2026-10-09 14:32 UTC</time></dd></div>
|
||||
<BevelLine as="div" direction="x" length="100%" decorative />
|
||||
<div><dt>Digest</dt><dd><code>sha256:2f8d9bb168afb7b19f447463f3fbe8b4728d8e87a64eaf1afbea1ca4d4c41f21</code></dd></div>
|
||||
</dl>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
const workspaceId = subjectWorkspaceId;
|
||||
const subjectId = subject.id;
|
||||
const request = {
|
||||
expected_behavior_revision: subject.behavior_revision,
|
||||
expected_behavior_md: subject.behavior_md,
|
||||
behavior_md: behaviorDraft,
|
||||
};
|
||||
const isCurrent = () => generation === routeGeneration &&
|
||||
@@ -94,11 +94,12 @@
|
||||
return workspaceRoute(data.workspaceId, '/memory');
|
||||
}
|
||||
|
||||
function memoryHref(memoryId: string): string {
|
||||
return workspaceRoute(
|
||||
function memoryHref(memoryId: string, changeId?: string): string {
|
||||
const path = workspaceRoute(
|
||||
data.workspaceId,
|
||||
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
|
||||
);
|
||||
return changeId ? `${path}?change_id=${encodeURIComponent(changeId)}` : path;
|
||||
}
|
||||
|
||||
function memoryPageHref(cursor?: string | null): string {
|
||||
@@ -192,8 +193,8 @@
|
||||
<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>
|
||||
<dt>Memory fingerprint</dt>
|
||||
<dd>{subject.memory_fingerprint}<small>Content condition for current 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>
|
||||
@@ -218,7 +219,7 @@
|
||||
<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>
|
||||
<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 text 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>
|
||||
@@ -237,7 +238,7 @@
|
||||
{: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}
|
||||
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed. This page does not confirm application by a connected Worker.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -273,18 +274,18 @@
|
||||
<dl>
|
||||
<div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
||||
<div>
|
||||
<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>
|
||||
<dt>Generated from Memory fingerprint</dt>
|
||||
<dd>{snapshot.built_from_memory_fingerprint}<small>The content condition used to generate this surface.</small></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Referenced Memory revisions</dt>
|
||||
<dt>Referenced Memory changes</dt>
|
||||
<dd>
|
||||
{#if snapshot.memory_refs.length === 0}
|
||||
None
|
||||
{:else}
|
||||
<ul class="reference-list">
|
||||
{#each snapshot.memory_refs as reference (`${reference.memory_id}:${reference.revision}`)}
|
||||
<li><a href={memoryHref(reference.memory_id)}><code>{reference.memory_id}</code> · revision {reference.revision}</a></li>
|
||||
{#each snapshot.memory_refs as reference (`${reference.memory_id}:${reference.change_id}`)}
|
||||
<li><a href={memoryHref(reference.memory_id, reference.change_id)}><code>{reference.memory_id}</code> · change {reference.change_id}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -347,7 +348,7 @@
|
||||
</div>
|
||||
<dl>
|
||||
<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>Memory change</dt><dd>{memory.change_id}</dd></div>
|
||||
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</a>
|
||||
|
||||
+52
-50
@@ -3,7 +3,7 @@
|
||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
import type {
|
||||
MemoryEvidenceOrigin,
|
||||
SubjektivMemoryRevisionRef,
|
||||
SubjektivMemoryChangeRef,
|
||||
SubjektivMemorySourceEvidenceRef,
|
||||
} from '#lib/generated/memory-api.ts';
|
||||
import type { PageProps } from './$types';
|
||||
@@ -11,7 +11,7 @@
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const memory = $derived(data.memory.data);
|
||||
const revisions = $derived(data.revisions.data?.items ?? []);
|
||||
const changes = $derived(data.changes.data?.items ?? []);
|
||||
|
||||
function subjectHref(): string {
|
||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`);
|
||||
@@ -24,26 +24,26 @@
|
||||
);
|
||||
}
|
||||
|
||||
function revisionHref(revision: number): string {
|
||||
return `${memoryPath()}?revision=${revision}`;
|
||||
function changeHref(changeId: string): string {
|
||||
return `${memoryPath()}?change_id=${encodeURIComponent(changeId)}`;
|
||||
}
|
||||
|
||||
function revisionPageHref(cursor?: string | null): string {
|
||||
function changePageHref(cursor?: string | null): string {
|
||||
const query = new URLSearchParams();
|
||||
if (memory) query.set('revision', String(memory.revision));
|
||||
if (cursor) query.set('revision_cursor', cursor);
|
||||
if (memory) query.set('change_id', String(memory.change_id));
|
||||
if (cursor) query.set('change_cursor', cursor);
|
||||
const encoded = query.toString();
|
||||
return encoded ? `${memoryPath()}?${encoded}` : memoryPath();
|
||||
}
|
||||
|
||||
function derivedHref(reference: SubjektivMemoryRevisionRef): string {
|
||||
return `${memoryPath(reference.memory_id)}?revision=${reference.revision}`;
|
||||
function derivedHref(reference: SubjektivMemoryChangeRef): string {
|
||||
return `${memoryPath(reference.memory_id)}?change_id=${encodeURIComponent(reference.change_id)}`;
|
||||
}
|
||||
|
||||
function bodyContinuationHref(): string | null {
|
||||
if (!memory?.body_truncated || memory.body_next_offset == null || memory.body_next_byte_offset == null) return null;
|
||||
const query = new URLSearchParams({
|
||||
revision: String(memory.revision),
|
||||
change_id: String(memory.change_id),
|
||||
offset: String(memory.body_next_offset),
|
||||
byte_offset: String(memory.body_next_byte_offset),
|
||||
});
|
||||
@@ -53,7 +53,7 @@
|
||||
function evidenceContinuationHref(): string | null {
|
||||
if (!memory?.evidence_has_more || !memory.evidence_next_cursor) return null;
|
||||
const query = new URLSearchParams({
|
||||
revision: String(memory.revision),
|
||||
change_id: String(memory.change_id),
|
||||
evidence_cursor: memory.evidence_next_cursor,
|
||||
});
|
||||
return `${memoryPath()}?${query}`;
|
||||
@@ -81,7 +81,6 @@
|
||||
origin.worker_id && `worker ${origin.worker_id}`,
|
||||
origin.flow_selector && `flow ${origin.flow_selector}`,
|
||||
origin.flow_definition_id && `definition ${origin.flow_definition_id}`,
|
||||
origin.flow_definition_revision != null && `definition revision ${origin.flow_definition_revision}`,
|
||||
].filter(Boolean);
|
||||
return `${label(origin.kind)}${identity.length ? ` · ${identity.join(' · ')}` : ''}`;
|
||||
}
|
||||
@@ -93,7 +92,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{memory?.claim ?? data.memoryId} · Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Committed Memory detail, immutable revisions, and provenance" />
|
||||
<meta name="description" content="Committed Memory detail, immutable changes, and provenance" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="memory-page memory-detail-page" aria-labelledby="memory-detail-heading" data-memory-view="detail" data-memory-state={data.memory.error ? 'error' : memory ? memory.state : 'unavailable'}>
|
||||
@@ -105,7 +104,7 @@
|
||||
<div class="pill-row">
|
||||
<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}
|
||||
{#if memory.change_id !== memory.current_change_id}<span class="historical-pill">Historical change</span>{/if}
|
||||
</div>
|
||||
<h1 id="memory-detail-heading">{memory.claim}</h1>
|
||||
<code class="memory-id" title={memory.memory_id}>{memory.memory_id}</code>
|
||||
@@ -114,7 +113,7 @@
|
||||
</header>
|
||||
|
||||
<dl class="memory-facts" aria-label="Memory details">
|
||||
<div><dt>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
||||
<div><dt>Memory change</dt><dd><code>{memory.change_id}</code><small>{memory.change_id === memory.current_change_id ? 'Current change' : `Current change: ${memory.current_change_id}`}</small></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>
|
||||
@@ -223,48 +222,48 @@
|
||||
<p class="empty-copy">No derivation references.</p>
|
||||
{:else}
|
||||
<ul class="derived-list">
|
||||
{#each memory.derived_from as reference (`${reference.memory_id}:${reference.revision}`)}
|
||||
<li><a href={derivedHref(reference)}><code>{reference.memory_id}</code><span>Revision {reference.revision}</span></a></li>
|
||||
{#each memory.derived_from as reference (`${reference.memory_id}:${reference.change_id}`)}
|
||||
<li><a href={derivedHref(reference)}><code>{reference.memory_id}</code><span>Change {reference.change_id}</span></a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="detail-section revision-section" aria-labelledby="revision-heading">
|
||||
<section class="detail-section change-section" aria-labelledby="change-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} shown{data.revisions.data.has_more ? ' · more available' : ''}</span>{/if}
|
||||
<div><p class="memory-eyebrow">Immutable record</p><h2 id="change-heading">Change history</h2></div>
|
||||
{#if data.changes.data}<span>{changes.length} shown{data.changes.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}
|
||||
<p class="section-intro">These are immutable changes to this Memory record. Change IDs identify content; they do not imply a numeric order.</p>
|
||||
{#if data.changes.data}
|
||||
{#if changes.length === 0}
|
||||
<div>
|
||||
<p class="empty-copy">No revision history is available.</p>
|
||||
{#if data.revisionCursor}<p><a href={revisionPageHref()}>Return to the first page</a></p>{/if}
|
||||
<p class="empty-copy">No change history is available.</p>
|
||||
{#if data.changeCursor}<p><a href={changePageHref()}>Return to the first page</a></p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ol class="revision-list">
|
||||
{#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>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>
|
||||
<ol class="change-list">
|
||||
{#each changes as change (change.change_id)}
|
||||
<li class:current={change.change_id === memory.change_id}>
|
||||
<a href={changeHref(change.change_id)} aria-current={change.change_id === memory.change_id ? 'page' : undefined}>
|
||||
<div><strong>Memory change {change.change_id}</strong>{#if change.change_id === memory.current_change_id}<span>Current</span>{/if}<span class="memory-pill is-{change.state}">{titleLabel(change.state)}</span><span class="memory-kind">{titleLabel(change.kind)}</span></div>
|
||||
<p>{change.claim}</p>
|
||||
<small>{change.change_reason} · <time datetime={change.updated_at}>{formatDate(change.updated_at)}</time></small>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<nav class="memory-pagination" aria-label="Revision history pages">
|
||||
{#if data.revisionCursor}<a href={revisionPageHref()}>First page</a>{/if}
|
||||
{#if data.revisions.data.has_more && data.revisions.data.next_cursor}
|
||||
<a href={revisionPageHref(data.revisions.data.next_cursor)}>Next page →</a>
|
||||
<nav class="memory-pagination" aria-label="Change history pages">
|
||||
{#if data.changeCursor}<a href={changePageHref()}>First page</a>{/if}
|
||||
{#if data.changes.data.has_more && data.changes.data.next_cursor}
|
||||
<a href={changePageHref(data.changes.data.next_cursor)}>Next page →</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
{:else if data.revisions.error}
|
||||
<div class="memory-state is-error" role="alert"><strong>Revision history unavailable.</strong><p>{data.revisions.error}</p></div>
|
||||
{:else if data.changes.error}
|
||||
<div class="memory-state is-error" role="alert"><strong>Change history unavailable.</strong><p>{data.changes.error}</p></div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Revision history data is unavailable.</p></div>
|
||||
<div class="memory-state" role="status"><p>Change history data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if data.memory.error}
|
||||
@@ -346,7 +345,7 @@
|
||||
}
|
||||
|
||||
.pill-row,
|
||||
.revision-list a > div,
|
||||
.change-list a > div,
|
||||
.evidence-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -408,6 +407,8 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.memory-facts small { display: block; margin-top: var(--space-1); color: var(--text-muted); }
|
||||
|
||||
.memory-facts dd {
|
||||
margin-top: var(--space-1);
|
||||
overflow-wrap: anywhere;
|
||||
@@ -503,7 +504,7 @@
|
||||
.evidence-list,
|
||||
.source-list,
|
||||
.derived-list,
|
||||
.revision-list {
|
||||
.change-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
@@ -564,8 +565,8 @@
|
||||
|
||||
.derived-list a:hover,
|
||||
.derived-list a:focus-visible,
|
||||
.revision-list a:hover,
|
||||
.revision-list a:focus-visible {
|
||||
.change-list a:hover,
|
||||
.change-list a:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
@@ -578,19 +579,19 @@
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.revision-list {
|
||||
.change-list {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.revision-list li {
|
||||
.change-list li {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.revision-list li.current {
|
||||
.change-list li.current {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.revision-list a {
|
||||
.change-list a {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
@@ -599,13 +600,14 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.revision-list p,
|
||||
.revision-list small {
|
||||
.change-list strong,
|
||||
.change-list p,
|
||||
.change-list small {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.revision-list small {
|
||||
.change-list small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivMemoryListRevisionsResponse,
|
||||
parseSubjektivMemoryListChangesResponse,
|
||||
parseSubjektivMemoryReadResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "#lib/workspace/memory/api.ts";
|
||||
@@ -40,7 +40,6 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
const detailQuery = new URLSearchParams({ limit: "1000" });
|
||||
for (
|
||||
const [key, positive] of [
|
||||
["revision", true],
|
||||
["offset", false],
|
||||
["byte_offset", false],
|
||||
] as const
|
||||
@@ -48,16 +47,18 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
const value = safeIntegerQuery(url.searchParams, key, positive);
|
||||
if (value !== null) detailQuery.set(key, value);
|
||||
}
|
||||
const changeId = boundedCursor(url.searchParams.get("change_id"), 512);
|
||||
if (changeId) detailQuery.set("change_id", changeId);
|
||||
const evidenceCursor = boundedCursor(
|
||||
url.searchParams.get("evidence_cursor"),
|
||||
512,
|
||||
);
|
||||
if (evidenceCursor) detailQuery.set("evidence_cursor", evidenceCursor);
|
||||
const revisionCursor = boundedCursor(url.searchParams.get("revision_cursor"));
|
||||
const revisionQuery = new URLSearchParams({ limit: "100" });
|
||||
if (revisionCursor) revisionQuery.set("cursor", revisionCursor);
|
||||
const changeCursor = boundedCursor(url.searchParams.get("change_cursor"));
|
||||
const changeQuery = new URLSearchParams({ limit: "100" });
|
||||
if (changeCursor) changeQuery.set("cursor", changeCursor);
|
||||
|
||||
const [subject, memory, revisions] = await Promise.all([
|
||||
const [subject, memory, changes] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, subjectPath),
|
||||
@@ -75,10 +76,10 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
loadJson(
|
||||
fetch,
|
||||
`${
|
||||
workspaceApiPath(params.workspaceId, `${memoryPath}/revisions`)
|
||||
}?${revisionQuery}`,
|
||||
workspaceApiPath(params.workspaceId, `${memoryPath}/changes`)
|
||||
}?${changeQuery}`,
|
||||
undefined,
|
||||
(value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId),
|
||||
(value) => parseSubjektivMemoryListChangesResponse(value, memoryId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
]);
|
||||
@@ -87,9 +88,9 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
workspaceId: params.workspaceId,
|
||||
subjectId,
|
||||
memoryId,
|
||||
revisionCursor,
|
||||
changeCursor,
|
||||
subject,
|
||||
memory,
|
||||
revisions,
|
||||
changes,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -35,9 +35,8 @@ function subject() {
|
||||
id: "subject-1",
|
||||
role: "Release coordinator",
|
||||
behavior_md: "Prefer explicit evidence.",
|
||||
behavior_revision: 2,
|
||||
state: "active" as const,
|
||||
store_revision: 9,
|
||||
memory_fingerprint: "fingerprint-9",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
};
|
||||
@@ -122,9 +121,9 @@ test("creates one Subject with the exact role and navigates to its generated det
|
||||
id: "generated-subject-1",
|
||||
role: " Review lead ",
|
||||
behavior_md: "Challenge unsupported assumptions.",
|
||||
behavior_revision: 0,
|
||||
|
||||
state: "active",
|
||||
store_revision: 0,
|
||||
memory_fingerprint: "fingerprint-0",
|
||||
created_at: "2026-01-03T00:00:00Z",
|
||||
updated_at: "2026-01-03T00:00:00Z",
|
||||
}, { status: 201 }));
|
||||
@@ -254,8 +253,8 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
snapshot_id: "snapshot-1",
|
||||
body_md:
|
||||
"# Resident context\n\nUse the **current** committed record.",
|
||||
memory_refs: [{ memory_id: "memory-1", revision: 3 }],
|
||||
built_from_store_revision: 9,
|
||||
memory_refs: [{ memory_id: "memory-1", change_id: "change-current" }],
|
||||
built_from_memory_fingerprint: "fingerprint-9",
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
}),
|
||||
@@ -263,7 +262,7 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
items: [
|
||||
{
|
||||
id: "memory-1",
|
||||
revision: 3,
|
||||
change_id: "change-current",
|
||||
kind: "decision" as const,
|
||||
state: "active" as const,
|
||||
claim: "Current decision",
|
||||
@@ -272,7 +271,7 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
},
|
||||
{
|
||||
id: "memory-2",
|
||||
revision: 2,
|
||||
change_id: "change-earlier",
|
||||
kind: "lesson" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Resolved lesson",
|
||||
@@ -281,7 +280,7 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
},
|
||||
{
|
||||
id: "memory-3",
|
||||
revision: 1,
|
||||
change_id: "change-original",
|
||||
kind: "constraint" as const,
|
||||
state: "retracted" as const,
|
||||
claim: "Retracted constraint",
|
||||
@@ -309,7 +308,7 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
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
|
||||
expect(screen.getByText("Memory fingerprint", { exact: true })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText(/not a Memory count/)).not.toBeNull();
|
||||
expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan(
|
||||
@@ -329,7 +328,6 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
|
||||
return Response.json({
|
||||
...subject(),
|
||||
behavior_md: request.behavior_md,
|
||||
behavior_revision: request.expected_behavior_revision + 1,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -360,11 +358,11 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
|
||||
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."}',
|
||||
'{"expected_behavior_md":"Prefer explicit evidence.","behavior_md":"Ask before irreversible actions."}',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Behavior storage confirmed at revision 3/).textContent,
|
||||
screen.getByText(/Behavior storage confirmed/).textContent,
|
||||
)
|
||||
.toContain("does not confirm application");
|
||||
});
|
||||
@@ -380,7 +378,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
|
||||
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":""}');
|
||||
expect(secondBody).toBe('{"expected_behavior_md":"Ask before irreversible actions.","behavior_md":""}');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No behavior is set.")).not.toBeNull()
|
||||
);
|
||||
@@ -411,7 +409,6 @@ test.each([
|
||||
return Response.json({
|
||||
...nextSubject,
|
||||
behavior_md: request.behavior_md,
|
||||
behavior_revision: request.expected_behavior_revision + 1,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -453,11 +450,11 @@ test.each([
|
||||
`/api/w/${workspaceId}/subjektiv/subjects/${id}/behavior`,
|
||||
);
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
expected_behavior_revision: 2,
|
||||
expected_behavior_md: "Only B behavior.",
|
||||
behavior_md: "Only B behavior.",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
|
||||
expect(screen.getByText(/storage confirmed/)).not.toBeNull()
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -498,8 +495,7 @@ test.each(["success", "failure"])(
|
||||
outcome === "success"
|
||||
? Response.json({
|
||||
...subject(),
|
||||
behavior_md: "Late A response",
|
||||
behavior_revision: 3,
|
||||
behavior_md: "Prefer explicit evidence.",
|
||||
})
|
||||
: Response.json({
|
||||
error: "Forbidden",
|
||||
@@ -524,11 +520,10 @@ test.each(["success", "failure"])(
|
||||
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.getByText(/storage confirmed/)).not.toBeNull()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
@@ -557,14 +552,12 @@ test("a refreshed loader snapshot fences old saves even after returning to the s
|
||||
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,
|
||||
behavior_md: "Prefer explicit evidence.",
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -589,6 +582,28 @@ test("a refreshed loader snapshot fences old saves even after returning to the s
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test.each([409, 503])("behavior save rejection %s preserves the draft and observed CAS text", async (status) => {
|
||||
const fetchMock = vi.fn(async () => Response.json({ message: "save rejected" }, { status }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectPage, { data: subjectPageData() } as never);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
|
||||
await fireEvent.input(textarea, { target: { value: " Unsaved\nbehavior.\n" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull());
|
||||
expect(textarea).toHaveProperty("value", " Unsaved\nbehavior.\n");
|
||||
expect(screen.queryByText(/storage confirmed/)).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]).toEqual([
|
||||
"/api/w/workspace-1/subjektiv/subjects/subject-1/behavior",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ expected_behavior_md: "Prefer explicit evidence.", behavior_md: " Unsaved\nbehavior.\n" }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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
|
||||
@@ -598,7 +613,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
cursor: null,
|
||||
subject: result({ ...subject(), store_revision: 0 }),
|
||||
subject: result({ ...subject(), memory_fingerprint: "fingerprint-0" }),
|
||||
surface: result({ subject_id: "subject-1", availability: "ungenerated" }),
|
||||
memories: result({ limit: 100, items: [], has_more: false }),
|
||||
},
|
||||
@@ -620,7 +635,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
|
||||
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 record = { ...subject(), memory_fingerprint: "fingerprint-0" };
|
||||
const view = render(SubjectPage, {
|
||||
data: {
|
||||
...subjectPageData(record),
|
||||
@@ -633,7 +648,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
|
||||
snapshot_id: "empty-snapshot",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 0,
|
||||
built_from_memory_fingerprint: "fingerprint-0",
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
}
|
||||
@@ -654,7 +669,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
|
||||
"details.subject-technical-details",
|
||||
) as HTMLDetailsElement;
|
||||
expect(technical.open).toBe(false);
|
||||
expect(technical.textContent).toContain("Subject store revision");
|
||||
expect(technical.textContent).toContain("Memory fingerprint");
|
||||
if (availability === "ready") {
|
||||
expect(screen.getByText("Resident context is current but empty.")).not
|
||||
.toBeNull();
|
||||
@@ -663,7 +678,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
|
||||
expect(
|
||||
view.container.querySelector("details.surface-details")?.textContent,
|
||||
)
|
||||
.toContain("Generated from Subject revision");
|
||||
.toContain("Generated from Memory fingerprint");
|
||||
}
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
@@ -693,7 +708,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
snapshot_id: "snapshot-empty",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 9,
|
||||
built_from_memory_fingerprint: "fingerprint-9",
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
}),
|
||||
@@ -760,18 +775,18 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
);
|
||||
});
|
||||
|
||||
test("renders committed Memory detail, provenance, derivation, and immutable revisions", async () => {
|
||||
test("renders committed Memory detail, provenance, derivation, and immutable changes", async () => {
|
||||
render(MemoryDetailPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
memoryId: "memory-1",
|
||||
revisionCursor: "revision-current",
|
||||
changeCursor: "a/current",
|
||||
subject: result(subject()),
|
||||
memory: result({
|
||||
memory_id: "memory-1",
|
||||
revision: 2,
|
||||
current_revision: 3,
|
||||
change_id: "z/earlier & context",
|
||||
current_change_id: "a/current",
|
||||
kind: "decision" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Keep provenance typed",
|
||||
@@ -782,7 +797,9 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
body_offset: 0,
|
||||
body_byte_offset: 0,
|
||||
body_truncated: false,
|
||||
body_truncated: true,
|
||||
body_next_offset: 2,
|
||||
body_next_byte_offset: 0,
|
||||
source_candidate_ids: ["candidate-1"],
|
||||
source_candidates: [{
|
||||
candidate_id: "candidate-1",
|
||||
@@ -802,28 +819,29 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
|
||||
source_refs_total: 1,
|
||||
source_refs_truncated: false,
|
||||
}],
|
||||
derived_from: [{ memory_id: "memory-parent", revision: 4 }],
|
||||
evidence_has_more: false,
|
||||
derived_from: [{ memory_id: "memory-parent", change_id: "parent/source & context" }],
|
||||
evidence_has_more: true,
|
||||
evidence_next_cursor: "evidence/next",
|
||||
}),
|
||||
revisions: result({
|
||||
changes: result({
|
||||
memory_id: "memory-1",
|
||||
current_revision: 3,
|
||||
current_change_id: "a/current",
|
||||
items: [{
|
||||
revision: 3,
|
||||
change_id: "a/current",
|
||||
kind: "decision" as const,
|
||||
state: "active" as const,
|
||||
claim: "Current claim",
|
||||
change_reason: "Clarified",
|
||||
updated_at: "2026-01-03T00:00:00Z",
|
||||
}, {
|
||||
revision: 2,
|
||||
change_id: "z/earlier & context",
|
||||
kind: "decision" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Earlier claim",
|
||||
change_reason: "Resolved",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}],
|
||||
next_cursor: "revision-next",
|
||||
next_cursor: "change-next",
|
||||
has_more: true,
|
||||
}),
|
||||
},
|
||||
@@ -836,11 +854,29 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
|
||||
expect(screen.getByText("candidate-1")).not.toBeNull();
|
||||
expect(screen.getByText("Decision discussion")).not.toBeNull();
|
||||
expect(screen.getByText("memory-parent")).not.toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "Revision history" })).not
|
||||
expect(screen.getByRole("heading", { name: "Change history" })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("Historical revision")).not.toBeNull();
|
||||
expect(screen.getByRole("navigation", { name: "Revision history pages" }))
|
||||
expect(screen.getByText("Historical change")).not.toBeNull();
|
||||
expect(screen.getByRole("navigation", { name: "Change history pages" }))
|
||||
.not.toBeNull();
|
||||
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||
.toContain("revision_cursor=revision-next");
|
||||
const historicalLink = screen.getByRole("link", { name: /Memory change z\/earlier/ });
|
||||
expect(historicalLink.getAttribute("aria-current")).toBe("page");
|
||||
expect(historicalLink.getAttribute("href")).toBe("/w/workspace-1/memory/subject-1/memory-1?change_id=z%2Fearlier%20%26%20context");
|
||||
const currentLink = screen.getByRole("link", { name: /Memory change a\/current/ });
|
||||
expect(currentLink.getAttribute("aria-current")).toBeNull();
|
||||
expect(currentLink.textContent).toContain("Current");
|
||||
expect(screen.getByRole("link", { name: /memory-parent/ }).getAttribute("href")).toBe("/w/workspace-1/memory/subject-1/memory-parent?change_id=parent%2Fsource%20%26%20context");
|
||||
const bodyHref = screen.getByRole("link", { name: "View next body segment" }).getAttribute("href");
|
||||
const bodyQuery = new URL(bodyHref!, "https://example.com").searchParams;
|
||||
expect(bodyQuery.get("change_id")).toBe("z/earlier & context");
|
||||
expect(bodyQuery.get("offset")).toBe("2");
|
||||
expect(bodyQuery.get("byte_offset")).toBe("0");
|
||||
const evidenceHref = screen.getByRole("link", { name: "View next evidence page" }).getAttribute("href");
|
||||
const evidenceQuery = new URL(evidenceHref!, "https://example.com").searchParams;
|
||||
expect(evidenceQuery.get("change_id")).toBe("z/earlier & context");
|
||||
expect(evidenceQuery.get("evidence_cursor")).toBe("evidence/next");
|
||||
const nextHref = screen.getByRole("link", { name: /Next page/ }).getAttribute("href");
|
||||
const nextQuery = new URL(nextHref!, "https://example.com").searchParams;
|
||||
expect(nextQuery.get("change_cursor")).toBe("change-next");
|
||||
expect(nextQuery.get("change_id")).toBe("z/earlier & context");
|
||||
});
|
||||
|
||||
+2
-2
@@ -59,12 +59,12 @@
|
||||
<div><dt>Repository</dt><dd>{mergeRequest.repository_key}</dd></div>
|
||||
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
|
||||
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
|
||||
<div><dt>Live source revision</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
|
||||
<div><dt>Live source ref</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
|
||||
{#if mergeRequest.source.diagnostic}
|
||||
<div><dt>Source observation error</dt><dd>{mergeRequest.source.diagnostic.code}: {mergeRequest.source.diagnostic.message}</dd></div>
|
||||
{/if}
|
||||
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
|
||||
<div><dt>Target revision</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
|
||||
<div><dt>Target ref</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
|
||||
runtime_id: operation.runtimeId,
|
||||
host_key: selectedHostKey,
|
||||
expected_host_trust_revision: probe.expected_host_trust_revision
|
||||
expected_host_key_fingerprint: probe.expected_host_key_fingerprint
|
||||
};
|
||||
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
|
||||
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
|
||||
@@ -147,8 +147,8 @@
|
||||
<dd><a href={`/w/${encodeURIComponent(data.workspace.workspace_id)}/settings/repository-access`}>Manage SSH credentials and pinned host keys</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source revision</dt>
|
||||
<dd>{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}</dd>
|
||||
<dt>Source fingerprint</dt>
|
||||
<dd>{data.repository.item.source_fingerprint}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Observed</dt>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</a>
|
||||
</header>
|
||||
<p>
|
||||
These launch profiles are derived from the active Workspace configuration revision. Edit Profile declarations and Decodal sources in the shared configuration editor.
|
||||
These launch profiles are derived from the active Workspace configuration. Edit Profile declarations and Decodal sources in the shared configuration editor.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
@@ -59,7 +59,7 @@
|
||||
<p class="status-message error">{message}</p>
|
||||
{:else if profileSettings}
|
||||
<p class="settings-note">
|
||||
Revision {profileSettings.config_revision ?? "unknown"} · tree {profileSettings.tree_digest ?? "unknown"} · projection {profileSettings.projection_digest ?? "unknown"}
|
||||
Tree {profileSettings.tree_digest ?? "unknown"} · projection {profileSettings.projection_digest ?? "unknown"}
|
||||
</p>
|
||||
<ul class="settings-profile-list">
|
||||
{#each profileSettings.profiles as profile (profile.profile_id)}
|
||||
|
||||
@@ -95,14 +95,14 @@
|
||||
let hostname = $state('');
|
||||
let port = $state(22);
|
||||
let hostKey = $state('');
|
||||
let hostExpectedRevision = $state<number | null>(null);
|
||||
let hostExpectedFingerprint = $state<string | null>(null);
|
||||
let hostErrors = $state<RepositoryAccessFieldErrors>({});
|
||||
let hostTrustIdInput = $state<HTMLInputElement>();
|
||||
let hostKeyInput = $state<HTMLTextAreaElement>();
|
||||
let hostFormElement = $state<HTMLFormElement>();
|
||||
let hostReturnFocus: HTMLElement | null = null;
|
||||
const editingHostTrust = $derived(
|
||||
hostExpectedRevision === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null
|
||||
hostExpectedFingerprint === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null
|
||||
);
|
||||
|
||||
function resetPageState(next: PageProps['data']): void {
|
||||
@@ -139,7 +139,7 @@
|
||||
hostname = '';
|
||||
port = 22;
|
||||
hostKey = '';
|
||||
hostExpectedRevision = null;
|
||||
hostExpectedFingerprint = null;
|
||||
hostErrors = {};
|
||||
hostReturnFocus = null;
|
||||
}
|
||||
@@ -460,7 +460,7 @@
|
||||
try {
|
||||
const body: RotateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-rotate'),
|
||||
expected_revision: credential.current_revision,
|
||||
expected_public_key_fingerprint: credential.public_key_fingerprint,
|
||||
private_key: rotatePrivateKey,
|
||||
passphrase: rotatePassphrase || null
|
||||
};
|
||||
@@ -504,7 +504,7 @@
|
||||
try {
|
||||
const body: DeleteRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-delete'),
|
||||
expected_revision: credential.current_revision
|
||||
expected_public_key_fingerprint: credential.public_key_fingerprint
|
||||
};
|
||||
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`);
|
||||
requireCurrentOperation(operation);
|
||||
@@ -528,7 +528,7 @@
|
||||
hostname = '';
|
||||
port = 22;
|
||||
hostKey = '';
|
||||
hostExpectedRevision = null;
|
||||
hostExpectedFingerprint = null;
|
||||
hostErrors = {};
|
||||
hostFormNotice = null;
|
||||
await tick();
|
||||
@@ -542,7 +542,7 @@
|
||||
hostname = hostTrust.hostname;
|
||||
port = hostTrust.port;
|
||||
hostKey = hostTrust.host_key;
|
||||
hostExpectedRevision = hostTrust.current_revision;
|
||||
hostExpectedFingerprint = hostTrust.fingerprint;
|
||||
hostErrors = {};
|
||||
hostFormNotice = null;
|
||||
await tick();
|
||||
@@ -552,13 +552,13 @@
|
||||
async function closeHostEditor(restore = true) {
|
||||
const returnFocus = hostReturnFocus;
|
||||
const shouldRestoreFocus = restore && focusIsInside(hostFormElement);
|
||||
const fallbackId = hostExpectedRevision !== null ? `rotate-host-${hostTrustId}` : undefined;
|
||||
const fallbackId = hostExpectedFingerprint !== null ? `rotate-host-${hostTrustId}` : undefined;
|
||||
hostEditorOpen = false;
|
||||
hostTrustId = '';
|
||||
hostname = '';
|
||||
port = 22;
|
||||
hostKey = '';
|
||||
hostExpectedRevision = null;
|
||||
hostExpectedFingerprint = null;
|
||||
hostErrors = {};
|
||||
hostFormNotice = null;
|
||||
hostReturnFocus = null;
|
||||
@@ -571,7 +571,7 @@
|
||||
await focusFirstInvalid(hostFormElement);
|
||||
return;
|
||||
}
|
||||
const rotating = hostExpectedRevision !== null;
|
||||
const rotating = hostExpectedFingerprint !== null;
|
||||
const rotationTarget = editingHostTrust;
|
||||
if (rotating && !rotationTarget) {
|
||||
hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' };
|
||||
@@ -587,7 +587,7 @@
|
||||
hostname: rotationTarget?.hostname ?? hostname.trim(),
|
||||
port: rotationTarget?.port ?? port,
|
||||
host_key: hostKey.trim(),
|
||||
expected_revision: hostExpectedRevision
|
||||
expected_fingerprint: hostExpectedFingerprint
|
||||
};
|
||||
const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key');
|
||||
requireCurrentOperation(operation);
|
||||
@@ -619,7 +619,7 @@
|
||||
try {
|
||||
const body: DeleteRepositorySshHostTrustRequest = {
|
||||
operation_id: operationId('host-trust-delete'),
|
||||
expected_revision: hostTrust.current_revision
|
||||
expected_fingerprint: hostTrust.fingerprint
|
||||
};
|
||||
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`);
|
||||
requireCurrentOperation(operation);
|
||||
@@ -677,7 +677,6 @@
|
||||
<details class="repository-access-technical">
|
||||
<summary>Technical details</summary>
|
||||
<dl>
|
||||
<div><dt>Config revision</dt><dd>{accessProjection.config_revision}</dd></div>
|
||||
<div><dt>Projection digest</dt><dd><code>{accessProjection.projection_digest}</code></dd></div>
|
||||
</dl>
|
||||
</details>
|
||||
@@ -802,12 +801,6 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if credentials.length > 0 && !data.credentialsError}
|
||||
<details class="repository-access-technical">
|
||||
<summary>Credential revisions</summary>
|
||||
<dl>{#each credentials as credential (credential.credential_id)}<div><dt>{credential.credential_id}</dt><dd>Revision {credential.current_revision}</dd></div>{/each}</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="repository-access-section" aria-labelledby="host-trust-heading">
|
||||
@@ -862,30 +855,24 @@
|
||||
<a class="inline-link" href={connectionTestHref}>{sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'}</a>
|
||||
|
||||
{#if hostEditorOpen}
|
||||
<form bind:this={hostFormElement} class="repository-access-form" aria-busy={hostExpectedRevision === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} novalidate onsubmit={(event) => { event.preventDefault(); void saveHostTrust(); }}>
|
||||
<form bind:this={hostFormElement} class="repository-access-form" aria-busy={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} novalidate onsubmit={(event) => { event.preventDefault(); void saveHostTrust(); }}>
|
||||
<div class="repository-access-form-heading">
|
||||
<div>
|
||||
<h3>{hostExpectedRevision === null ? 'Add pinned host key' : `Rotate key for ${hostname}:${port}`}</h3>
|
||||
<p>{hostExpectedRevision === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : `This keeps the endpoint fixed and replaces its current trusted identity. ${editingHostTrust?.referenced_repositories.length ? `Explicitly referenced by ${editingHostTrust.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this pin. '}Repositories without an explicit host-key binding may also select it automatically when it is the unique pin matching this host and port.`}</p>
|
||||
<h3>{hostExpectedFingerprint === null ? 'Add pinned host key' : `Rotate key for ${hostname}:${port}`}</h3>
|
||||
<p>{hostExpectedFingerprint === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : `This keeps the endpoint fixed and replaces its current trusted identity. ${editingHostTrust?.referenced_repositories.length ? `Explicitly referenced by ${editingHostTrust.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this pin. '}Repositories without an explicit host-key binding may also select it automatically when it is the unique pin matching this host and port.`}</p>
|
||||
</div>
|
||||
{#if hostExpectedRevision !== null}<div><small>Current fingerprint</small><code>{editingHostTrust?.fingerprint}</code></div>{/if}
|
||||
{#if hostExpectedFingerprint !== null}<div><small>Current fingerprint</small><code>{editingHostTrust?.fingerprint}</code></div>{/if}
|
||||
</div>
|
||||
{#if hostFormNotice}<p class="repository-access-notice {hostFormNotice.tone}" role={hostFormNotice.tone === 'error' ? 'alert' : 'status'}>{hostFormNotice.text}</p>{/if}
|
||||
<div class="repository-access-form-grid">
|
||||
<label><span>Host trust id</span><input bind:this={hostTrustIdInput} bind:value={hostTrustId} disabled={hostExpectedRevision !== null} aria-invalid={Boolean(hostErrors.hostTrustId)} aria-describedby={hostErrors.hostTrustId ? 'host-id-error' : undefined} />{#if hostErrors.hostTrustId}<small id="host-id-error" class="field-error">{hostErrors.hostTrustId}</small>{/if}</label>
|
||||
<label><span>Hostname</span><input bind:value={hostname} disabled={hostExpectedRevision !== null} aria-invalid={Boolean(hostErrors.hostname)} aria-describedby={hostErrors.hostname ? 'hostname-error' : undefined} />{#if hostErrors.hostname}<small id="hostname-error" class="field-error">{hostErrors.hostname}</small>{/if}</label>
|
||||
<label><span>Port</span><input type="number" bind:value={port} disabled={hostExpectedRevision !== null} aria-invalid={Boolean(hostErrors.port)} aria-describedby={hostErrors.port ? 'host-port-error' : undefined} />{#if hostErrors.port}<small id="host-port-error" class="field-error">{hostErrors.port}</small>{/if}</label>
|
||||
<label><span>Host trust id</span><input bind:this={hostTrustIdInput} bind:value={hostTrustId} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.hostTrustId)} aria-describedby={hostErrors.hostTrustId ? 'host-id-error' : undefined} />{#if hostErrors.hostTrustId}<small id="host-id-error" class="field-error">{hostErrors.hostTrustId}</small>{/if}</label>
|
||||
<label><span>Hostname</span><input bind:value={hostname} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.hostname)} aria-describedby={hostErrors.hostname ? 'hostname-error' : undefined} />{#if hostErrors.hostname}<small id="hostname-error" class="field-error">{hostErrors.hostname}</small>{/if}</label>
|
||||
<label><span>Port</span><input type="number" bind:value={port} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.port)} aria-describedby={hostErrors.port ? 'host-port-error' : undefined} />{#if hostErrors.port}<small id="host-port-error" class="field-error">{hostErrors.port}</small>{/if}</label>
|
||||
<label class="wide"><span>OpenSSH public host key (Ed25519)</span><textarea bind:this={hostKeyInput} bind:value={hostKey} rows="4" aria-invalid={Boolean(hostErrors.hostKey)} aria-describedby={hostErrors.hostKey ? 'host-key-error host-key-help' : 'host-key-help'}></textarea><small id="host-key-help">The new fingerprint is derived from this key after save; compare the key with a trusted source first.</small>{#if hostErrors.hostKey}<small id="host-key-error" class="field-error">{hostErrors.hostKey}</small>{/if}</label>
|
||||
</div>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={hostExpectedRevision === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)}>{hostExpectedRevision === null ? (isPending('add-host-trust') ? 'Saving…' : 'Pin host key') : (isPending(`rotate-host-${hostTrustId}`) ? 'Saving…' : 'Save new key')}</button><button type="button" class="secondary" disabled={hostExpectedRevision === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} onclick={() => void closeHostEditor()}>Cancel</button></div>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)}>{hostExpectedFingerprint === null ? (isPending('add-host-trust') ? 'Saving…' : 'Pin host key') : (isPending(`rotate-host-${hostTrustId}`) ? 'Saving…' : 'Save new key')}</button><button type="button" class="secondary" disabled={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} onclick={() => void closeHostEditor()}>Cancel</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if hostTrusts.length > 0 && !data.hostTrustsError}
|
||||
<details class="repository-access-technical">
|
||||
<summary>Host key revisions</summary>
|
||||
<dl>{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}<div><dt>{hostTrust.host_trust_id}</dt><dd>Revision {hostTrust.current_revision}</dd></div>{/each}</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+1
-14
@@ -18,7 +18,6 @@ const defaultCredential = {
|
||||
name: "Workspace default",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:default",
|
||||
current_revision: 3,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
@@ -31,7 +30,6 @@ const deployCredential = {
|
||||
name: "Deploy key",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy",
|
||||
current_revision: 2,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
@@ -46,7 +44,6 @@ const hostTrust = {
|
||||
key_algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA-current",
|
||||
fingerprint: "SHA256:host-current",
|
||||
current_revision: 4,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
referenced_repositories: [],
|
||||
@@ -62,7 +59,6 @@ function pageData(workspaceId = "workspace-1") {
|
||||
credentialsError: null,
|
||||
publicKeys: [{
|
||||
credential_id: "workspace-default",
|
||||
current_revision: 3,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:default",
|
||||
public_key: "ssh-ed25519 AAAA-default",
|
||||
@@ -72,7 +68,6 @@ function pageData(workspaceId = "workspace-1") {
|
||||
hostTrustsError: null,
|
||||
accessProjection: {
|
||||
workspace_id: workspaceId,
|
||||
config_revision: 1,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [],
|
||||
},
|
||||
@@ -144,7 +139,6 @@ test("fences a deferred editor while an unrelated host draft remains active afte
|
||||
if (url.endsWith("/credentials/generated/public-key")) {
|
||||
return Promise.resolve(Response.json({
|
||||
credential_id: "generated",
|
||||
current_revision: 1,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:generated",
|
||||
public_key: "ssh-ed25519 AAAA-generated",
|
||||
@@ -202,7 +196,6 @@ test("fences a deferred editor while an unrelated host draft remains active afte
|
||||
credential_id: "generated",
|
||||
name: "Generated key",
|
||||
public_key_fingerprint: "SHA256:generated",
|
||||
current_revision: 1,
|
||||
}));
|
||||
expect(await screen.findByText("Generated key")).not.toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "Add pinned host key" })).not
|
||||
@@ -321,7 +314,6 @@ test("rerendering for another Workspace resets local state and fences stale resp
|
||||
}],
|
||||
accessProjection: {
|
||||
workspace_id: "workspace-2",
|
||||
config_revision: 7,
|
||||
projection_digest: "sha256:workspace-b",
|
||||
bindings: [],
|
||||
},
|
||||
@@ -335,7 +327,6 @@ test("rerendering for another Workspace resets local state and fences stale resp
|
||||
|
||||
stalePublicKey.resolve(Response.json({
|
||||
credential_id: "deploy",
|
||||
current_revision: 2,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy",
|
||||
public_key: "ssh-ed25519 AAAA-stale",
|
||||
@@ -363,7 +354,6 @@ test("retries public-key loading after a post-rotation refresh failure", async (
|
||||
return Promise.resolve(Response.json({
|
||||
...deployCredential,
|
||||
public_key_fingerprint: "SHA256:deploy-new",
|
||||
current_revision: 3,
|
||||
rotated_at: "2026-02-01T00:00:00Z",
|
||||
}));
|
||||
}
|
||||
@@ -374,7 +364,6 @@ test("retries public-key loading after a post-rotation refresh failure", async (
|
||||
}
|
||||
return Promise.resolve(Response.json({
|
||||
credential_id: "deploy",
|
||||
current_revision: 3,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy-new",
|
||||
public_key: newPublicKey,
|
||||
@@ -388,7 +377,6 @@ test("retries public-key loading after a post-rotation refresh failure", async (
|
||||
const data = pageData();
|
||||
data.publicKeys.push({
|
||||
credential_id: "deploy",
|
||||
current_revision: 2,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy",
|
||||
public_key: "ssh-ed25519 AAAA-old-deploy",
|
||||
@@ -454,7 +442,7 @@ test("locks a rotation to its host and port and discloses explicit and automatic
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
|
||||
expect(body.hostname).toBe("github.com");
|
||||
expect(body.port).toBe(22);
|
||||
expect(body.expected_revision).toBe(4);
|
||||
expect(body.expected_fingerprint).toBe("SHA256:host-current");
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
@@ -474,7 +462,6 @@ test("locks a rotation to its host and port and discloses explicit and automatic
|
||||
...hostTrust,
|
||||
host_key: "ssh-ed25519 AAAA-new",
|
||||
fingerprint: "SHA256:host-new",
|
||||
current_revision: 5,
|
||||
}));
|
||||
expect(await within(row).findByText(/Rotated the key for github.com:22/)).not
|
||||
.toBeNull();
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let runtimePublicBundle = $state('');
|
||||
let workspaceTrustId = $state('');
|
||||
let displayName = $state('');
|
||||
let endpoint = $state('');
|
||||
let runtimeFingerprint = $state<string | null>(null);
|
||||
@@ -144,11 +145,13 @@
|
||||
}
|
||||
await createRemoteRuntime(data.workspaceId, {
|
||||
public_bundle: publicBundle,
|
||||
workspace_trust_id: workspaceTrustId,
|
||||
display_name: displayName || null,
|
||||
endpoint,
|
||||
expected_revision: null,
|
||||
expected_binding_id: null,
|
||||
});
|
||||
runtimePublicBundle = '';
|
||||
workspaceTrustId = '';
|
||||
runtimeFingerprint = null;
|
||||
displayName = '';
|
||||
endpoint = '';
|
||||
@@ -168,14 +171,14 @@
|
||||
runtime: WorkspaceRuntimeResource,
|
||||
): RuntimeConnectionTestResponse | undefined {
|
||||
const result = testResults[runtime.runtime_id];
|
||||
return result?.binding_revision === runtime.management?.binding?.revision
|
||||
return result?.binding_id === runtime.management?.binding?.binding_id
|
||||
? result
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
||||
const bindingRevision = runtime.management?.binding?.revision;
|
||||
if (typeof bindingRevision !== 'number') return;
|
||||
const bindingId = runtime.management?.binding?.binding_id;
|
||||
if (typeof bindingId !== 'string') return;
|
||||
const generation = ++connectionTestGeneration;
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
@@ -183,7 +186,7 @@
|
||||
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
|
||||
if (
|
||||
generation !== connectionTestGeneration ||
|
||||
result.binding_revision !== bindingRevision
|
||||
result.binding_id !== bindingId
|
||||
) {
|
||||
await invalidateAll();
|
||||
return;
|
||||
@@ -258,9 +261,15 @@
|
||||
</button>
|
||||
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
|
||||
<small>
|
||||
Copy the Runtime-issued <code>workspace_trust_id</code> from <code>trust-workspace add/show</code>.
|
||||
It identifies this enrollment, not the Workspace key fingerprint.
|
||||
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
|
||||
service. Existing Workspace trust entries are preserved.
|
||||
</small>
|
||||
<label>
|
||||
Workspace trust ID
|
||||
<input bind:value={workspaceTrustId} required autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
{:else}
|
||||
<p class="section-state">Loading Workspace public identity…</p>
|
||||
{/if}
|
||||
@@ -285,10 +294,10 @@
|
||||
spellcheck="false"
|
||||
placeholder={runtimeBundlePlaceholder}
|
||||
></textarea>
|
||||
<button type="button" disabled={busyRuntimeId !== null} onclick={previewRuntimeFingerprint}>
|
||||
Preview fingerprint
|
||||
</button>
|
||||
</label>
|
||||
<button class="settings-form-wide" type="button" disabled={busyRuntimeId !== null} onclick={previewRuntimeFingerprint}>
|
||||
Preview fingerprint
|
||||
</button>
|
||||
{#if runtimeFingerprint}
|
||||
<dl class="runtime-facts">
|
||||
<div>
|
||||
@@ -321,7 +330,7 @@
|
||||
<div class="settings-action-row">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busyRuntimeId !== null || !data.signingIdentity?.public_bundle || !runtimeFingerprint}
|
||||
disabled={busyRuntimeId !== null || !data.signingIdentity?.public_bundle || !runtimeFingerprint || !workspaceTrustId}
|
||||
>Register Runtime</button>
|
||||
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
||||
Cancel
|
||||
|
||||
+20
-10
@@ -25,6 +25,7 @@
|
||||
let showPublicKey = $state(false);
|
||||
let revealedPublicKey = $state<string | null>(null);
|
||||
let publicKey = $state('');
|
||||
let workspaceTrustId = $state('');
|
||||
let displayName = $state('');
|
||||
let endpoint = $state('');
|
||||
let editingMetadata = $state(false);
|
||||
@@ -49,6 +50,7 @@
|
||||
showPublicKey = false;
|
||||
revealedPublicKey = null;
|
||||
publicKey = '';
|
||||
workspaceTrustId = '';
|
||||
displayName = data.runtimeDetail?.runtime.label ?? '';
|
||||
endpoint = data.runtimeDetail?.endpoint ?? '';
|
||||
editingMetadata = false;
|
||||
@@ -201,12 +203,14 @@
|
||||
identity_id: operation.runtimeId,
|
||||
public_key: key,
|
||||
},
|
||||
workspace_trust_id: workspaceTrustId,
|
||||
display_name: data.runtimeDetail.runtime.label,
|
||||
endpoint: data.runtimeDetail.endpoint,
|
||||
expected_revision: binding.revision,
|
||||
expected_binding_id: binding.binding_id,
|
||||
});
|
||||
if (!isCurrentRoute(operation)) return;
|
||||
publicKey = '';
|
||||
workspaceTrustId = '';
|
||||
showPublicKey = false;
|
||||
revealedPublicKey = null;
|
||||
successMessage = action === 'create'
|
||||
@@ -233,7 +237,7 @@
|
||||
async function revokeTrust(): Promise<void> {
|
||||
if (busyAction !== null || !data.runtimeDetail) return;
|
||||
const trust = data.runtimeDetail.trust_key;
|
||||
if (trust.revision == null || trust.status !== 'active') {
|
||||
if (trust.binding_id == null || trust.status !== 'active') {
|
||||
requestError = 'Only active Workspace trust can be revoked.';
|
||||
return;
|
||||
}
|
||||
@@ -248,7 +252,7 @@
|
||||
const operation = routeFence.capture(data.runtimeId);
|
||||
busyAction = 'revoke';
|
||||
const request: RevokeRuntimeTrustKeyRequest = {
|
||||
expected_revision: trust.revision,
|
||||
expected_binding_id: trust.binding_id,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -289,8 +293,8 @@
|
||||
|
||||
const routeOperation = routeFence.capture(data.runtimeId);
|
||||
const trust = data.runtimeDetail.trust_key;
|
||||
if (trust.revision == null) {
|
||||
deleteRuntimeError = 'The authoritative Runtime binding revision is unavailable. Reload before removal.';
|
||||
if (trust.binding_id == null) {
|
||||
deleteRuntimeError = 'The authoritative Runtime binding identity is unavailable. Reload before removal.';
|
||||
return;
|
||||
}
|
||||
const operationId = runtimeRemovalAttempt.operationId();
|
||||
@@ -302,7 +306,7 @@
|
||||
routeOperation.runtimeId,
|
||||
{
|
||||
operation_id: operationId,
|
||||
expected_binding_revision: trust.revision,
|
||||
expected_binding_id: trust.binding_id,
|
||||
},
|
||||
);
|
||||
if (!isCurrentRoute(routeOperation)) return;
|
||||
@@ -405,11 +409,9 @@
|
||||
<div><dt>Connection state</dt><dd>{runtime.management.binding?.connection_state ?? 'Not configured'}</dd></div>
|
||||
<div><dt>Workspace signing key</dt><dd><code>{runtime.management.binding?.workspace_key_id ?? '—'}</code></dd></div>
|
||||
<div><dt>Verified</dt><dd>{formatTimestamp(runtime.management.binding?.verification?.verified_at)}</dd></div>
|
||||
<div><dt>Verified binding revision</dt><dd>{runtime.management.binding?.verification?.binding_revision?.toString() ?? '—'}</dd></div>
|
||||
<div><dt>Last verification check</dt><dd>{runtime.management.binding?.verification?.last_outcome ?? '—'} · {formatTimestamp(runtime.management.binding?.verification?.last_checked_at)}</dd></div>
|
||||
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
|
||||
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
||||
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
|
||||
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
|
||||
@@ -503,6 +505,15 @@
|
||||
</div>
|
||||
{:else}
|
||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||
<label for="runtime-workspace-trust-id-input">Workspace trust ID</label>
|
||||
<input
|
||||
id="runtime-workspace-trust-id-input"
|
||||
bind:value={workspaceTrustId}
|
||||
required
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<p>Copy the Runtime-issued <code>workspace_trust_id</code> from <code>trust-workspace add/show</code> for the current enrollment. Do not use a key fingerprint or a revoked enrollment ID.</p>
|
||||
<label for="runtime-public-key-input">Runtime public key</label>
|
||||
<textarea
|
||||
id="runtime-public-key-input"
|
||||
@@ -570,13 +581,12 @@
|
||||
<div class="runtime-audit-table-wrap">
|
||||
<table class="runtime-audit-table">
|
||||
<thead>
|
||||
<tr><th>Action</th><th>Revision</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
|
||||
<tr><th>Action</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each detail.recent_audit as entry}
|
||||
<tr>
|
||||
<td>{entry.action}</td>
|
||||
<td>{entry.revision.toString()}</td>
|
||||
<td><code>{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}</code></td>
|
||||
<td><code>{entry.actor_account_id}</code></td>
|
||||
<td>{formatTimestamp(entry.at)}</td>
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
import { parseRuntimeCleanupPlan } from '#lib/workspace/api/runtime-workers.ts';
|
||||
import { parseWorkingDirectoryListResponse } from '#lib/workspace/api/workdirs.ts';
|
||||
import { canRemoveWorkdir, removalCause, removalGuard, removeWorkdir, WorkdirRemovalError } from '#lib/workspace/settings/workdir-removal.ts';
|
||||
import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
|
||||
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
|
||||
import type { CleanupWorkdirCandidate, RuntimeCleanupPlanResponse, WorkingDirectorySummary } from '#lib/workspace/sidebar/types.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
function repositoryKey(workdir: WorkingDirectorySummary): string | null {
|
||||
return workdir.source.kind === 'repository' ? workdir.source.repository_key : null;
|
||||
}
|
||||
function currentRevision(workdir: WorkingDirectorySummary): string {
|
||||
function currentCheckout(workdir: WorkingDirectorySummary): string {
|
||||
const key = repositoryKey(workdir);
|
||||
const provider = key ? data.repositories?.items.find((repository) => repository.repository_key === key)?.provider ?? null : null;
|
||||
return formatCurrentWorkdirRevision(workdir, provider);
|
||||
return formatCurrentWorkdirCheckout(workdir, provider);
|
||||
}
|
||||
function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
|
||||
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
|
||||
@@ -131,7 +131,7 @@
|
||||
<tr>
|
||||
<th>Workdir</th>
|
||||
<th>Repository</th>
|
||||
<th>Revision</th>
|
||||
<th>Checkout</th>
|
||||
<th>Status</th>
|
||||
<th>Cleanliness</th>
|
||||
<th>Occupied by</th>
|
||||
@@ -147,7 +147,7 @@
|
||||
<small><code>{workdir.working_directory_id}</code></small>
|
||||
</td>
|
||||
<td>{repositoryKey(workdir) ?? 'External'}</td>
|
||||
<td><code>{currentRevision(workdir)}</code></td>
|
||||
<td><code>{currentCheckout(workdir)}</code></td>
|
||||
<td>{workdir.status}</td>
|
||||
<td>{workdir.cleanliness ?? 'unknown'}</td>
|
||||
<td>
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import RuntimeSettings from "./+page.svelte";
|
||||
import RuntimeDetail from "./[runtimeId]/+page.svelte";
|
||||
import { identityFixture } from "#lib/workspace/settings/identity.test-fixtures.ts";
|
||||
|
||||
const api = vi.hoisted(() => ({ create: vi.fn(), fingerprint: vi.fn() }));
|
||||
vi.mock("$app/navigation", () => ({ invalidateAll: vi.fn(async () => {}) }));
|
||||
vi.mock("#lib/workspace/api/runtime-management.ts", async (importOriginal) => ({
|
||||
...await importOriginal<
|
||||
typeof import("#lib/workspace/api/runtime-management.ts")
|
||||
>(),
|
||||
createRemoteRuntime: api.create,
|
||||
previewRuntimePublicKeyFingerprint: api.fingerprint,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("Runtime registration requires and preserves the Runtime-issued enrollment ID independently of the fingerprint", async () => {
|
||||
api.fingerprint.mockResolvedValue("sha256:runtime-public-key");
|
||||
api.create.mockResolvedValue({});
|
||||
const data = {
|
||||
workspaceId: "workspace-a",
|
||||
workspace: { permissions: { manage_runtimes: true } },
|
||||
runtimes: { items: [], diagnostics: [] },
|
||||
signingIdentity: identityFixture("workspace-a"),
|
||||
} as unknown as ComponentProps<typeof RuntimeSettings>["data"];
|
||||
render(RuntimeSettings, {
|
||||
props: { data, params: { workspaceId: "workspace-a" } },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Add Runtime" }));
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText("Runtime public bundle", { selector: "textarea" }),
|
||||
{
|
||||
target: {
|
||||
value: JSON.stringify({
|
||||
identity_id: "runtime-a",
|
||||
public_key: "runtime-public-key",
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
await fireEvent.input(screen.getByLabelText("Endpoint"), {
|
||||
target: { value: "https://runtime.example" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Preview fingerprint" }),
|
||||
);
|
||||
const submit = screen.getByRole("button", { name: "Register Runtime" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("sha256:runtime-public-key")).toBeTruthy()
|
||||
);
|
||||
expect(submit).toHaveProperty("disabled", true);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
await fireEvent.input(screen.getByLabelText("Workspace trust ID"), {
|
||||
target: { value: "Runtime-issued-enrollment-A" },
|
||||
});
|
||||
expect(submit).toHaveProperty("disabled", false);
|
||||
await fireEvent.submit(submit.closest("form")!);
|
||||
await waitFor(() => expect(api.create).toHaveBeenCalledOnce());
|
||||
expect(api.create).toHaveBeenCalledWith("workspace-a", {
|
||||
public_bundle: {
|
||||
identity_id: "runtime-a",
|
||||
public_key: "runtime-public-key",
|
||||
},
|
||||
workspace_trust_id: "Runtime-issued-enrollment-A",
|
||||
display_name: null,
|
||||
endpoint: "https://runtime.example",
|
||||
expected_binding_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("Runtime reactivation submits a new enrollment ID with the observed binding instead of reusing revoked trust", async () => {
|
||||
api.fingerprint.mockResolvedValue("sha256:replacement-key");
|
||||
api.create.mockResolvedValue({});
|
||||
const data = {
|
||||
workspaceId: "workspace-a",
|
||||
runtimeId: "runtime-a",
|
||||
workspace: { permissions: { manage_runtimes: true } },
|
||||
runtimeDetail: {
|
||||
workspace_id: "workspace-a",
|
||||
endpoint: "https://runtime.example",
|
||||
runtime: {
|
||||
runtime_id: "runtime-a",
|
||||
label: "Runtime A",
|
||||
diagnostics: [],
|
||||
management: {
|
||||
built_in: false,
|
||||
config_managed: true,
|
||||
removable: true,
|
||||
binding: {
|
||||
state: "revoked",
|
||||
connection_state: "revoked",
|
||||
binding_id: "observed-binding",
|
||||
workspace_trust_id: "revoked-enrollment",
|
||||
},
|
||||
},
|
||||
},
|
||||
trust_key: {
|
||||
status: "revoked",
|
||||
fingerprint: "sha256:old-key",
|
||||
binding_id: "observed-binding",
|
||||
},
|
||||
recent_audit: [],
|
||||
},
|
||||
} as unknown as ComponentProps<typeof RuntimeDetail>["data"];
|
||||
render(RuntimeDetail, {
|
||||
props: {
|
||||
data,
|
||||
params: { workspaceId: "workspace-a", runtimeId: "runtime-a" },
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText("Workspace trust ID")).toHaveProperty(
|
||||
"value",
|
||||
"",
|
||||
);
|
||||
await fireEvent.input(screen.getByLabelText("Workspace trust ID"), {
|
||||
target: { value: "new-enrollment" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText("Runtime public key"), {
|
||||
target: { value: "yoi-ed25519-pub:v1:new-key" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("sha256:replacement-key")).toBeTruthy()
|
||||
);
|
||||
await fireEvent.submit(
|
||||
screen.getByRole("button", { name: "Reactivate with this key" }).closest(
|
||||
"form",
|
||||
)!,
|
||||
);
|
||||
await waitFor(() => expect(api.create).toHaveBeenCalledOnce());
|
||||
expect(api.create).toHaveBeenCalledWith("workspace-a", {
|
||||
public_bundle: {
|
||||
identity_id: "runtime-a",
|
||||
public_key: "yoi-ed25519-pub:v1:new-key",
|
||||
},
|
||||
workspace_trust_id: "new-enrollment",
|
||||
display_name: "Runtime A",
|
||||
endpoint: "https://runtime.example",
|
||||
expected_binding_id: "observed-binding",
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,10 @@ test("saved settings have no editable form, badges or raw data expanded; each te
|
||||
);
|
||||
expect(document.querySelectorAll("details[open]")).toHaveLength(0);
|
||||
expect(document.querySelector(".badge")).toBeNull();
|
||||
expect(screen.queryByText("Revision", { exact: true })).toBeNull();
|
||||
expect(
|
||||
screen.getByText(identityFixture().public_bundle!.public_key_fingerprint),
|
||||
).toBeTruthy();
|
||||
expect(api.mock.calls).toHaveLength(2);
|
||||
expect(api.mock.calls.some(([path]) => String(path).includes("deletion")))
|
||||
.toBe(false);
|
||||
@@ -133,7 +137,7 @@ test("Edit focuses the field; Cancel discards the draft and returns focus withou
|
||||
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
|
||||
});
|
||||
|
||||
test("save uses the current revision, publishes confirmed name and invalidates the Workspace header", async () => {
|
||||
test("save uses the observed update timestamp, publishes confirmed name and invalidates the Workspace header", async () => {
|
||||
const api = mockApi();
|
||||
mount();
|
||||
await fireEvent.input(await openName(), {
|
||||
@@ -152,15 +156,15 @@ test("save uses the current revision, publishes confirmed name and invalidates t
|
||||
)),
|
||||
).toEqual({
|
||||
display_name: "Renamed Workspace",
|
||||
revision: metadataFixture().revision,
|
||||
expected_updated_at: metadataFixture().updated_at,
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledWith("/api/w/home-owner/workspace");
|
||||
});
|
||||
|
||||
test("failed save preserves the draft and saved name; Reload saved name recovers from revision conflicts", async () => {
|
||||
test("failed save preserves the draft and saved name; Reload saved name recovers from metadata conflicts", async () => {
|
||||
mockApi((request) =>
|
||||
request.method === "PUT"
|
||||
? new Response("Revision conflict", { status: 409 })
|
||||
? new Response("Metadata conflict", { status: 409 })
|
||||
: null
|
||||
);
|
||||
mount();
|
||||
@@ -168,7 +172,7 @@ test("failed save preserves the draft and saved name; Reload saved name recovers
|
||||
await fireEvent.input(input, { target: { value: "Keep draft" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||
"Revision conflict",
|
||||
"Metadata conflict",
|
||||
);
|
||||
expect(input.value).toBe("Keep draft");
|
||||
expect(screen.getByText(`Current: ${metadataFixture().display_name}`))
|
||||
@@ -435,7 +439,7 @@ test("reload resumes persisted deletion without sending another POST", async ()
|
||||
"yoi:workspace-deletion:home-owner",
|
||||
JSON.stringify({
|
||||
operation_id: "op-test",
|
||||
expected_revision: metadataFixture().revision,
|
||||
expected_workspace_updated_at: metadataFixture().updated_at,
|
||||
confirmation: metadataFixture().display_name,
|
||||
}),
|
||||
);
|
||||
@@ -501,7 +505,7 @@ test("slow identity keeps the name usable and old identity responses cannot leak
|
||||
test("a completed deletion request from an unmounted Workspace cannot navigate away from the new scope", async () => {
|
||||
const request = {
|
||||
operation_id: "op-test",
|
||||
expected_revision: metadataFixture().revision,
|
||||
expected_workspace_updated_at: metadataFixture().updated_at,
|
||||
confirmation: metadataFixture().display_name,
|
||||
};
|
||||
sessionStorage.setItem(
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or revision"} /></label>
|
||||
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} /></label>
|
||||
<label>Access
|
||||
<select bind:value={target.access}>
|
||||
<option value="read_write">Read and write</option>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
let manualWorkerId = $state("");
|
||||
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
|
||||
let cancellationReason = $state("");
|
||||
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
|
||||
let routeTicketSnapshot = JSON.stringify([initialData.workspaceId, initialData.ticketId, loadedTicket]);
|
||||
let routeGeneration = 0;
|
||||
const workerAssignment = $derived(
|
||||
ticket.assignments.find((assignment) => assignment.role === "worker") ?? null,
|
||||
@@ -164,7 +164,7 @@
|
||||
const incomingTicketId = data.ticketId;
|
||||
const incomingTicket = data.ticket.data;
|
||||
if (!incomingTicket) return;
|
||||
const incomingSnapshot = `${incomingTicketId}:${incomingTicket.item_revision}`;
|
||||
const incomingSnapshot = JSON.stringify([data.workspaceId, incomingTicketId, incomingTicket]);
|
||||
|
||||
untrack(() => {
|
||||
if (incomingSnapshot === routeTicketSnapshot) return;
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
function ticketMutationError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/\(409\)/.test(message)) return `Update conflict. Refresh the Ticket and resolve the current revision or resource binding before retrying. ${message}`;
|
||||
if (/\(409\)/.test(message)) return `Update conflict. Refresh the Ticket and resolve the current content or resource binding before retrying. ${message}`;
|
||||
if (/\(401\)|\(403\)/.test(message)) return `Permission denied. This action did not grant access. ${message}`;
|
||||
if (/\(404\)/.test(message)) return `Resource unavailable or not connected. Check the Ticket and authorized resource connection. ${message}`;
|
||||
return `Outcome unknown or request rejected. Refresh before retrying; no success is inferred. ${message}`;
|
||||
@@ -382,7 +382,7 @@
|
||||
await mutate("state", "/state", {
|
||||
state: nextState,
|
||||
operation_key: crypto.randomUUID(),
|
||||
expected_item_revision: ticket.item_revision,
|
||||
expected_content_digest: ticket.content_digest,
|
||||
expected_state: ticket.state,
|
||||
reason: transitionReason.trim(),
|
||||
body: progressBody.trim() || null,
|
||||
@@ -408,7 +408,7 @@
|
||||
await mutate("close", "/close", {
|
||||
resolution: resolution.trim(),
|
||||
operation_key: crypto.randomUUID(),
|
||||
expected_item_revision: ticket.item_revision,
|
||||
expected_content_digest: ticket.content_digest,
|
||||
expected_state: ticket.state,
|
||||
})
|
||||
) resolution = "";
|
||||
@@ -674,7 +674,7 @@
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or revision"} disabled={busy !== null} /></label>
|
||||
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} disabled={busy !== null} /></label>
|
||||
<label>Access
|
||||
<select bind:value={target.access} disabled={busy !== null}>
|
||||
<option value="read_write">Read and write</option>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
} from '#lib/workspace/api/runtime-workers.ts';
|
||||
import { workerHref } from '#lib/workspace/resource-links.ts';
|
||||
import { formatWorkdirPermissions } from '#lib/workspace/settings/workdir-permissions.ts';
|
||||
import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
|
||||
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
|
||||
import { canOpenWorkerConsole } from '#lib/workspace/sidebar/workers.ts';
|
||||
import { liveWorkerState } from '#lib/workspace/sidebar/worker-state.ts';
|
||||
import type { CleanupWorkerCandidate, RuntimeCleanupPlanResponse, Worker } from '#lib/workspace/sidebar/types.ts';
|
||||
@@ -48,17 +48,17 @@
|
||||
workers = initial.workers?.items ?? [];
|
||||
cleanupPlans = initial.cleanupPlans;
|
||||
let previousCatalog: string | undefined;
|
||||
let previousObservation: number | undefined;
|
||||
let previousRequest: AbortSignal | null | undefined;
|
||||
let previousRefreshing: boolean | undefined;
|
||||
const scope = actionScope();
|
||||
const unsubscribe = workspaceWorkersStore(id).subscribe((state) => {
|
||||
if (!scope.isCurrent()) return;
|
||||
const observationChanged = previousObservation !== state.observationVersion;
|
||||
const observationChanged = previousRequest !== state.catalogRequest;
|
||||
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;
|
||||
previousRequest = state.catalogRequest;
|
||||
previousRefreshing = state.catalogRefreshing;
|
||||
previousCatalog = catalog;
|
||||
catalogRefreshing = state.catalogRefreshing;
|
||||
@@ -210,7 +210,7 @@
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expected_plan_revision: plan.revision,
|
||||
|
||||
expected_plan_digest: plan.digest,
|
||||
worker_target_ids: [candidate.target_id],
|
||||
workdir_target_ids: [],
|
||||
@@ -261,7 +261,7 @@
|
||||
if (directory.source.kind === 'external_grant') {
|
||||
return `${alias}: ${label} · ${formatWorkdirPermissions(effective_permissions)}`;
|
||||
}
|
||||
return `${alias}: ${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
||||
return `${alias}: ${label} · ${formatCurrentWorkdirCheckout(directory, provider)}`;
|
||||
}).join(', ');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
});
|
||||
let protocolSubscription: WorkspaceMultiplexerSubscription | null = null;
|
||||
let pendingSubmissions = $state<PendingSubmissionsSnapshot>({
|
||||
revision: 0,
|
||||
|
||||
notification_count: 0,
|
||||
head_id: null,
|
||||
submissions: [],
|
||||
@@ -2095,7 +2095,6 @@
|
||||
method: "cancel_pending_submission",
|
||||
params: {
|
||||
submission_id: submission.submission_id,
|
||||
expected_revision: pendingSubmissions.revision,
|
||||
},
|
||||
}, "Pending submission cancellation")}
|
||||
>
|
||||
|
||||
+9
-9
@@ -94,7 +94,7 @@ function pageData(workerId = "worker-a", runtimeId = "runtime-a") {
|
||||
function emptySession(): SessionSnapshot {
|
||||
return {
|
||||
pending_submissions: {
|
||||
revision: 0,
|
||||
|
||||
notification_count: 0,
|
||||
head_id: null,
|
||||
submissions: [],
|
||||
@@ -591,13 +591,13 @@ function subworkerFrame() {
|
||||
session.entries.push(reply, { ...reply, entry_id: "child-reply-2" });
|
||||
const snapshot = snapshotEvent(sessionWithUserMessage("parent question")) as Extract<ProtocolEvent, { event: "snapshot" }>;
|
||||
snapshot.data.internal_workers = [{
|
||||
worker: child, revision: 5, session, status: "idle",
|
||||
worker: child, session, status: "idle",
|
||||
in_flight: { blocks: [] }, internal_workers: [],
|
||||
}];
|
||||
const frame = subscribedFrame();
|
||||
frame.message.payload.snapshot.data.events = [snapshot, ...[6, 7].map((revision): ProtocolEvent => ({
|
||||
frame.message.payload.snapshot.data.events = [snapshot, ...[reply, reply].map((entry): ProtocolEvent => ({
|
||||
event: "internal_worker", data: {
|
||||
worker: child, revision, event: { event: "session_entry_committed", data: { entry: reply } },
|
||||
worker: child, event: { event: "session_entry_committed", data: { entry } },
|
||||
},
|
||||
}))];
|
||||
return frame;
|
||||
@@ -782,13 +782,13 @@ test.each([
|
||||
expect(form.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("pending inputs show literal previews above Composer with revision-fenced icon actions", async () => {
|
||||
test("pending inputs show literal previews above Composer with submission-identity icon actions", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
|
||||
const view = render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
const session = emptySession();
|
||||
session.pending_submissions = {
|
||||
revision: 7, head_id: "private-submission-id", notification_count: 1,
|
||||
head_id: "private-submission-id", notification_count: 1,
|
||||
notification_previews: ["<b>通知内容</b>"],
|
||||
submissions: [
|
||||
{ submission_id: "private-submission-id", preview: "<b>実際の入力</b> " + "長文".repeat(100), accepted_at_ms: 1, segment_count: 1, byte_len: 500 },
|
||||
@@ -820,14 +820,14 @@ test("pending inputs show literal previews above Composer with revision-fenced i
|
||||
await fireEvent.click(cancel);
|
||||
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({
|
||||
method: "cancel_pending_submission",
|
||||
params: { submission_id: "private-submission-id", expected_revision: 7 },
|
||||
params: { submission_id: "private-submission-id" },
|
||||
});
|
||||
// Do not remove a row optimistically before the authoritative snapshot arrives.
|
||||
expect(queue.querySelectorAll("li")).toHaveLength(2);
|
||||
latestListener().onFrame({ frame: "event", message: { event: "event", data: {
|
||||
payload: { event: "worker_protocol", data: { worker_id: "worker-a", event: {
|
||||
event: "pending_submissions_changed", data: { pending: {
|
||||
...session.pending_submissions, revision: 8, head_id: "legacy-submission-id",
|
||||
...session.pending_submissions, head_id: "legacy-submission-id",
|
||||
submissions: session.pending_submissions.submissions.slice(1),
|
||||
} },
|
||||
} } },
|
||||
@@ -836,7 +836,7 @@ test("pending inputs show literal previews above Composer with revision-fenced i
|
||||
expect(notifications.textContent).toContain("<b>通知内容</b>");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel queued input 1" }));
|
||||
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({
|
||||
method: "cancel_pending_submission", params: { submission_id: "legacy-submission-id", expected_revision: 8 },
|
||||
method: "cancel_pending_submission", params: { submission_id: "legacy-submission-id" },
|
||||
});
|
||||
latestListener().onFrame(subscribedFrame(emptySession()));
|
||||
await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull());
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
parseWorkerLaunchOptionsResponse,
|
||||
} from '#lib/workspace/api/workers.ts';
|
||||
import { parseSubjektivSubjectListResponse } from '#lib/workspace/memory/api.ts';
|
||||
import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
|
||||
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
|
||||
import { formatExternalGrantPermissionLevel } from '#lib/workspace/settings/workdir-permissions.ts';
|
||||
import {
|
||||
buildCreateWorkspaceWorkerRequest,
|
||||
@@ -46,7 +46,7 @@
|
||||
if (directory.source.kind === 'external_grant') {
|
||||
return `${label} · External · ${formatExternalGrantPermissionLevel(directory.source.grant_permissions)}`;
|
||||
}
|
||||
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
||||
return `${label} · ${formatCurrentWorkdirCheckout(directory, provider)}`;
|
||||
}
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
@@ -52,17 +52,16 @@ function worker(overrides: Partial<Worker> = {}): Worker {
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
function plan(revision = "plan-1"): RuntimeCleanupPlanResponse {
|
||||
function plan(scenario = "plan-1"): RuntimeCleanupPlanResponse {
|
||||
return {
|
||||
workspace_id: "page-test",
|
||||
generated_at: "2026-10-06T00:00:00Z",
|
||||
runtime_id: "runtime-a",
|
||||
revision,
|
||||
digest: `digest-${revision}`,
|
||||
digest: `digest-${scenario}`,
|
||||
diagnostics: [],
|
||||
workdirs: [],
|
||||
workers: [{
|
||||
target_id: `target-${revision}`,
|
||||
target_id: `target-${scenario}`,
|
||||
action: "worker_delete",
|
||||
worker_id: "W-1",
|
||||
runtime_worker_id: "worker-a",
|
||||
@@ -80,7 +79,7 @@ function catalog(id = "page-test") {
|
||||
loading: true,
|
||||
workers: [],
|
||||
catalogWorkers: null,
|
||||
observationVersion: 0,
|
||||
catalogRequest: null,
|
||||
catalogRefreshing: true,
|
||||
});
|
||||
shared.stores.set(id, state);
|
||||
@@ -107,7 +106,7 @@ function publish(store: ReturnType<typeof catalog>, items: Worker[]) {
|
||||
loading: false,
|
||||
workers: [],
|
||||
catalogWorkers: items,
|
||||
observationVersion: previous.observationVersion + 1,
|
||||
catalogRequest: new AbortController().signal,
|
||||
catalogRefreshing: false,
|
||||
}));
|
||||
}
|
||||
@@ -199,7 +198,7 @@ test("refresh start invalidates an installed Delete candidate without requesting
|
||||
.toBeTruthy();
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The flag itself invalidates plans, independently of the observation version.
|
||||
// The flag itself invalidates plans, independently of the catalog request identity.
|
||||
store.update((previous) => ({ ...previous, catalogRefreshing: true }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
|
||||
@@ -211,7 +210,7 @@ test("refresh start invalidates an installed Delete candidate without requesting
|
||||
expect(screen.queryByRole("button", { name: "Delete Catalog worker" }))
|
||||
.toBeNull();
|
||||
|
||||
// No catalog or version change on completion: the transition still reloads.
|
||||
// No catalog or request change on completion: the transition still reloads.
|
||||
store.update((previous) => ({ ...previous, catalogRefreshing: false }));
|
||||
expect(await screen.findByRole("button", { name: "Delete Catalog worker" }))
|
||||
.toBeTruthy();
|
||||
@@ -234,7 +233,7 @@ test.each(["during refresh", "after completion"] as const)(
|
||||
store.update((previous) => ({
|
||||
...previous,
|
||||
catalogRefreshing: true,
|
||||
observationVersion: previous.observationVersion + 1,
|
||||
catalogRequest: new AbortController().signal,
|
||||
catalogWorkers: [
|
||||
worker({ pinned: false, display_name: "Overlay worker" }),
|
||||
],
|
||||
@@ -250,7 +249,7 @@ test.each(["during refresh", "after completion"] as const)(
|
||||
.toBeNull();
|
||||
}
|
||||
|
||||
// Successful catalog GET retains exactly the same catalog and observationVersion.
|
||||
// Successful catalog GET retains exactly the same catalog and catalog request identity.
|
||||
store.update((previous) => ({ ...previous, catalogRefreshing: false }));
|
||||
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
if (settlement === "after completion") {
|
||||
@@ -288,7 +287,7 @@ test("restore follow-up cannot request cleanup while the authoritative catalog r
|
||||
store.update((previous) => ({
|
||||
...previous,
|
||||
catalogRefreshing: true,
|
||||
observationVersion: previous.observationVersion + 1,
|
||||
catalogRequest: new AbortController().signal,
|
||||
}));
|
||||
});
|
||||
await fireEvent.click(
|
||||
|
||||
-1
@@ -88,7 +88,6 @@ function plan(state: string) {
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: runtimeId,
|
||||
generated_at: "2026-10-06T00:00:00Z",
|
||||
revision: state,
|
||||
digest: state,
|
||||
diagnostics: [],
|
||||
workdirs: [],
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
commitConfigTree,
|
||||
ConfigSourceApiError,
|
||||
fetchConfigEntry,
|
||||
fetchConfigRevision,
|
||||
fetchConfigHistory,
|
||||
fetchConfigTree,
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
} from "../../src/lib/workspace/config-source/api.ts";
|
||||
@@ -29,7 +29,6 @@ const entry = {
|
||||
content_digest: "sha256:entry",
|
||||
};
|
||||
const snapshot = {
|
||||
revision: 7,
|
||||
digest: "sha256:tree",
|
||||
entries: { "profiles/main.dcdl": entry },
|
||||
};
|
||||
@@ -57,15 +56,14 @@ Deno.test("config source API commits directly through the workspace scope", asyn
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
if (url.includes("/entries/")) return Promise.resolve(response(entry));
|
||||
if (url.includes("/revisions/")) return Promise.resolve(response(snapshot));
|
||||
if (url.includes("/history/")) return Promise.resolve(response(snapshot));
|
||||
return Promise.resolve(response(tree));
|
||||
}) as typeof fetch;
|
||||
|
||||
await fetchConfigTree("w/one", fetcher);
|
||||
await fetchConfigRevision("w/one", 7, fetcher);
|
||||
await fetchConfigHistory("w/one", "sha256:tree", fetcher);
|
||||
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher);
|
||||
await commitConfigTree("w/one", {
|
||||
base_revision: 4,
|
||||
base_digest: "sha256:base",
|
||||
changes: [],
|
||||
entrypoints: [],
|
||||
@@ -73,7 +71,7 @@ Deno.test("config source API commits directly through the workspace scope", asyn
|
||||
|
||||
assertEquals<unknown>(calls.map((call) => call.url), [
|
||||
"/api/w/w%2Fone/config/source-tree",
|
||||
"/api/w/w%2Fone/config/source-tree/revisions/7",
|
||||
"/api/w/w%2Fone/config/source-tree/history/sha256%3Atree",
|
||||
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
||||
"/api/w/w%2Fone/config/source-tree/commit",
|
||||
]);
|
||||
@@ -81,6 +79,9 @@ Deno.test("config source API commits directly through the workspace scope", asyn
|
||||
assert(
|
||||
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
||||
);
|
||||
assertEquals(JSON.parse(String(calls[3].init?.body)), {
|
||||
base_digest: "sha256:base", changes: [], entrypoints: [],
|
||||
});
|
||||
});
|
||||
|
||||
function withSchema(source: unknown, contributionSource: unknown = "builtin") {
|
||||
@@ -113,7 +114,6 @@ for (const field of ["bundle", "contribution"] as const) {
|
||||
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body);
|
||||
assertEquals<unknown>(
|
||||
await commitConfigTree("w", {
|
||||
base_revision: 7,
|
||||
base_digest: snapshot.digest,
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
@@ -197,12 +197,12 @@ Deno.test("config source API rejects mismatched entry map paths", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("config source API rejects unsafe revisions and oversized entry content", () => {
|
||||
Deno.test("config source API rejects removed snapshot revision fields and oversized entry content", () => {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWorkspaceConfigTreeResponse({
|
||||
...tree,
|
||||
snapshot: { ...snapshot, revision: Number.MAX_SAFE_INTEGER + 1 },
|
||||
snapshot: { ...snapshot, revision: 7 },
|
||||
}),
|
||||
ConfigSourceApiError,
|
||||
"invalid response",
|
||||
@@ -251,7 +251,6 @@ Deno.test("config source API does not materialize imported builtins in the works
|
||||
Deno.test("config source API preserves unknown read-only builtin diagnostics without fallback requests", async () => {
|
||||
const diagnostic = {
|
||||
path: "main.dcdl",
|
||||
revision: 7,
|
||||
tree_digest: "sha256:tree",
|
||||
kind: "import",
|
||||
span: { start_byte: 8, end_byte: 40 },
|
||||
@@ -270,7 +269,6 @@ Deno.test("config source API preserves unknown read-only builtin diagnostics wit
|
||||
const error = await assertRejects(
|
||||
() =>
|
||||
commitConfigTree("w", {
|
||||
base_revision: 7,
|
||||
base_digest: "sha256:tree",
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
@@ -290,7 +288,6 @@ Deno.test("config source API surfaces bounded failed evaluation details", async
|
||||
let message = "";
|
||||
try {
|
||||
await commitConfigTree("w", {
|
||||
base_revision: 1,
|
||||
base_digest: "sha256:base",
|
||||
changes: [],
|
||||
entrypoints: [],
|
||||
|
||||
@@ -156,7 +156,7 @@ Deno.test("commit formats every working Decodal source without a preview roundtr
|
||||
assert(
|
||||
source.includes("await formatWorkingSources();") &&
|
||||
source.includes("entrypoints: entrypoints()") &&
|
||||
source.includes("Committed formatted revision"),
|
||||
source.includes("Committed formatted source tree"),
|
||||
"commit should format first and send the working changes directly to Backend authority",
|
||||
);
|
||||
assert(
|
||||
@@ -167,7 +167,7 @@ Deno.test("commit formats every working Decodal source without a preview roundtr
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("config diagnostics analyze continuously with debounce and generation fencing", async () => {
|
||||
Deno.test("config diagnostics analyze continuously with debounce and request cancellation", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
|
||||
@@ -179,7 +179,7 @@ Deno.test("config diagnostics analyze continuously with debounce and generation
|
||||
source.includes("setTimeout(() =>") &&
|
||||
source.includes("}, 250)") &&
|
||||
source.includes("analyzer.analyze(path, value)") &&
|
||||
source.includes("generation === analysisGeneration") &&
|
||||
source.includes("!request.signal.aborted") &&
|
||||
source.includes("analysisReady"),
|
||||
"source changes should trigger only the latest debounced analysis after snapshot initialization",
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||
Deno.test("toolchain converts reactive-like proxies to plain Worker messages", async () => {
|
||||
const snapshot = new Proxy(
|
||||
{
|
||||
revision: 1,
|
||||
digest: "sha256:test",
|
||||
entries: {},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/// <reference lib="deno.ns" />
|
||||
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { createHash } from "node:crypto";
|
||||
import { assertEquals, assertThrows } from "jsr:@std/assert";
|
||||
import init, {
|
||||
analyze_snapshot,
|
||||
apply_changes,
|
||||
changes_between,
|
||||
complete_current,
|
||||
compose_schema_bundle,
|
||||
evaluate_snapshot,
|
||||
@@ -32,6 +35,23 @@ async function digestText(text: string): Promise<string> {
|
||||
}`;
|
||||
}
|
||||
|
||||
|
||||
function fixtureSnapshot(tree: Pick<ConfigTreeSnapshot, "entries">): ConfigTreeSnapshot {
|
||||
const hash = createHash("sha256").update("yoi-config-tree-v1\0");
|
||||
const entries: ConfigTreeSnapshot["entries"] = {};
|
||||
for (const path of Object.keys(tree.entries).toSorted()) {
|
||||
const entry = tree.entries[path];
|
||||
entries[path] = {
|
||||
...entry,
|
||||
content_digest: "sha256:" + createHash("sha256").update(entry.content).digest("hex"),
|
||||
};
|
||||
hash.update(path).update("\0")
|
||||
.update(entry.content_type === "decodal" ? "text/x-decodal" : "text/plain")
|
||||
.update("\0").update(entry.content).update("\0");
|
||||
}
|
||||
return { digest: "sha256:" + hash.digest("hex"), entries };
|
||||
}
|
||||
|
||||
async function toolchainFingerprint(
|
||||
entrypoints: string[],
|
||||
schemaBundle: WorkspaceConfigSchemaBundle,
|
||||
@@ -47,7 +67,6 @@ async function toolchainFingerprint(
|
||||
}
|
||||
|
||||
const snapshot: ConfigTreeSnapshot = {
|
||||
revision: 4,
|
||||
digest: "sha256:test-tree",
|
||||
entries: {
|
||||
"workspace.dcdl": {
|
||||
@@ -88,15 +107,40 @@ const mainEntrypointContract: ToolchainContract = {
|
||||
};
|
||||
|
||||
Deno.test("generated WASM evaluates the same virtual import contract", () => {
|
||||
const result = evaluate_snapshot(snapshot, contract) as {
|
||||
const result = evaluate_snapshot(fixtureSnapshot(snapshot), contract) as {
|
||||
projections: Array<{ data_json: { answer: number } }>;
|
||||
};
|
||||
assertEquals(result.projections[0].data_json, { answer: 42 });
|
||||
});
|
||||
|
||||
Deno.test("generated WASM keeps literal paths matching old cache identities as relative import bases", async () => {
|
||||
const source = "{}";
|
||||
const literalPath = `a.dcdl@text/x-decodal@${await digestText(source)}`;
|
||||
// Match the native fixture: even an unreferenced source must resolve its
|
||||
// relative import from the literal path rather than another entry's cache ID.
|
||||
const entries: ConfigTreeSnapshot["entries"] = {};
|
||||
for (const [path, content] of [
|
||||
["main.dcdl", "{}"],
|
||||
["a.dcdl", source],
|
||||
[literalPath, 'import "./shared.dcdl"'],
|
||||
["a.dcdl@text/shared.dcdl", "{ answer = 42; }"],
|
||||
]) {
|
||||
entries[path] = {
|
||||
path,
|
||||
content_type: "decodal",
|
||||
content,
|
||||
content_digest: await digestText(content),
|
||||
};
|
||||
}
|
||||
const result = evaluate_snapshot(
|
||||
fixtureSnapshot({ entries }),
|
||||
mainEntrypointContract,
|
||||
) as { projections: Array<{ data_json: unknown }> };
|
||||
assertEquals(result.projections[0].data_json, {});
|
||||
});
|
||||
|
||||
Deno.test("generated WASM accepts the mandatory main schema assertion", () => {
|
||||
const assertedSnapshot: ConfigTreeSnapshot = {
|
||||
revision: 1,
|
||||
digest: "sha256:asserted-main",
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
@@ -107,8 +151,7 @@ Deno.test("generated WASM accepts the mandatory main schema assertion", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = evaluate_snapshot(
|
||||
assertedSnapshot,
|
||||
const result = evaluate_snapshot(fixtureSnapshot(assertedSnapshot),
|
||||
mainEntrypointContract,
|
||||
) as { projections: Array<{ data_json: { answer: number } }> };
|
||||
|
||||
@@ -116,19 +159,17 @@ Deno.test("generated WASM accepts the mandatory main schema assertion", () => {
|
||||
});
|
||||
|
||||
Deno.test("generated WASM diagnostics carry snapshot provenance", () => {
|
||||
const diagnostics = analyze_snapshot(
|
||||
snapshot,
|
||||
const diagnostics = analyze_snapshot(fixtureSnapshot(snapshot),
|
||||
"workspace.dcdl",
|
||||
"{ broken = ; }",
|
||||
) as Array<{
|
||||
path: string;
|
||||
revision: number;
|
||||
tree_digest: string;
|
||||
kind: string;
|
||||
}>;
|
||||
assertEquals(diagnostics[0].path, "workspace.dcdl");
|
||||
assertEquals(diagnostics[0].revision, 4);
|
||||
assertEquals(diagnostics[0].tree_digest, "sha256:test-tree");
|
||||
assertEquals(diagnostics[0].tree_digest, fixtureSnapshot(snapshot).digest);
|
||||
assertEquals("revision" in diagnostics[0], false);
|
||||
assertEquals(diagnostics[0].kind, "syntax");
|
||||
});
|
||||
|
||||
@@ -153,7 +194,6 @@ const schemaBundle = compose_schema_bundle([
|
||||
|
||||
function schemaSnapshot(source: string): ConfigTreeSnapshot {
|
||||
return {
|
||||
revision: 7,
|
||||
digest: "sha256:schema-tree",
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
@@ -177,7 +217,6 @@ const schemaContract: ToolchainContract = {
|
||||
};
|
||||
|
||||
const markdownSnapshot: ConfigTreeSnapshot = {
|
||||
revision: 8,
|
||||
digest: "sha256:markdown-tree",
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
@@ -203,7 +242,7 @@ const markdownContract: ToolchainContract = {
|
||||
};
|
||||
|
||||
Deno.test("generated WASM evaluates Markdown with the shared Skill projection", () => {
|
||||
const result = evaluate_snapshot(markdownSnapshot, markdownContract) as {
|
||||
const result = evaluate_snapshot(fixtureSnapshot(markdownSnapshot), markdownContract) as {
|
||||
projections: Array<{ data_json: Record<string, unknown> }>;
|
||||
};
|
||||
assertEquals(result.projections[0].data_json, {
|
||||
@@ -219,10 +258,9 @@ Deno.test("generated WASM evaluates Markdown with the shared Skill projection",
|
||||
});
|
||||
|
||||
Deno.test("generated WASM applies Decodal 0.4 typed maps and explicit object rest", () => {
|
||||
const result = evaluate_snapshot(
|
||||
schemaSnapshot(
|
||||
const result = evaluate_snapshot(fixtureSnapshot(schemaSnapshot(
|
||||
"{ features = { console = { enabled = true; }; }; web = { enabled = true; extension_value = 42; }; }",
|
||||
),
|
||||
)),
|
||||
schemaContract,
|
||||
) as { projections: Array<{ data_json: Record<string, unknown> }> };
|
||||
assertEquals(result.projections[0].data_json, {
|
||||
@@ -240,7 +278,7 @@ type ProjectedDiagnostic = {
|
||||
|
||||
function evaluateFailure(source: string): ProjectedDiagnostic {
|
||||
try {
|
||||
evaluate_snapshot(schemaSnapshot(source), schemaContract);
|
||||
evaluate_snapshot(fixtureSnapshot(schemaSnapshot(source)), schemaContract);
|
||||
} catch (error) {
|
||||
const diagnostics = error as ProjectedDiagnostic[];
|
||||
assertEquals(Array.isArray(diagnostics), true);
|
||||
@@ -274,7 +312,7 @@ Deno.test("generated WASM preserves native Decodal 0.4 diagnostic semantics", ()
|
||||
|
||||
Deno.test("generated WASM returns completion items for the editor adapter", () => {
|
||||
const source = 'import "./"';
|
||||
set_snapshot({
|
||||
set_snapshot(fixtureSnapshot({
|
||||
...snapshot,
|
||||
entries: {
|
||||
...snapshot.entries,
|
||||
@@ -283,7 +321,7 @@ Deno.test("generated WASM returns completion items for the editor adapter", () =
|
||||
content: source,
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
const result = complete_current(
|
||||
"workspace.dcdl",
|
||||
source,
|
||||
@@ -301,7 +339,7 @@ Deno.test("generated WASM returns completion items for the editor adapter", () =
|
||||
|
||||
Deno.test("generated WASM exposes read-only builtin completions without editable builtin entries", () => {
|
||||
const tree = schemaSnapshot("{}");
|
||||
set_snapshot(tree);
|
||||
set_snapshot(fixtureSnapshot(tree));
|
||||
set_schema_bundle(emptySchemaBundle);
|
||||
const source = 'let 名 = 1; import "$builtin/profiles/comp"';
|
||||
const result = complete_current(
|
||||
@@ -320,8 +358,7 @@ Deno.test("generated WASM exposes read-only builtin completions without editable
|
||||
assertEquals(companion?.kind, "file");
|
||||
assertEquals(companion?.detail, "read-only builtin Decodal source");
|
||||
assertEquals(Object.keys(tree.entries), ["main.dcdl"]);
|
||||
const evaluated = evaluate_snapshot(
|
||||
schemaSnapshot('import "$builtin/profiles/companion.dcdl"'),
|
||||
const evaluated = evaluate_snapshot(fixtureSnapshot(schemaSnapshot('import "$builtin/profiles/companion.dcdl"')),
|
||||
mainEntrypointContract,
|
||||
) as {
|
||||
projections: Array<{ data_json: { slug: string } }>;
|
||||
@@ -338,16 +375,14 @@ Deno.test("generated WASM rejects unknown builtin imports without same-suffix wo
|
||||
content_digest: "sha256:workspace-fallback",
|
||||
};
|
||||
set_schema_bundle(emptySchemaBundle);
|
||||
const diagnostics = analyze_snapshot(tree, "main.dcdl", undefined) as Array<{
|
||||
const diagnostics = analyze_snapshot(fixtureSnapshot(tree), "main.dcdl", undefined) as Array<{
|
||||
path: string;
|
||||
revision: number;
|
||||
tree_digest: string;
|
||||
message: string;
|
||||
}>;
|
||||
assertEquals(diagnostics.length > 0, true);
|
||||
assertEquals(diagnostics[0].path, "main.dcdl");
|
||||
assertEquals(diagnostics[0].revision, tree.revision);
|
||||
assertEquals(diagnostics[0].tree_digest, tree.digest);
|
||||
assertEquals(diagnostics[0].tree_digest, fixtureSnapshot(tree).digest);
|
||||
assertEquals(
|
||||
diagnostics[0].message.includes(
|
||||
"unknown or non-public read-only builtin source",
|
||||
@@ -360,7 +395,7 @@ Deno.test("generated WASM rejects unknown builtin imports without same-suffix wo
|
||||
);
|
||||
let failed = false;
|
||||
try {
|
||||
evaluate_snapshot(tree, mainEntrypointContract);
|
||||
evaluate_snapshot(fixtureSnapshot(tree), mainEntrypointContract);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
assertEquals(
|
||||
@@ -372,26 +407,24 @@ Deno.test("generated WASM rejects unknown builtin imports without same-suffix wo
|
||||
}
|
||||
assertEquals(failed, true);
|
||||
assertEquals(
|
||||
analyze_snapshot(tree, "main.dcdl", 'import "./profiles/missing.dcdl"'),
|
||||
analyze_snapshot(fixtureSnapshot(tree), "main.dcdl", 'import "./profiles/missing.dcdl"'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
function importFailure(tree: ConfigTreeSnapshot): ProjectedDiagnostic[] {
|
||||
try {
|
||||
evaluate_snapshot(tree, mainEntrypointContract);
|
||||
evaluate_snapshot(fixtureSnapshot(tree), mainEntrypointContract);
|
||||
} catch (error) {
|
||||
// A WASM trap/stack overflow must not count as a structured import failure.
|
||||
assertEquals(Array.isArray(error), true);
|
||||
const diagnostics = error as Array<
|
||||
ProjectedDiagnostic & {
|
||||
revision: number;
|
||||
tree_digest: string;
|
||||
}
|
||||
>;
|
||||
assertEquals(diagnostics.length > 0, true);
|
||||
assertEquals(diagnostics[0].revision, tree.revision);
|
||||
assertEquals(diagnostics[0].tree_digest, tree.digest);
|
||||
assertEquals(diagnostics[0].tree_digest, fixtureSnapshot(tree).digest);
|
||||
return diagnostics;
|
||||
}
|
||||
throw new Error("expected a structured import diagnostic");
|
||||
@@ -427,7 +460,7 @@ Deno.test("generated WASM enforces the import depth budget on a real 33-deep cha
|
||||
return tree;
|
||||
}
|
||||
// The synthetic evaluation-wrapper import consumes one of the 32 edges.
|
||||
const accepted = evaluate_snapshot(chain(31), mainEntrypointContract) as {
|
||||
const accepted = evaluate_snapshot(fixtureSnapshot(chain(31)), mainEntrypointContract) as {
|
||||
projections: Array<{ data_json: { answer: number } }>;
|
||||
};
|
||||
assertEquals(accepted.projections[0].data_json, { answer: 42 });
|
||||
@@ -447,7 +480,7 @@ Deno.test("generated WASM completes blank nested schema positions after Unicode"
|
||||
const source =
|
||||
'{ description = "日本語"; profile = { }\n} as WorkspaceConfigSchema';
|
||||
const cursor = source.indexOf("{ }") + 2;
|
||||
set_snapshot({
|
||||
set_snapshot(fixtureSnapshot({
|
||||
...snapshot,
|
||||
entries: {
|
||||
...snapshot.entries,
|
||||
@@ -456,7 +489,7 @@ Deno.test("generated WASM completes blank nested schema positions after Unicode"
|
||||
content: source,
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
set_schema_bundle({
|
||||
contributions: [],
|
||||
source: "{ profile = { default_profile = String; }; prompts = {}; }",
|
||||
@@ -486,7 +519,7 @@ Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () =>
|
||||
const bareSource = "{ pro }";
|
||||
const source = "{ pro } as WorkspaceConfigSchema";
|
||||
const cursor = source.indexOf("pro") + 3;
|
||||
set_snapshot({
|
||||
set_snapshot(fixtureSnapshot({
|
||||
...snapshot,
|
||||
entries: {
|
||||
...snapshot.entries,
|
||||
@@ -495,7 +528,7 @@ Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () =>
|
||||
content: source,
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
set_schema_bundle({
|
||||
contributions: [],
|
||||
source: "{ profile = { default_profile = String; }; prompts = {}; }",
|
||||
@@ -566,7 +599,6 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
const content =
|
||||
'{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema';
|
||||
const tree: ConfigTreeSnapshot = {
|
||||
revision: 8,
|
||||
digest: "sha256:value-profile-tree",
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
@@ -583,9 +615,9 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
schema_bundle: schema,
|
||||
fingerprint: await toolchainFingerprint(["main.dcdl"], schema),
|
||||
};
|
||||
set_snapshot(tree);
|
||||
set_snapshot(fixtureSnapshot(tree));
|
||||
set_schema_bundle(schema);
|
||||
const evaluated = evaluate_snapshot(tree, valueContract) as {
|
||||
const evaluated = evaluate_snapshot(fixtureSnapshot(tree), valueContract) as {
|
||||
projections: Array<
|
||||
{ data_json: { profile: { entries: Array<{ profile: unknown }> } } }
|
||||
>;
|
||||
@@ -648,13 +680,13 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
`profile = ${value};`,
|
||||
);
|
||||
assertEquals(
|
||||
analyze_snapshot(tree, "main.dcdl", partialContent),
|
||||
analyze_snapshot(fixtureSnapshot(tree), "main.dcdl", partialContent),
|
||||
[],
|
||||
value,
|
||||
);
|
||||
}
|
||||
const recipe = "{ worker = { mode = 42; }; }";
|
||||
const analysisTree = {
|
||||
const analysisTree: ConfigTreeSnapshot = {
|
||||
...tree,
|
||||
entries: {
|
||||
...tree.entries,
|
||||
@@ -685,13 +717,12 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
"profile = {};",
|
||||
`profile = ${value};`,
|
||||
);
|
||||
const diagnostics = analyze_snapshot(
|
||||
analysisTree,
|
||||
const diagnostics = analyze_snapshot(fixtureSnapshot(analysisTree),
|
||||
"main.dcdl",
|
||||
invalidContent,
|
||||
) as Array<{
|
||||
path: string;
|
||||
revision: number;
|
||||
tree_digest: string;
|
||||
kind: string;
|
||||
span: { start_byte: number; end_byte: number };
|
||||
message: string;
|
||||
@@ -702,7 +733,7 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
assertEquals(diagnostics.length > 0, true, value);
|
||||
const diagnostic = diagnostics[0];
|
||||
assertEquals(diagnostic.path, expectedPath);
|
||||
assertEquals(diagnostic.revision, tree.revision);
|
||||
assertEquals(diagnostic.tree_digest, fixtureSnapshot(analysisTree).digest);
|
||||
assertEquals(
|
||||
["constraint_violation", "type_mismatch"].includes(diagnostic.kind),
|
||||
true,
|
||||
@@ -783,7 +814,7 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
"profile = {};",
|
||||
`profile = ${form};`,
|
||||
);
|
||||
const scopedTree = {
|
||||
const scopedTree: ConfigTreeSnapshot = {
|
||||
...tree,
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
@@ -800,12 +831,11 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
analyze_snapshot(scopedTree, "main.dcdl", undefined),
|
||||
analyze_snapshot(fixtureSnapshot(scopedTree), "main.dcdl", undefined),
|
||||
[],
|
||||
form,
|
||||
);
|
||||
const result = evaluate_snapshot(
|
||||
scopedTree,
|
||||
const result = evaluate_snapshot(fixtureSnapshot(scopedTree),
|
||||
valueContract,
|
||||
) as typeof evaluated;
|
||||
assertEquals(
|
||||
@@ -839,7 +869,7 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
"profile = {};",
|
||||
`profile = ${form};`,
|
||||
);
|
||||
const fixtureTree = {
|
||||
const fixtureTree: ConfigTreeSnapshot = {
|
||||
...tree,
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
@@ -856,12 +886,11 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
analyze_snapshot(fixtureTree, "main.dcdl", undefined),
|
||||
analyze_snapshot(fixtureSnapshot(fixtureTree), "main.dcdl", undefined),
|
||||
[],
|
||||
`${fixture.name}: ${form}`,
|
||||
);
|
||||
const result = evaluate_snapshot(
|
||||
fixtureTree,
|
||||
const result = evaluate_snapshot(fixtureSnapshot(fixtureTree),
|
||||
valueContract,
|
||||
) as typeof evaluated;
|
||||
assertEquals(
|
||||
@@ -896,5 +925,65 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
);
|
||||
}
|
||||
// Neither completion nor diagnostic shape checking changes the saved value.
|
||||
assertEquals(evaluate_snapshot(tree, valueContract), evaluated);
|
||||
assertEquals(evaluate_snapshot(fixtureSnapshot(tree), valueContract), evaluated);
|
||||
});
|
||||
|
||||
Deno.test("generated WASM returns to the same content digest and enforces entry digest CAS", () => {
|
||||
const base = fixtureSnapshot(schemaSnapshot("{ answer = 1; }"));
|
||||
set_schema_bundle(emptySchemaBundle);
|
||||
set_snapshot(base);
|
||||
const changed = apply_changes([{
|
||||
kind: "update", path: "main.dcdl",
|
||||
expected_digest: base.entries["main.dcdl"].content_digest,
|
||||
content: "{ broken = ; }",
|
||||
}]) as ConfigTreeSnapshot;
|
||||
assertEquals(changed.digest === base.digest, false);
|
||||
assertEquals("revision" in changed, false);
|
||||
const diagnostics = analyze_snapshot(changed, "main.dcdl", undefined) as Array<{ tree_digest: string }>;
|
||||
assertEquals(diagnostics.length > 0, true);
|
||||
assertEquals(diagnostics.every((diagnostic) => diagnostic.tree_digest === changed.digest), true);
|
||||
assertThrows(() => apply_changes([{
|
||||
kind: "update", path: "main.dcdl",
|
||||
expected_digest: base.entries["main.dcdl"].content_digest,
|
||||
content: "{ answer = 2; }",
|
||||
}]));
|
||||
const restored = apply_changes(changes_between(changed, base)) as ConfigTreeSnapshot;
|
||||
assertEquals(restored, base);
|
||||
assertEquals(analyze_snapshot(restored, "main.dcdl", undefined), []);
|
||||
});
|
||||
|
||||
Deno.test("generated WASM rejects cache URI paths in snapshot JSON despite valid content digests", async () => {
|
||||
const source = "{}";
|
||||
const cacheUri = `config-source://a.dcdl@text/x-decodal@${await digestText(source)}`;
|
||||
// Both entry and tree digests are correct, so rejection must come from the
|
||||
// untrusted VirtualPath boundary rather than a content-identity mismatch.
|
||||
const snapshot = fixtureSnapshot({
|
||||
entries: {
|
||||
"a.dcdl": {
|
||||
path: "a.dcdl",
|
||||
content_type: "decodal",
|
||||
content: source,
|
||||
content_digest: await digestText(source),
|
||||
},
|
||||
[cacheUri]: {
|
||||
path: cacheUri,
|
||||
content_type: "decodal",
|
||||
content: 'import "./shared.dcdl"',
|
||||
content_digest: await digestText('import "./shared.dcdl"'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const json = JSON.stringify(snapshot);
|
||||
const rejection = assertThrows(() => set_snapshot(JSON.parse(json)));
|
||||
assertEquals(String(rejection).includes("config-source://"), true);
|
||||
});
|
||||
|
||||
Deno.test("generated WASM rejects forged content identity at the snapshot boundary", () => {
|
||||
const base = fixtureSnapshot(schemaSnapshot("{}"));
|
||||
for (const forged of [
|
||||
{ ...base, digest: "sha256:forged" },
|
||||
{ ...base, entries: { "main.dcdl": { ...base.entries["main.dcdl"], content: "{ changed = true; }" } } },
|
||||
]) {
|
||||
assertThrows(() => set_snapshot(forged));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,19 +34,19 @@ Start `tools/web-ux/drive-fixture/server.ts <port> <build-root>` with `--allow-n
|
||||
- `*denied*`: typed HTTP 403 root/read; `*offline*`/`home-error`: typed HTTP 503; `*empty*`: empty successful root list; `*paged*`: 240 additional nodes.
|
||||
- `POST /__fixture/fault` JSON `{workspace, mode}`: one-shot `conflict` or committed `unknown` mutation response; persistent `image_error` binary fetch failure.
|
||||
- `home-owner`/`home-member`/`home-long`: representative authoritative Worker catalogs; REST and subscription projections come from the same generated-shape records. `home-long` includes long display names and opaque Runtime/Worker IDs. `workers-error` intentionally fails catalog reads with HTTP 503.
|
||||
- `GET /__fixture/log`: observable API request IDs/methods/paths/node IDs/CAS revisions for identity and no-resend assertions.
|
||||
- `GET /__fixture/log`: observable API request IDs/methods/paths/node IDs/CAS observed committed requests for identity and no-resend assertions.
|
||||
- `POST /__fixture/reset`: fresh in-memory state.
|
||||
|
||||
Request delay/loss tests intercept the API boundary with Playwright and synchronize on gates, not guessed sleeps. They let the fixture commit before dropping/holding the response, then verify receipt query without blind upload retry.
|
||||
|
||||
## Visual evidence
|
||||
|
||||
Preserved production baseline: `.svelte-kit/t724/before-build/{site,output,build.log}`. Official bundles live below `.svelte-kit/t724/visual/`. Each contains screenshots, console/request/page diagnostics, accessibility snapshots, screenshot hashes, source revision/dirty context, and `review-context.json`.
|
||||
Preserved production baseline: `.svelte-kit/t724/before-build/{site,output,build.log}`. Official bundles live below `.svelte-kit/t724/visual/`. Each contains screenshots, console/request/page diagnostics, accessibility snapshots, screenshot hashes, source commit/dirty context, and `review-context.json`.
|
||||
|
||||
Home/sidebar and Workers are captured under identical owner/member/error/empty/long conditions, light/dark at 1440/768/390/320. After captures add Drive owner/member/empty/error/denied/paged/Markdown/image/image-error/truncated surfaces, pinned rename/replace revision forms, and owner grant selection/read-only/read-write/revoked states. Expected injected 503/403 diagnostics and superseded breadcrumb metadata aborts remain in official context; `diagnostic-disposition.json` explicitly separates unexpected diagnostics. CLI `completed-with-errors` is not relabeled a visual pass.
|
||||
Home/sidebar and Workers are captured under identical owner/member/error/empty/long conditions, light/dark at 1440/768/390/320. After captures add Drive owner/member/empty/error/denied/paged/Markdown/image/image-error/truncated surfaces, pinned rename/replace observed committed request forms, and owner grant selection/read-only/read-write/revoked states. Expected injected 503/403 diagnostics and superseded breadcrumb metadata aborts remain in official context; `diagnostic-disposition.json` explicitly separates unexpected diagnostics. CLI `completed-with-errors` is not relabeled a visual pass.
|
||||
|
||||
The production shell scrolls `main` internally, so `fullPage` does not expand that scroll panel. Capture points use ordinary keyboard focus to show the actual form or the top denial state; no CSS/DOM screenshot override is applied. The responsive audit separately records document/main/table-region geometry, visible keyboard focus, and page errors across the full theme/viewport matrix. Raster checks wait for actual decoding, assert natural dimensions 1200×800, preserve the 3:2 aspect ratio, and constrain the rendered box by both the available width and 60vh height at all four widths/both themes.
|
||||
|
||||
The Drive browser suite also exercises the file-browser redesign at 1440/768/390/320px in light/dark themes: menu placement, native-dialog focus and Escape restoration, folder creation, search, and destination-folder navigation. Screenshots are written to `.svelte-kit/drive-redesign/` for the list, New menu, creation dialog, Markdown preview, and move dialog. Component tests cover failed/paged destination reads, exclusion of files and the moving folder itself, and pinned mutation revisions. These checks use the same fixture boundary, not a live Backend.
|
||||
The Drive browser suite also exercises the file-browser redesign at 1440/768/390/320px in light/dark themes: menu placement, native-dialog focus and Escape restoration, folder creation, search, and destination-folder navigation. Screenshots are written to `.svelte-kit/drive-redesign/` for the list, New menu, creation dialog, Markdown preview, and move dialog. Component tests cover failed/paged destination reads, exclusion of files and the moving folder itself, and pinned mutation observed committed requests. These checks use the same fixture boundary, not a live Backend.
|
||||
|
||||
Parent and reviewer must open images and inspect context before asserting visual acceptance. In particular, a successful capture command is not sufficient and HTTP 200 does not excuse visible UI errors.
|
||||
|
||||
@@ -55,31 +55,31 @@ for (const scheme of ["light", "dark"]) {
|
||||
}
|
||||
if (stage === "after") {
|
||||
routes.push({
|
||||
id: "drive-form-revision",
|
||||
label: "Pinned rename and replace form revisions",
|
||||
id: "drive-form-observation",
|
||||
label: "Pinned rename and replace form observations",
|
||||
path: "/w/home-owner/drive/3",
|
||||
goal: "Show the form's locked read revision and explicit destination/file controls.",
|
||||
goal: "Show the form's locked read observation and explicit destination/file controls.",
|
||||
dataState:
|
||||
"Revision 1 read from fixture metadata; no mutation is sent by these capture interactions.",
|
||||
"Initial committed request read from fixture metadata; no mutation is sent by these capture interactions.",
|
||||
ready: { kind: "selector", selector: '.drive-page[data-drive-ready="true"]' },
|
||||
capturePoints: [
|
||||
{
|
||||
id: "rename",
|
||||
label: "Rename / move pinned revision",
|
||||
label: "Rename / move pinned observation",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{ action: "click", selector: '.drive-menu > summary[aria-label="More actions"]' },
|
||||
{ action: "click", selector: '.drive-toolbar button:has-text("Rename / move")' },
|
||||
{
|
||||
action: "wait",
|
||||
ready: { kind: "selector", selector: '.drive-form:has-text("Expected revision 1.")' },
|
||||
ready: { kind: "selector", selector: '.drive-form:has-text("Changes by another writer will not be overwritten.")' },
|
||||
},
|
||||
{ action: "press", selector: ".drive-form input:not([inputmode])", key: "Shift" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "replace",
|
||||
label: "Replace file pinned revision",
|
||||
label: "Replace file pinned observation",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{ action: "click", selector: '.drive-form button:has-text("Cancel form")' },
|
||||
@@ -87,7 +87,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
{ action: "click", selector: '.drive-toolbar button:has-text("Replace file")' },
|
||||
{
|
||||
action: "wait",
|
||||
ready: { kind: "selector", selector: '.drive-form:has-text("Expected revision 1.")' },
|
||||
ready: { kind: "selector", selector: '.drive-form:has-text("Changes by another writer will not be overwritten.")' },
|
||||
},
|
||||
{ action: "press", selector: ".drive-form input[type=file]", key: "Shift" },
|
||||
],
|
||||
@@ -367,7 +367,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
if (
|
||||
[
|
||||
"drive-markdown",
|
||||
"drive-form-revision",
|
||||
"drive-form-observation",
|
||||
"drive-image",
|
||||
"drive-image-error",
|
||||
"drive-truncated",
|
||||
|
||||
@@ -10,7 +10,7 @@ const ready = (page: Page) =>
|
||||
const button = (page: Page, name: string) =>
|
||||
page.getByRole("button", { name, exact: true });
|
||||
const draft = (page: Page) =>
|
||||
page.getByRole("textbox", { name: /^Draft \(expected revision/ });
|
||||
page.getByRole("textbox", { name: /^Draft$/ });
|
||||
const preview = (page: Page) =>
|
||||
page.getByRole("article", { name: "Current saved content" });
|
||||
function gate() {
|
||||
@@ -135,7 +135,7 @@ async function safeFulfill(
|
||||
} catch { /* selection intentionally canceled this old request */ }
|
||||
}
|
||||
|
||||
Deno.test("production Drive two tabs retain losing CAS draft and never retry with a newer revision", () =>
|
||||
Deno.test("production Drive two tabs retain losing CAS draft and never retry with a newer observation", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const first = await context.newPage(), second = await context.newPage();
|
||||
@@ -151,7 +151,7 @@ Deno.test("production Drive two tabs retain losing CAS draft and never retry wit
|
||||
await first.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
await button(second, "Save").click();
|
||||
await second.getByText("Revision conflict — your draft is retained.", {
|
||||
await second.getByText("Content changed — your draft is retained.", {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
@@ -167,7 +167,7 @@ Deno.test("production Drive two tabs retain losing CAS draft and never retry wit
|
||||
"Second actor unsaved draft",
|
||||
);
|
||||
assert(
|
||||
(await second.getByText("Draft (expected revision 1)", { exact: false })
|
||||
(await second.getByText("Draft", { exact: false })
|
||||
.count()) > 0,
|
||||
);
|
||||
assert(
|
||||
@@ -187,7 +187,7 @@ Deno.test("production Drive two tabs retain losing CAS draft and never retry wit
|
||||
const casWrites = (await logs(origin)).filter((x) =>
|
||||
x.path === "/mutate"
|
||||
);
|
||||
assertEquals(casWrites.map((x) => [x.nodeId, x.expectedRevision]), [[
|
||||
assertEquals(casWrites.map((x) => [x.nodeId, x.expectedMutationId]), [[
|
||||
"3",
|
||||
"1",
|
||||
], ["3", "1"]]);
|
||||
@@ -229,7 +229,7 @@ Deno.test("production Drive delayed old read cannot replace another file or work
|
||||
.last().waitFor();
|
||||
const mutation = (await logs(origin)).filter((x) => x.path === "/mutate");
|
||||
assertEquals(
|
||||
mutation.map((x) => [x.workspace, x.nodeId, x.expectedRevision]),
|
||||
mutation.map((x) => [x.workspace, x.nodeId, x.expectedMutationId]),
|
||||
[[
|
||||
"home-owner",
|
||||
"4",
|
||||
@@ -330,12 +330,14 @@ Deno.test("production Drive delayed old save and status preserve destination dra
|
||||
const pinnedWrites = (await logs(origin)).filter((x) =>
|
||||
x.path === "/mutate"
|
||||
);
|
||||
assertEquals(pinnedWrites.map((x) => [x.nodeId, x.expectedRevision]), [
|
||||
assertEquals(pinnedWrites.map((x) => [x.nodeId, x.expectedMutationId]), [
|
||||
["3", "1"],
|
||||
["4", "1"],
|
||||
["3", "2"],
|
||||
["4", "2"],
|
||||
["3", pinnedWrites[0].requestId],
|
||||
["4", pinnedWrites[1].requestId],
|
||||
]);
|
||||
assert(pinnedWrites.every((x) => typeof x.requestId === "string" && x.requestId.length > 0));
|
||||
assertEquals(new Set(pinnedWrites.map((x) => x.requestId)).size, 4);
|
||||
} finally {
|
||||
release.release();
|
||||
await handled;
|
||||
|
||||
@@ -47,13 +47,13 @@ Deno.test("browser fixture preserves decimal identity, bounded reads, binary and
|
||||
assertEquals(escaped.text, "\u0000".repeat(65536));
|
||||
assertEquals(escaped.truncated, false);
|
||||
});
|
||||
Deno.test("browser fixture two revisions conflict without committing the losing request", async () => {
|
||||
Deno.test("browser fixture two last_mutation_ids conflict without committing the losing request", async () => {
|
||||
const { client } = setup();
|
||||
const entry = await client.metadata("home-owner", ref("home-owner", "3"));
|
||||
await client.mutate("home-owner", "tab-one", {
|
||||
operation: "update_text",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
expected_mutation_id: entry.last_mutation_id,
|
||||
text: "first actor",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
@@ -62,7 +62,7 @@ Deno.test("browser fixture two revisions conflict without committing the losing
|
||||
client.mutate("home-owner", "tab-two", {
|
||||
operation: "update_text",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
expected_mutation_id: entry.last_mutation_id,
|
||||
text: "second actor draft",
|
||||
content_type: "text/markdown",
|
||||
}),
|
||||
@@ -113,7 +113,7 @@ Deno.test("browser fixture rename and move retain identity while delete and recr
|
||||
const moved = await client.mutate("home-owner", "move", {
|
||||
operation: "relocate",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
expected_mutation_id: entry.last_mutation_id,
|
||||
parent: ref("home-owner", "2"),
|
||||
name: "renamed.md",
|
||||
});
|
||||
@@ -122,7 +122,7 @@ Deno.test("browser fixture rename and move retain identity while delete and recr
|
||||
await client.mutate("home-owner", "delete", {
|
||||
operation: "delete",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
expected_mutation_id: entry.last_mutation_id,
|
||||
});
|
||||
const replacement = await client.mutate("home-owner", "recreate", {
|
||||
operation: "create_text",
|
||||
|
||||
@@ -6,7 +6,7 @@ The ordinary Rust test exercises loopback HTTP signed Worker ingress: explicit r
|
||||
|
||||
The explicit process test starts a loopback Axum Server and launches Deno against it. `roundtrip.ts` imports the production `src/lib/workspace/drive/api.ts` adapter, not a copy or mock. Its typed `fetchFn` resolves relative paths only to the bound loopback origin and adds fixture bearer authorization. Tokens travel over stdin, not command-line arguments or logs; Rust failure diagnostics defensively redact both fixture bearer tokens. Deno has network permission only for the exact loopback port, uses no npm dependencies, and runs with `--cached-only`. Each listener is abort-on-drop, child processes are kill-on-drop, and the child has a 60-second upper timeout, without sleeps or readiness polling.
|
||||
|
||||
Coverage: Worker file list/read, Japanese folder/Markdown names, create/read/edit/download, empty files, actual PNG upload/authenticated image/download and binary update, publication receipt lookup, paged list, two authenticated accounts racing a common revision (one commit/one typed conflict, no hidden retry), stable latest URL through rename and move, deleted URL and same-name recreation, and unauthorized latest URLs. The exact Workers-page `drive-grants/api.ts` adapter also lists persisted grants, creates both access levels, revokes them and re-reads durable state, and rejects member administration. Its global browser fetch is temporarily origin/auth-bound in this isolated child; network responses are never mocked. This tests real authenticated HTTP with test bearer credentials, not a Passkey/cookie browser login or a full Runtime process. UI dirty-draft retention, delayed response handling, visual review and browser cancellation belong to the **separate browser fixture**, not this API test.
|
||||
Coverage: Worker file list/read, Japanese folder/Markdown names, create/read/edit/download, empty files, actual PNG upload/authenticated image/download and binary update, publication receipt lookup, paged list, two authenticated accounts racing a common observed committed request (one commit/one typed conflict, no hidden retry), stable latest URL through rename and move, deleted URL and same-name recreation, and unauthorized latest URLs. The exact Workers-page `drive-grants/api.ts` adapter also lists persisted grants, creates both access levels, revokes them and re-reads durable state, and rejects member administration. Its global browser fetch is temporarily origin/auth-bound in this isolated child; network responses are never mocked. This tests real authenticated HTTP with test bearer credentials, not a Passkey/cookie browser login or a full Runtime process. UI dirty-draft retention, delayed response handling, visual review and browser cancellation belong to the **separate browser fixture**, not this API test.
|
||||
|
||||
## Registration
|
||||
|
||||
|
||||
@@ -93,9 +93,9 @@ async function download(
|
||||
entry: DriveEntry,
|
||||
): Promise<Uint8Array> {
|
||||
const response = await fetchFn(
|
||||
driveDownloadUrl(ws, entry.entry, entry.revision),
|
||||
driveDownloadUrl(ws, entry.entry, entry.last_mutation_id),
|
||||
);
|
||||
assert(response.status === 200, "download must succeed at exact revision");
|
||||
assert(response.status === 200, "download must succeed at exact last_mutation_id");
|
||||
equal(
|
||||
response.headers.get("x-content-type-options"),
|
||||
"nosniff",
|
||||
@@ -203,7 +203,7 @@ equal(
|
||||
const updated = await saved(owner, ws, "web-edit", {
|
||||
operation: "update_text",
|
||||
id: markdown.entry,
|
||||
expected_revision: markdown.revision,
|
||||
expected_mutation_id: markdown.last_mutation_id,
|
||||
text: "# 更新\n",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
@@ -274,7 +274,7 @@ const binaryUpdated = await owner.upload(
|
||||
{
|
||||
operation: "update",
|
||||
id: empty.entry,
|
||||
expected_revision: empty.revision,
|
||||
expected_mutation_id: empty.last_mutation_id,
|
||||
content_type: "image/png",
|
||||
},
|
||||
);
|
||||
@@ -313,12 +313,12 @@ equal(
|
||||
"paged traversal must contain each file once",
|
||||
);
|
||||
|
||||
// Two authenticated accounts read the same revision, then race real HTTP writes.
|
||||
// Two authenticated accounts read the same last_mutation_id, then race real HTTP writes.
|
||||
const secondRead = await second.readText(ws, updated.entry);
|
||||
equal(
|
||||
secondRead.entry.revision,
|
||||
updated.revision,
|
||||
"second actor must observe same revision",
|
||||
secondRead.entry.last_mutation_id,
|
||||
updated.last_mutation_id,
|
||||
"second actor must observe same last_mutation_id",
|
||||
);
|
||||
const drafts = ["# actor A draft\n", "# actor B draft\n"];
|
||||
const race = await Promise.allSettled(
|
||||
@@ -326,7 +326,7 @@ const race = await Promise.allSettled(
|
||||
saved(client, ws, `web-race-${index}`, {
|
||||
operation: "update_text",
|
||||
id: updated.entry,
|
||||
expected_revision: updated.revision,
|
||||
expected_mutation_id: updated.last_mutation_id,
|
||||
text: drafts[index],
|
||||
content_type: "text/markdown",
|
||||
})
|
||||
@@ -335,7 +335,7 @@ const race = await Promise.allSettled(
|
||||
equal(
|
||||
race.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
"same-revision two-actor CAS must have exactly one winner",
|
||||
"same-last_mutation_id two-actor CAS must have exactly one winner",
|
||||
);
|
||||
const loser = race.find((result) => result.status === "rejected");
|
||||
assert(
|
||||
@@ -358,14 +358,14 @@ const stableUrl = markdown.latest_url;
|
||||
const renamed = await saved(owner, ws, "web-rename", {
|
||||
operation: "relocate",
|
||||
id: latest.entry.entry,
|
||||
expected_revision: latest.entry.revision,
|
||||
expected_mutation_id: latest.entry.last_mutation_id,
|
||||
parent: folder.entry,
|
||||
name: "改名.md",
|
||||
});
|
||||
const moved = await saved(owner, ws, "web-move", {
|
||||
operation: "relocate",
|
||||
id: renamed.entry,
|
||||
expected_revision: renamed.revision,
|
||||
expected_mutation_id: renamed.last_mutation_id,
|
||||
parent: root.entry,
|
||||
name: renamed.name,
|
||||
});
|
||||
@@ -397,7 +397,7 @@ await rejected(
|
||||
const deleted = await owner.mutate(ws, "web-delete", {
|
||||
operation: "delete",
|
||||
id: moved.entry,
|
||||
expected_revision: moved.revision,
|
||||
expected_mutation_id: moved.last_mutation_id,
|
||||
});
|
||||
equal(deleted.entry, null, "delete must remove live metadata");
|
||||
await rejected(() => owner.readText(ws, moved.entry), "not_found");
|
||||
|
||||
@@ -40,7 +40,7 @@ function reviewRequested(
|
||||
event_id: eventId,
|
||||
sequence: 1,
|
||||
subject_ref: subjectRef,
|
||||
ticket_item_revision: "ticket-revision",
|
||||
ticket_content_digest: "ticket-digest",
|
||||
ticket_merge_request_subjects: [
|
||||
{ merge_request_id: "MR-1", subject_ref: subjectRef },
|
||||
],
|
||||
@@ -61,7 +61,7 @@ function review(
|
||||
sequence: 1,
|
||||
request_event_id: requestEventId,
|
||||
subject_ref: subjectRef,
|
||||
ticket_item_revision: "ticket-revision",
|
||||
ticket_content_digest: "ticket-digest",
|
||||
ticket_merge_request_subjects: [
|
||||
{ merge_request_id: "MR-1", subject_ref: subjectRef },
|
||||
],
|
||||
|
||||
@@ -37,7 +37,6 @@ const credential = {
|
||||
name: "Deploy key",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
current_revision: 2,
|
||||
status: "active",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
@@ -52,7 +51,6 @@ const hostTrust = {
|
||||
key_algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA",
|
||||
fingerprint: "SHA256:host",
|
||||
current_revision: 3,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
referenced_repositories: ["main"],
|
||||
@@ -63,14 +61,12 @@ Deno.test("Repository Access parsers accept generated response contracts", () =>
|
||||
assertEquals(
|
||||
parseRepositorySshPublicKey({
|
||||
credential_id: "deploy-key",
|
||||
current_revision: 2,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
public_key: "ssh-ed25519 AAAA",
|
||||
}),
|
||||
{
|
||||
credential_id: "deploy-key",
|
||||
current_revision: 2,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
public_key: "ssh-ed25519 AAAA",
|
||||
@@ -80,7 +76,6 @@ Deno.test("Repository Access parsers accept generated response contracts", () =>
|
||||
assertEquals(
|
||||
parseRepositoryAccessProjection({
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_key: "main",
|
||||
@@ -91,7 +86,6 @@ Deno.test("Repository Access parsers accept generated response contracts", () =>
|
||||
}),
|
||||
{
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_key: "main",
|
||||
@@ -115,10 +109,11 @@ Deno.test("Repository Access parsers reject malformed list responses", () => {
|
||||
});
|
||||
|
||||
Deno.test("Repository Access parsers reject missing and wrong-typed fields", () => {
|
||||
const { current_revision: _revision, ...missingRevision } = credential;
|
||||
const { public_key_fingerprint: _fingerprint, ...missingFingerprint } =
|
||||
credential;
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshCredentials([missingRevision]),
|
||||
"credentials[0].current_revision",
|
||||
() => parseRepositorySshCredentials([missingFingerprint]),
|
||||
"credentials[0].public_key_fingerprint",
|
||||
);
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshHostTrusts([{ ...hostTrust, port: "22" }]),
|
||||
@@ -128,7 +123,6 @@ Deno.test("Repository Access parsers reject missing and wrong-typed fields", ()
|
||||
() =>
|
||||
parseRepositoryAccessProjection({
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_key: "main",
|
||||
|
||||
@@ -21,7 +21,6 @@ type LoadedPage = {
|
||||
|
||||
const projectionFixture = {
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 1,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [],
|
||||
};
|
||||
@@ -31,7 +30,6 @@ const credentialFixture = {
|
||||
name: "Workspace default",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
current_revision: 1,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
@@ -39,7 +37,6 @@ const credentialFixture = {
|
||||
};
|
||||
const publicKeyFixture = {
|
||||
credential_id: "workspace-default",
|
||||
current_revision: 1,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
public_key: "ssh-ed25519 AAAA-workspace-default",
|
||||
@@ -52,7 +49,6 @@ const hostTrustFixture = {
|
||||
key_algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA-host",
|
||||
fingerprint: "SHA256:host",
|
||||
current_revision: 1,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
referenced_repositories: [],
|
||||
|
||||
@@ -90,9 +90,6 @@ test("Repository Access presents current state as tables with technical disclosu
|
||||
"Pinned SSH host keys",
|
||||
"repository-access-table-wrap",
|
||||
"Technical details",
|
||||
"Credential revisions",
|
||||
"Host key revisions",
|
||||
"accessProjection.config_revision",
|
||||
"accessProjection.projection_digest",
|
||||
"binding.repository_key",
|
||||
"binding.credential_id",
|
||||
|
||||
@@ -30,7 +30,7 @@ Deno.test("Repository SSH probe parser preserves the confirmation contract", ()
|
||||
port: 22,
|
||||
trust_state: "untrusted" as const,
|
||||
host_trust_id: "tofu-example.test-22",
|
||||
expected_host_trust_revision: null,
|
||||
expected_host_key_fingerprint: null,
|
||||
candidates: [
|
||||
{
|
||||
algorithm: "ssh-ed25519",
|
||||
@@ -136,7 +136,7 @@ Deno.test("Repository SSH connection test requires an explicit host-key confirma
|
||||
"selectedRuntimeId",
|
||||
"candidate.fingerprint",
|
||||
"Confirm and trust selected host key",
|
||||
"expected_host_trust_revision",
|
||||
"expected_host_key_fingerprint",
|
||||
"requestConnectionTest('POST'",
|
||||
"requestConnectionTest('PUT'",
|
||||
]
|
||||
|
||||
@@ -138,7 +138,7 @@ Deno.test("sidebar Delete executes the authoritative runtime cleanup plan", asyn
|
||||
workspace_id: "team",
|
||||
runtime_id: worker.runtime_id,
|
||||
generated_at: "2026-01-01T00:00:00Z",
|
||||
revision: "7",
|
||||
|
||||
digest: "digest-7",
|
||||
workers: [{
|
||||
target_id: "worker-target",
|
||||
@@ -171,7 +171,7 @@ Deno.test("sidebar Delete executes the authoritative runtime cleanup plan", asyn
|
||||
workspace_id: "team",
|
||||
runtime_id: worker.runtime_id,
|
||||
generated_at: "2026-01-01T00:00:01Z",
|
||||
revision: "8",
|
||||
|
||||
digest: "digest-8",
|
||||
workers: [],
|
||||
workdirs: [],
|
||||
@@ -189,7 +189,7 @@ Deno.test("sidebar Delete executes the authoritative runtime cleanup plan", asyn
|
||||
]);
|
||||
assertEquals(requests[1]?.init?.method, "POST");
|
||||
assertEquals(JSON.parse(String(requests[1]?.init?.body)), {
|
||||
expected_plan_revision: "7",
|
||||
|
||||
expected_plan_digest: "digest-7",
|
||||
worker_target_ids: ["worker-target"],
|
||||
workdir_target_ids: [],
|
||||
@@ -204,7 +204,7 @@ Deno.test("sidebar Delete reports cleanup-plan blocking reasons", async () => {
|
||||
workspace_id: "team",
|
||||
runtime_id: worker.runtime_id,
|
||||
generated_at: "2026-01-01T00:00:00Z",
|
||||
revision: "8",
|
||||
|
||||
digest: "digest-8",
|
||||
workers: [{
|
||||
target_id: "worker-target",
|
||||
|
||||
@@ -16,7 +16,7 @@ Deno.test("ticket detail synchronizes reused route data", () => {
|
||||
const token of [
|
||||
"data.ticketId",
|
||||
"data.ticket.data",
|
||||
"incomingTicket.item_revision",
|
||||
"JSON.stringify([data.workspaceId, incomingTicketId, incomingTicket])",
|
||||
"routeGeneration += 1",
|
||||
"resetTicketView(incomingTicket)",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ declare const Deno: {
|
||||
import {
|
||||
createSubjektivSubject,
|
||||
parseMemoryStagingListResponse,
|
||||
parseSubjektivMemoryListRevisionsResponse,
|
||||
parseSubjektivMemoryListChangesResponse,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
parseSubjektivMemoryReadResponse,
|
||||
parseSubjektivResidentSurfaceResponse,
|
||||
@@ -75,9 +75,8 @@ function subject(id = "subject-1") {
|
||||
id,
|
||||
role: "Release coordinator",
|
||||
behavior_md: "Prefer explicit evidence.",
|
||||
behavior_revision: 2,
|
||||
state: "active",
|
||||
store_revision: 12,
|
||||
memory_fingerprint: "fingerprint-12",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
};
|
||||
@@ -110,7 +109,7 @@ function currentWorker() {
|
||||
function queryItem(id = "memory-1") {
|
||||
return {
|
||||
id,
|
||||
revision: 3,
|
||||
change_id: "change-current",
|
||||
kind: "decision",
|
||||
state: "active",
|
||||
claim: "Keep provenance typed.",
|
||||
@@ -127,12 +126,11 @@ function detailFixture() {
|
||||
worker_id: "worker-1",
|
||||
flow_selector: "builtin:coder-review",
|
||||
flow_definition_id: "flow-1",
|
||||
flow_definition_revision: 7,
|
||||
};
|
||||
return {
|
||||
memory_id: "memory-1",
|
||||
revision: 2,
|
||||
current_revision: 3,
|
||||
change_id: "change-earlier",
|
||||
current_change_id: "change-current",
|
||||
kind: "decision",
|
||||
state: "resolved",
|
||||
claim: "Keep provenance typed.",
|
||||
@@ -173,13 +171,13 @@ function detailFixture() {
|
||||
source_refs_total: 2,
|
||||
source_refs_truncated: true,
|
||||
}],
|
||||
derived_from: [{ memory_id: "memory-parent", revision: 4 }],
|
||||
derived_from: [{ memory_id: "memory-parent", change_id: "change-parent" }],
|
||||
evidence_next_cursor: "evidence-page-2",
|
||||
evidence_has_more: true,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bounds", () => {
|
||||
Deno.test("Subject parsers enforce identity, exact enums, bounded fingerprints, and bounds", () => {
|
||||
assertEquals(
|
||||
parseSubjektivSubjectResponse(subject(), "subject-1"),
|
||||
subject(),
|
||||
@@ -196,9 +194,9 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
|
||||
() =>
|
||||
parseSubjektivSubjectResponse({
|
||||
...subject(),
|
||||
store_revision: Number.MAX_SAFE_INTEGER + 1,
|
||||
memory_fingerprint: 12,
|
||||
}),
|
||||
"safe integer",
|
||||
"bounded identifier",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse({ ...subject(), id: "x".repeat(513) }),
|
||||
@@ -313,7 +311,7 @@ Deno.test("Subject behavior update uses CAS and preserves exact text", async ()
|
||||
return Response.json({
|
||||
...subject("subject/one"),
|
||||
behavior_md: "Line one.\nLine two.",
|
||||
behavior_revision: 3,
|
||||
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
@@ -321,9 +319,8 @@ Deno.test("Subject behavior update uses CAS and preserves exact text", async ()
|
||||
fetchFn,
|
||||
"workspace one",
|
||||
"subject/one",
|
||||
{ expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." },
|
||||
{ expected_behavior_md: "Prefer explicit evidence.", behavior_md: "Line one.\nLine two." },
|
||||
);
|
||||
assertEquals(updated.behavior_revision, 3);
|
||||
assertEquals(updated.behavior_md, "Line one.\nLine two.");
|
||||
assertEquals(
|
||||
requests[0].path,
|
||||
@@ -332,20 +329,20 @@ Deno.test("Subject behavior update uses CAS and preserves exact text", async ()
|
||||
assertEquals(requests[0].init?.method, "PATCH");
|
||||
assertEquals(
|
||||
requests[0].init?.body,
|
||||
'{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}',
|
||||
'{"expected_behavior_md":"Prefer explicit evidence.","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" }, {
|
||||
Response.json({ message: "behavior content conflict" }, {
|
||||
status: 409,
|
||||
})) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", {
|
||||
expected_behavior_revision: 1,
|
||||
expected_behavior_md: "old",
|
||||
behavior_md: "new",
|
||||
}),
|
||||
"rejected",
|
||||
@@ -459,7 +456,7 @@ Deno.test("Resident surface parser accepts ready-empty and enforces snapshot inv
|
||||
snapshot_id: "snapshot-empty",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 0,
|
||||
built_from_memory_fingerprint: "fingerprint-0",
|
||||
created_at: "2026-09-02T00:00:00Z",
|
||||
},
|
||||
};
|
||||
@@ -506,7 +503,7 @@ Deno.test("Resident surface parser accepts ready-empty and enforces snapshot inv
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Current Memory list parser rejects unknown variants, unsafe integers, cursors, and oversized pages", () => {
|
||||
Deno.test("Current Memory list parser rejects unknown variants, invalid change IDs, cursors, and oversized pages", () => {
|
||||
const response = {
|
||||
items: [queryItem()],
|
||||
has_more: true,
|
||||
@@ -533,9 +530,9 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
...response,
|
||||
items: [{ ...queryItem(), revision: 0 }],
|
||||
items: [{ ...queryItem(), change_id: "" }],
|
||||
}),
|
||||
"positive safe integer",
|
||||
"bounded identifier",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
@@ -572,7 +569,7 @@ Deno.test("Memory detail parser preserves body/evidence continuation, sources, o
|
||||
);
|
||||
assertEquals(parsed.derived_from, [{
|
||||
memory_id: "memory-parent",
|
||||
revision: 4,
|
||||
change_id: "change-parent",
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -586,9 +583,9 @@ Deno.test("Memory detail parser fails closed on identity, variants, bounds, and
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
current_revision: Number.MAX_SAFE_INTEGER + 1,
|
||||
current_change_id: 3,
|
||||
}),
|
||||
"safe integer",
|
||||
"bounded identifier",
|
||||
);
|
||||
const missingBodyContinuation = structuredClone(fixture) as Record<
|
||||
string,
|
||||
@@ -637,26 +634,26 @@ Deno.test("Memory detail parser fails closed on identity, variants, bounds, and
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Memory revision parser preserves immutable states and checks response identity", () => {
|
||||
Deno.test("Memory change parser preserves immutable states and checks response identity", () => {
|
||||
const response = {
|
||||
memory_id: "memory-1",
|
||||
current_revision: 3,
|
||||
current_change_id: "change-current",
|
||||
items: [{
|
||||
revision: 3,
|
||||
change_id: "change-current",
|
||||
kind: "decision",
|
||||
state: "active",
|
||||
claim: "Current claim.",
|
||||
change_reason: "Clarified wording.",
|
||||
updated_at: "2026-09-03T00:00:00Z",
|
||||
}, {
|
||||
revision: 2,
|
||||
change_id: "change-earlier",
|
||||
kind: "decision",
|
||||
state: "resolved",
|
||||
claim: "Earlier claim.",
|
||||
change_reason: "Resolved.",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
}, {
|
||||
revision: 1,
|
||||
change_id: "change-original",
|
||||
kind: "decision",
|
||||
state: "retracted",
|
||||
claim: "Original claim.",
|
||||
@@ -666,16 +663,16 @@ Deno.test("Memory revision parser preserves immutable states and checks response
|
||||
has_more: false,
|
||||
};
|
||||
assertEquals(
|
||||
parseSubjektivMemoryListRevisionsResponse(response, "memory-1"),
|
||||
parseSubjektivMemoryListChangesResponse(response, "memory-1"),
|
||||
response,
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryListRevisionsResponse(response, "memory-2"),
|
||||
() => parseSubjektivMemoryListChangesResponse(response, "memory-2"),
|
||||
"identity does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryListRevisionsResponse({
|
||||
parseSubjektivMemoryListChangesResponse({
|
||||
...response,
|
||||
items: [{ ...response.items[0], state: "archived" }],
|
||||
}),
|
||||
@@ -683,14 +680,61 @@ Deno.test("Memory revision parser preserves immutable states and checks response
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryListRevisionsResponse({
|
||||
parseSubjektivMemoryListChangesResponse({
|
||||
...response,
|
||||
items: [{ ...response.items[0], revision: 4 }],
|
||||
items: [response.items[0], response.items[0]],
|
||||
}),
|
||||
"future revision",
|
||||
"Memory changes must be unique",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Memory changes preserve server order without interpreting opaque identities", () => {
|
||||
const item = { kind: "decision", state: "active", claim: "Claim", change_reason: "Reason", updated_at: "2026-09-01T00:00:00Z" };
|
||||
const response = {
|
||||
memory_id: "memory-1",
|
||||
current_change_id: "a/current",
|
||||
items: [{ ...item, change_id: "z/earlier" }, { ...item, change_id: "a/current" }],
|
||||
has_more: false,
|
||||
};
|
||||
assertEquals(parseSubjektivMemoryListChangesResponse(response), response);
|
||||
assertEquals(parseSubjektivMemoryReadResponse({ ...detailFixture(), change_id: "z/earlier", current_change_id: "a/current" }).change_id, "z/earlier");
|
||||
});
|
||||
|
||||
Deno.test("Memory identities reject numeric counters, empty and oversized strings, and legacy fields", () => {
|
||||
for (const value of [3, "", "x".repeat(513)]) {
|
||||
assertThrows(() => parseSubjektivMemoryReadResponse({ ...detailFixture(), change_id: value }), "bounded identifier");
|
||||
assertThrows(() => parseSubjektivMemoryReadResponse({ ...detailFixture(), current_change_id: value }), "bounded identifier");
|
||||
assertThrows(() => parseSubjektivSubjectResponse({ ...subject(), memory_fingerprint: value }), "bounded identifier");
|
||||
}
|
||||
assertThrows(() => parseSubjektivMemoryReadResponse({ ...detailFixture(), revision: 2 }), "unknown field");
|
||||
assertThrows(() => parseSubjektivSubjectResponse({ ...subject(), behavior_revision: 2 }), "unknown field");
|
||||
assertThrows(() => parseSubjektivSubjectResponse({ ...subject(), store_revision: 12 }), "unknown field");
|
||||
});
|
||||
|
||||
Deno.test("Behavior CAS sends the observed text byte-for-byte including empty content", async () => {
|
||||
for (const expected of ["", " Previous\nbehavior.\n"]) {
|
||||
let body: unknown;
|
||||
const fetchFn = (async (_path: string | URL | Request, init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body));
|
||||
return Response.json({ ...subject(), behavior_md: "Replacement" });
|
||||
}) as typeof fetch;
|
||||
await updateSubjektivSubjectBehavior(fetchFn, "workspace", "subject-1", { expected_behavior_md: expected, behavior_md: "Replacement" });
|
||||
assertEquals(body, { expected_behavior_md: expected, behavior_md: "Replacement" });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Behavior saves with mismatched response text have unknown outcome and are not retried", async () => {
|
||||
let calls = 0;
|
||||
const fetchFn = (async () => {
|
||||
calls += 1;
|
||||
return Response.json(subject());
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(() => updateSubjektivSubjectBehavior(fetchFn, "workspace", "subject-1", {
|
||||
expected_behavior_md: "Prefer explicit evidence.", behavior_md: "Replacement",
|
||||
}), "unknown_outcome", "could not be confirmed");
|
||||
assertEquals(calls, 1);
|
||||
});
|
||||
|
||||
Deno.test("Legacy staging parser remains strict for deprecated compatibility", () => {
|
||||
const origin = { kind: "worker_input", worker_id: "worker-1" };
|
||||
const response = {
|
||||
|
||||
@@ -44,8 +44,6 @@ const diagnostic = {
|
||||
function profileSettingsFixture(): Record<string, unknown> {
|
||||
return {
|
||||
workspace_id: "workspace 1",
|
||||
registry_revision: "config-source:7:tree:projection",
|
||||
config_revision: 7,
|
||||
tree_digest: "tree",
|
||||
projection_digest: "projection",
|
||||
default_profile: "workspace:coder",
|
||||
@@ -68,7 +66,6 @@ function profileSettingsFixture(): Record<string, unknown> {
|
||||
content_digest: "sha256:source",
|
||||
provenance: "project_profile_source_tree",
|
||||
editable: false,
|
||||
revision: "config-source:7",
|
||||
size_bytes: 128,
|
||||
diagnostics: [],
|
||||
}],
|
||||
@@ -86,7 +83,7 @@ Deno.test("profile settings requests use scoped API and strictly validate respon
|
||||
|
||||
try {
|
||||
const response = await fetchProfileSettings("workspace 1");
|
||||
assertEquals(response.config_revision, 7);
|
||||
assertEquals(response.tree_digest, "tree");
|
||||
assertEquals(response.sources[0].provenance, "project_profile_source_tree");
|
||||
assertEquals(requests.length, 1);
|
||||
assertEquals(requests[0].url, "/api/w/workspace%201/settings/profiles");
|
||||
@@ -103,7 +100,7 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
|
||||
workspace_id: "workspace 1",
|
||||
display_name: "Workspace",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
revision: "sha256:metadata",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
source: "workspace-config",
|
||||
diagnostics: [diagnostic],
|
||||
};
|
||||
@@ -118,13 +115,13 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
|
||||
|
||||
try {
|
||||
assertEquals(
|
||||
(await fetchWorkspaceMetadata("workspace 1")).revision,
|
||||
"sha256:metadata",
|
||||
(await fetchWorkspaceMetadata("workspace 1")).updated_at,
|
||||
"2026-01-02T00:00:00Z",
|
||||
);
|
||||
assertEquals(
|
||||
(await updateWorkspaceMetadata("workspace 1", {
|
||||
display_name: "Renamed",
|
||||
revision: "sha256:metadata",
|
||||
expected_updated_at: "2026-01-02T00:00:00Z",
|
||||
})).workspace.workspace_id,
|
||||
"workspace 1",
|
||||
);
|
||||
@@ -135,7 +132,10 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
|
||||
assertEquals(requests[1].init?.method, "PUT");
|
||||
assertEquals(
|
||||
requests[1].init?.body,
|
||||
JSON.stringify({ display_name: "Renamed", revision: "sha256:metadata" }),
|
||||
JSON.stringify({
|
||||
display_name: "Renamed",
|
||||
expected_updated_at: "2026-01-02T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
@@ -152,7 +152,6 @@ Deno.test("Workspace signing identity requests use flat settings routes", async
|
||||
workspace_id: "workspace 1",
|
||||
key_id: "workspace-signing-key",
|
||||
algorithm: "ed25519",
|
||||
revision: 1,
|
||||
state: "pending_provisioning",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
@@ -182,7 +181,7 @@ Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid
|
||||
);
|
||||
|
||||
const mistyped = profileSettingsFixture();
|
||||
mistyped.config_revision = "7";
|
||||
mistyped.tree_digest = 7;
|
||||
assertThrows(
|
||||
() => parseProfileSettingsResponse(mistyped),
|
||||
ProfileApiError,
|
||||
@@ -212,7 +211,6 @@ Deno.test("Workspace signing identity parser validates active and pending public
|
||||
algorithm: "ed25519",
|
||||
public_key: "public-key",
|
||||
public_key_fingerprint: "sha256:fingerprint",
|
||||
revision: 1,
|
||||
state: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
provisioned_at: "2026-01-01T00:00:00Z",
|
||||
@@ -224,7 +222,6 @@ Deno.test("Workspace signing identity parser validates active and pending public
|
||||
algorithm: "ed25519",
|
||||
public_key: "public-key",
|
||||
public_key_fingerprint: "sha256:fingerprint",
|
||||
revision: 1,
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
@@ -237,7 +234,6 @@ Deno.test("Workspace signing identity parser validates active and pending public
|
||||
workspace_id: "workspace-1",
|
||||
key_id: "WK-1",
|
||||
algorithm: "ed25519",
|
||||
revision: 1,
|
||||
state: "pending_provisioning",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ const repository = {
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
source: { kind: "local_path", uri: "/srv/main" },
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:main",
|
||||
observed_status: "ready",
|
||||
record_authority: "workspace-control-plane",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user