feat: integrate Subject permission management with Worker contracts

This commit is contained in:
2026-10-30 05:02:30 +09:00
33 changed files with 4122 additions and 288 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../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/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.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/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.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/alert-order.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 src/lib/workspace/tickets/ticket-search.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 tests/subject-delete.test.ts tests/journal-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/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.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/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.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/alert-order.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 src/lib/workspace/tickets/ticket-search.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 src/lib/workspace/memory/permissions.test.ts tests/subject-delete.test.ts tests/journal-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:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.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:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// 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
+58 -215
View File
@@ -15,250 +15,93 @@ 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_fingerprint?: 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 PermissionResource = { "kind": "job", workspace_id: string, job_id: string, } | { "kind": "ticket", workspace_id: string, ticket_id: string, } | { "kind": "objective", workspace_id: string, objective_id: string, } | { "kind": "merge_request", workspace_id: string, merge_request_id: string, } | { "kind": "subject", workspace_id: string, subject_id: string, } | { "kind": "drive_entry", workspace_id: string, node_id: string, } | { "kind": "drive_request", workspace_id: string, request_id: string, } | { "kind": "repository", workspace_id: string, repository_key: string, } | { "kind": "workdir_attachment", workspace_id: string, connection_id: string, } | { "kind": "workspace_config", workspace_id: string, path: string, recursive: boolean, } | { "kind": "network", url: string, } | { "kind": "sub_worker", runtime_id: string, worker_id: string, name: string, } | { "kind": "feature" } | { "kind": "workspace", workspace_id: string, } | { "kind": "workdir", working_directory_id: string, path: string, recursive: boolean, } | { "kind": "worker_operation", workspace_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, };
export type ScopedPermission = { operation: string, resource: PermissionResource, };
export type SubjectPermissions = { change_id: string, execution: Array<ScopedPermission>, delegation: Array<ScopedPermission>, };
export type SubjectPermissionsUpdate = { operation_id: string, expected_change_id: string | null, execution: Array<ScopedPermission>, delegation: Array<ScopedPermission>, };
export type PermissionResourceKind = "job" | "ticket" | "objective" | "merge_request" | "subject" | "drive_entry" | "drive_request" | "repository" | "workdir_attachment" | "workspace_config" | "network" | "sub_worker" | "feature" | "workspace" | "workdir" | "worker" | "worker_operation";
export type PermissionOperationAvailability = { "kind": "available" } | { "kind": "unavailable", reason: string, };
export type PermissionOperationDescriptor = { operation: string, label: string, description: string,
/**
* Accepted management input kinds, not a claim that a target exists or is usable.
*/
resource_kinds: Array<PermissionResourceKind>, availability: PermissionOperationAvailability, };
export type SubjectPermissionsResponse = { permissions: SubjectPermissions | null, can_manage: boolean, operations: Array<PermissionOperationDescriptor>, };
export type SubjectPermissionsMutationResponse = { permissions: SubjectPermissions,
/**
* New operations are already fenced. These are cancellation requests and
* Backend command cleanup evidence, not proof that a Runtime turn stopped.
*/
interruptions: Array<SubjectPermissionInterruption>, };
export type SubjectPermissionInterruption = { runtime_id: string, worker_id: string, cancellation_accepted: boolean, backend_command_sessions_closed: boolean, diagnostics: Array<string>, };
export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectCreateRequest = {
role: string;
behavior_md?: string;
};
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectDeleteRequest = { operation_id: string };
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
export type SubjektivSubjectDeleteResponse = {
subject_id: string;
operation_id: string;
completed: boolean;
};
export type SubjektivSubjectDeleteResponse = { subject_id: string, operation_id: string, completed: boolean, };
export type SubjektivSubjectBehaviorUpdateRequest = {
expected_behavior_md: string;
behavior_md: string;
};
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
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 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 SubjektivMemoryChangeRef = { memory_id: string; change_id: string };
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<SubjektivMemoryChangeRef>;
built_from_memory_fingerprint: string;
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;
change_id: string;
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;
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 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 SubjektivMemoryChangeItem = {
change_id: string;
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 SubjektivMemoryListChangesResponse = {
memory_id: string;
current_change_id: string;
items: Array<SubjektivMemoryChangeItem>;
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, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// 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
@@ -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:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// 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
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// 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
@@ -0,0 +1,264 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import SubjectPermissions from "./SubjectPermissions.svelte";
import type { Policy, PolicyUpdate } from "./permissions.ts";
const ops = [
{
operation: "ticket.create",
label: "Create Tickets",
description: "Create an open Ticket in this Workspace.",
resource_kinds: ["workspace"],
availability: { kind: "available" },
},
{
operation: "filesystem.read",
label: "Read files",
description: "Read only the selected Workdir path.",
resource_kinds: ["workdir"],
availability: { kind: "available" },
},
{
operation: "filesystem.write",
label: "Edit files",
description: "Write only the selected Workdir path.",
resource_kinds: ["workdir"],
availability: { kind: "available" },
},
{
operation: "worker.messaging",
label: "Message Worker",
description: "Separate operation use and directional target control.",
resource_kinds: ["workspace"],
availability: { kind: "unavailable", reason: "T-803 connection pending" },
},
];
const props = { workspaceId: "w", subjectId: "s" };
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
sessionStorage.clear();
});
function harness(initial: Policy | null = null, canManage = true) {
let policy = initial;
const requests: PolicyUpdate[] = [];
const fetch = vi.fn(async (_path: string, init?: RequestInit) => {
if (init?.method === "POST") {
const request = JSON.parse(init.body as string) as PolicyUpdate;
requests.push(request);
policy = {
change_id: request.operation_id,
execution: request.execution,
delegation: request.delegation,
};
return Response.json({ permissions: policy, interruptions: [] });
}
return Response.json({
permissions: policy,
can_manage: canManage,
operations: ops,
});
});
vi.stubGlobal("fetch", fetch);
return { fetch, requests, setPolicy: (next: Policy | null) => policy = next };
}
async function choose(name = "ticket.create") {
await screen.findByRole("combobox", { name: "Operation" });
await fireEvent.change(screen.getByRole("combobox", { name: "Operation" }), {
target: { value: name },
});
}
async function add(which = "execution", name = "ticket.create") {
await choose(name);
await fireEvent.change(
screen.getByRole("combobox", { name: "Permission scope" }),
{ target: { value: which } },
);
if (name.startsWith("filesystem")) {
await fireEvent.input(
screen.getByRole("textbox", { name: "Workdir identifier" }),
{ target: { value: "wd-only" } },
);
await fireEvent.input(
screen.getByRole("textbox", { name: "Relative path" }),
{ target: { value: "src" } },
);
}
await fireEvent.click(
screen.getByRole("button", { name: "Add to unsaved changes" }),
);
}
async function confirmSave() {
await fireEvent.click(screen.getByRole("checkbox", { name: /I reviewed/ }));
await fireEvent.click(
screen.getByRole("button", { name: "Save permissions" }),
);
}
test("new Subject selects direct and narrow delegation independently, previews exact diff and saves through existing endpoint", async () => {
const h = harness();
render(SubjectPermissions, props);
await add();
await add("delegation", "filesystem.write");
expect(h.requests).toHaveLength(0);
expect(
screen.getByRole("region", { name: "Unsaved permission differences" })
.textContent,
).toContain("Edit files");
expect(
(screen.getByRole("button", {
name: "Save permissions",
}) as HTMLButtonElement).disabled,
).toBe(true);
await confirmSave();
await screen.findByText("Saved and re-read from the current policy.");
expect(h.requests).toHaveLength(1);
expect(h.requests[0].expected_change_id).toBe(null);
expect(h.requests[0].execution[0].operation).toBe("ticket.create");
expect(h.requests[0].delegation[0]).toEqual({
operation: "filesystem.write",
resource: {
kind: "workdir",
working_directory_id: "wd-only",
path: "src",
recursive: false,
},
});
expect(h.fetch.mock.calls[1][0]).toBe(
"/api/w/w/subjektiv/subjects/s/permissions",
);
});
test("unsupported legacy target is retained verbatim while an unrelated operation is added", async () => {
const legacy = {
operation: "worker.notify",
resource: {
kind: "worker" as const,
runtime_id: "r",
worker_id: "only-target",
},
};
const h = harness({ change_id: "old", execution: [], delegation: [legacy] });
render(SubjectPermissions, props);
await add();
await confirmSave();
await screen.findByText("Saved and re-read from the current policy.");
expect(h.requests[0].delegation).toEqual([legacy]);
expect(screen.getByText(/Preserved, not editable/)).toBeTruthy();
});
test("unimplemented Worker operation has reason, no Worker-ID fields and cannot be added", async () => {
harness();
render(SubjectPermissions, props);
await choose("worker.messaging");
expect(screen.getByText(/Target unavailable: T-803/)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Add to unsaved changes" }))
.toBeNull();
expect(screen.queryByRole("textbox", { name: /Worker identifier/ }))
.toBeNull();
});
test("read-only projection retains current permission visibility without save or edit actions", async () => {
harness({
change_id: "p",
execution: [{
operation: "ticket.create",
resource: { kind: "workspace", workspace_id: "w" },
}],
delegation: [],
}, false);
render(SubjectPermissions, props);
await screen.findByText(/Read-only. The Backend/);
expect(screen.queryByRole("combobox")).toBeNull();
expect(screen.queryByRole("button", { name: /^Remove/ })).toBeNull();
expect(
within(screen.getByRole("region", { name: "Direct operations" })).getByText(
"Create Tickets",
),
).toBeTruthy();
});
test("conflict retains unsaved diff and blocks editing until explicit reload, never rebases CAS silently", async () => {
const h = harness();
h.fetch.mockImplementation(async (_path, init) =>
init?.method === "POST"
? new Response(null, { status: 409 })
: Response.json({ permissions: null, can_manage: true, operations: ops })
);
render(SubjectPermissions, props);
await add();
await confirmSave();
await screen.findByText(/Conflict: another save/);
expect(screen.getByRole("region", { name: "Unsaved permission differences" }))
.toBeTruthy();
expect(screen.queryByRole("button", { name: "Save permissions" })).toBeNull();
expect(sessionStorage.length).toBe(0);
});
test("lost response survives remount and exact replay confirms receipt without reviving later policy", async () => {
const h = harness();
let saved: PolicyUpdate | null = null;
const later: Policy = {
change_id: "later-shrink",
execution: [],
delegation: [],
};
let lost = false;
h.fetch.mockImplementation(async (_path, init) => {
if (init?.method === "POST") {
const request = JSON.parse(init.body as string) as PolicyUpdate;
if (!lost) {
saved = request;
lost = true;
throw new Error("lost after commit");
}
expect(request).toEqual(saved);
return Response.json({
permissions: {
change_id: request.operation_id,
execution: request.execution,
delegation: request.delegation,
},
interruptions: [],
});
}
return Response.json({
permissions: lost ? later : null,
can_manage: true,
operations: ops,
});
});
const first = render(SubjectPermissions, props);
await add();
await confirmSave();
await screen.findByText("Save outcome not yet confirmed.");
first.unmount();
render(SubjectPermissions, props);
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Reconcile the same save",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
await fireEvent.click(
screen.getByRole("button", { name: "Reconcile the same save" }),
);
await screen.findByText(/original save was confirmed, but a later change/);
expect(
within(screen.getByRole("region", { name: "Direct operations" }))
.queryByText("Create Tickets"),
).toBeNull();
expect(sessionStorage.length).toBe(0);
});
test("deleted Subject is refused with no success indication", async () => {
const h = harness();
h.fetch.mockImplementation(async () => new Response(null, { status: 404 }));
render(SubjectPermissions, props);
await screen.findByText(
/Target unavailable. This Subject may have been deleted/,
);
expect(screen.queryByRole("combobox")).toBeNull();
expect(h.requests).toHaveLength(0);
});
@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount } from 'svelte';
import { attemptKey, fieldLabel, makeResource, PermissionHttpError, permissionKey, policyDiff, readAttempt, readPermissions, resourceFields, resourceLabel, writePermissions } from './permissions.ts';
import type { Interruption, Lane, PermissionResource, PermissionView, PolicyUpdate, ScopedPermission } from './permissions.ts';
let { workspaceId, subjectId }: { workspaceId: string; subjectId: string } = $props();
let view = $state<PermissionView | null>(null);
let execution = $state<ScopedPermission[]>([]);
let delegation = $state<ScopedPermission[]>([]);
let pending = $state<PolicyUpdate | null>(null);
let storageBlocked = $state(false);
let busy = $state(false);
let error = $state('');
let notice = $state('');
let confirming = $state(false);
let interruptions = $state<Interruption[]>([]);
let lane = $state<Lane>('execution');
let operationName = $state('');
let resourceKind = $state<PermissionResource['kind']>('workspace');
let inputs = $state<Record<string, string>>({});
let recursive = $state(false);
const selected = $derived(view?.operations.find((o) => o.operation === operationName));
const changes = $derived({ execution: policyDiff(view?.permissions?.execution ?? [], execution), delegation: policyDiff(view?.permissions?.delegation ?? [], delegation) });
const dirty = $derived(Object.values(changes).some((d) => d.added.length || d.removed.length));
const editable = $derived(view?.can_manage && !busy && !pending && !storageBlocked);
const label = (p: ScopedPermission) => view?.operations.find((o) => o.operation === p.operation)?.label ?? 'Preserved unsupported permission';
const supported = (p: ScopedPermission) => view?.operations.some((o) => o.operation === p.operation && o.available && o.resource_kinds.includes(p.resource.kind));
function accept(v: PermissionView) {
view = v;
execution = structuredClone(v.permissions?.execution ?? []);
delegation = structuredClone(v.permissions?.delegation ?? []);
confirming = false;
}
function describe(error: unknown) {
if (error instanceof PermissionHttpError) {
if (error.status === 403 || error.status === 401) return 'Read-only / permission unavailable. This account cannot manage this Subject.';
if (error.status === 404) return 'Target unavailable. This Subject may have been deleted; an old screen cannot restore it.';
if (error.status === 409) return 'Conflict: another save changed the policy. Your edits were not applied. Reload current permissions and review again.';
if (error.status === 400) return 'The operation or resource was rejected. Check the selected target; no new permissions were saved.';
}
return 'The request could not be confirmed. Do not assume success or create another save.';
}
async function reload() {
if (busy) return;
busy = true; error = ''; notice = '';
try { accept(await readPermissions(fetch, workspaceId, subjectId)); }
catch (e) { error = describe(e); }
finally { busy = false; }
}
onMount(() => {
try { pending = readAttempt(sessionStorage, workspaceId, subjectId); }
catch { storageBlocked = true; error = 'The saved request cannot be read. Saving is blocked; do not start another request.'; }
if (!storageBlocked) void reload();
});
function chooseOperation() {
resourceKind = selected?.resource_kinds[0] ?? 'workspace'; inputs = {}; recursive = false; confirming = false;
}
function add() {
if (!editable || !selected?.available || !selected.resource_kinds.includes(resourceKind)) return;
try {
const p = { operation: selected.operation, resource: makeResource(resourceKind, workspaceId, subjectId, inputs, recursive) };
const current = lane === 'execution' ? execution : delegation;
if (!current.some((x) => permissionKey(x) === permissionKey(p))) {
if (lane === 'execution') execution = [...execution, p]; else delegation = [...delegation, p];
}
confirming = false; error = ''; notice = '';
} catch (e) { error = e instanceof Error ? e.message : 'Invalid resource'; }
}
function remove(which: Lane, index: number) {
if (!editable) return;
if (which === 'execution') execution = execution.filter((_, i) => i !== index);
else delegation = delegation.filter((_, i) => i !== index);
confirming = false; notice = '';
}
async function save(request?: PolicyUpdate) {
if (busy || storageBlocked || !view?.can_manage) return;
const attempt = request ?? { operation_id: crypto.randomUUID(), expected_change_id: view.permissions?.change_id ?? null, execution: $state.snapshot(execution), delegation: $state.snapshot(delegation) };
busy = true; error = ''; notice = '';
try {
// 永続入力を保持できなければPOSTしない。再接続で新しいoperationを作らない。
sessionStorage.setItem(attemptKey(workspaceId, subjectId), JSON.stringify(attempt));
pending = attempt;
} catch { error = 'Cannot retain the save request in this tab. Nothing was sent.'; busy = false; return; }
let receiptAccepted = false;
try {
const receipt = await writePermissions(fetch, workspaceId, subjectId, attempt);
receiptAccepted = true;
const current = await readPermissions(fetch, workspaceId, subjectId);
accept(current); interruptions = receipt.interruptions;
sessionStorage.removeItem(attemptKey(workspaceId, subjectId)); pending = null;
notice = current.permissions?.change_id === receipt.permissions.change_id
? 'Saved and re-read from the current policy.'
: 'The original save was confirmed, but a later change is now current. The current policy is shown; the receipt did not restore older permissions.';
} catch (e) {
const definitive = !receiptAccepted && e instanceof PermissionHttpError && [400, 401, 403, 404, 409].includes(e.status);
if (definitive) {
sessionStorage.removeItem(attemptKey(workspaceId, subjectId)); pending = null;
// 競合後の新しい観測を元の下書きのCASへ黙って差し替えない。
view = view ? { ...view, can_manage: false } : null;
}
error = receiptAccepted ? 'The save receipt was accepted, but current policy could not be re-read. Reconcile the same request; do not treat its receipt as current authority.' : describe(e);
} finally { busy = false; }
}
</script>
<section class="permissions" aria-labelledby="permissions-heading" aria-busy={busy} data-permission-state={pending ? 'unknown' : error ? 'error' : dirty ? 'unsaved' : notice ? 'saved' : view ? 'ready' : 'loading'}>
<header><h2 id="permissions-heading">Operation permissions</h2><span class="state">{pending ? 'Outcome unknown' : dirty ? 'Unsaved changes' : notice ? 'Saved / current policy re-read' : view?.permissions ? 'Saved policy' : view ? 'Not configured · no permissions' : 'Loading…'}</span></header>
<p>Direct operations and the background Worker delegation ceiling are independent. Profile and Feature selection never grant permissions. Workdir/provider grants, target existence and assignment conditions still apply.</p>
{#if error}<p class="attention" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if pending}
<div class="attention"><strong>Save outcome not yet confirmed.</strong><p>This tab retained the exact request. Reconcile it using the same operation and then re-read the current policy. Do not start another save.</p><button disabled={busy || !view?.can_manage} onclick={() => save(pending!)}>Reconcile the same save</button></div>
{/if}
{#if !view}
<button disabled={busy || storageBlocked} onclick={reload}>Read current permissions</button>
{:else}
{#if !view.can_manage}<p role="status">Read-only. The Backend must authorize permission management; hiding controls is not authorization.</p>{/if}
<div class="lanes">
{#each ['execution', 'delegation'] as which}
{@const permissions = which === 'execution' ? execution : delegation}
<section class="lane" aria-label={which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}>
<h3>{which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}</h3>
<p>{which === 'execution' ? 'Operations the Subject body may use, if its tools and current connections support them.' : 'Maximum operations and resources that may be explicitly passed to background Workers. This does not give the body work tools or start a Worker.'}</p>
{#if permissions.length === 0}<p class="empty">No permissions in this scope.</p>{/if}
<ul>{#each permissions as p, index}
<li><div><strong>{label(p)}</strong><span class="resource">{resourceLabel(p.resource)}</span>
{#if !supported(p)}<span class="legacy">Preserved, not editable here. No conversion or expansion is implied.</span><details><summary>Saved operation identifier</summary><code>{p.operation}</code></details>{/if}
</div>{#if editable}<button aria-label={`Remove ${label(p)} from ${which === 'execution' ? 'direct operations' : 'delegation ceiling'}`} onclick={() => remove(which as Lane, index)}>Remove</button>{/if}</li>
{/each}</ul>
</section>
{/each}
</div>
{#if view.can_manage}
<form onsubmit={(event) => { event.preventDefault(); add(); }} class="permission-form">
<h3>Add an explicit operation</h3>
<fieldset disabled={!editable}>
<label>Permission scope<select value={lane} onchange={(event) => { lane = event.currentTarget.value as Lane; }}><option value="execution">Direct operations</option><option value="delegation">Background Worker delegation ceiling</option></select></label>
<label>Operation<select value={operationName} onchange={(event) => { operationName = event.currentTarget.value; chooseOperation(); }}><option value="">Choose an operation</option>{#each view.operations as o}<option value={o.operation}>{o.label}{o.available ? '' : ' — unavailable'}</option>{/each}</select></label>
{#if selected}
<p>{selected.description}</p>
{#if !selected.available}<p class="attention">Target unavailable: {selected.unavailable_reason} No permission will be added.</p>
{:else}
<label>Resource type<select value={resourceKind} onchange={(event) => { resourceKind = event.currentTarget.value as PermissionResource['kind']; inputs = {}; recursive = false; }}>{#each selected.resource_kinds as kind}<option value={kind}>{kind.replaceAll('_', ' ')}</option>{/each}</select></label>
{#if resourceKind === 'workspace'}<p>Collection operations in this Workspace only; this does not cover individual resources.</p>{/if}
{#if resourceKind === 'feature'}<p>MCP is a temporary coarse activation exception. This is not per-server or per-tool authorization and does not grant ordinary Feature operations.</p>{/if}
{#each resourceFields(resourceKind) as field}<label>{fieldLabel[field]}<input bind:value={inputs[field]} placeholder={field === 'subject_id' ? subjectId : field === 'path' ? '.' : ''} required={field !== 'subject_id' && field !== 'path'} /></label>{/each}
{#if resourceKind === 'workdir' || resourceKind === 'workspace_config'}<label class="check"><input type="checkbox" bind:checked={recursive} />Include descendant paths</label>{/if}
<button type="submit">Add to unsaved changes</button>
{/if}
{/if}
</fieldset>
</form>
{/if}
<details class="help"><summary>Examples and enforcement boundaries</summary><p>For read-only work, add a read operation and its exact resource to the delegation ceiling. For limited editing, separately add the write operation for only the intended Workdir and relative path. Nothing else is implied; no wildcard or fixed all-powerful role is added.</p><p>Worker operations require both permission to use the operation and a directional Backend control grant for the actual target. Idle/CAS and lifecycle rules remain separate. This editor never asks for per-Worker notification/stop permissions or copies creation grants into the Subject policy. The T-803 connection is not yet available. Subject Ticket assignment migration (T-778) is not enabled by this screen.</p><p>Saving fences later operations using the current policy. Running work may receive cancellation requests; acceptance is not a confirmed stop. Already completed effects are not undone. Existing child grants are invalidated rather than silently refreshed.</p></details>
{#if dirty && !pending}
<section class="diff" aria-label="Unsaved permission differences"><h3>Review before saving</h3>
{#each ['execution', 'delegation'] as which}<h4>{which === 'execution' ? 'Direct operations' : 'Delegation ceiling'}</h4>
<ul>{#each changes[which as Lane].added as p}<li>+ Add: {label(p)} — {resourceLabel(p.resource)}</li>{/each}{#each changes[which as Lane].removed as p}<li>− Remove: {label(p)} — {resourceLabel(p.resource)}</li>{/each}</ul>
{/each}
{#if editable}<label class="check"><input type="checkbox" bind:checked={confirming} />I reviewed the operation, resource and delegation differences.</label><button class="save" disabled={!confirming || busy} onclick={() => save()}>Save permissions</button>{/if}
</section>
{/if}
<button class="reload" disabled={busy || !!pending || storageBlocked} onclick={reload}>{dirty ? 'Discard unsaved changes and reload current policy' : 'Reload current policy'}</button>
{#if interruptions.length}<section aria-label="Cancellation evidence"><h3>Cancellation requests, not stop confirmation</h3><ul>{#each interruptions as i}<li>Worker {i.worker_id}: cancellation {i.cancellation_accepted ? 'accepted' : 'not confirmed'}; Backend command cleanup {i.backend_command_sessions_closed ? 'confirmed' : 'not confirmed'}. Runtime stop remains unconfirmed.</li>{/each}</ul></section>{/if}
{/if}
</section>
<style>
.permissions { max-width: 72rem; min-width: 0; }
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem; }
h2 { margin: 0; font-size: var(--font-size-body); } h3 { font-size: var(--font-size-body); margin: 0 0 .5rem; } h4 { margin: 1rem 0 .4rem; }
p { line-height: 1.55; color: var(--text-muted); } .state { font-size: var(--font-size-compact); }
.lanes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; margin: 1.5rem 0; }
.lane { min-width: 0; border-top: 1px solid var(--line); padding-top: 1rem; }
ul { list-style: none; padding: 0; margin: .75rem 0; } li { display: flex; align-items: start; gap: .75rem; justify-content: space-between; padding: .75rem 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
li > div { min-width: 0; } li button { flex-shrink: 0; white-space: nowrap; } .resource, .legacy { display: block; margin-top: .35rem; font-size: var(--font-size-body); } .legacy { color: var(--text-muted); }
.permission-form, .diff { border-top: 1px solid var(--line); margin-top: 1.5rem; padding-top: 1rem; }
fieldset { border: 0; padding: 0; min-width: 0; display: grid; gap: .75rem; max-width: 38rem; }
label { display: grid; gap: .35rem; font-size: var(--font-size-body); } input, select { width: 100%; min-width: 0; padding: .55rem; border: 1px solid var(--line); border-radius: 3px; background: var(--bg); color: var(--text); font: inherit; }
button { background: transparent; color: var(--text); border: 1px solid var(--line); padding: .45rem .7rem; border-radius: 3px; cursor: pointer; font: inherit; font-size: var(--font-size-body); } button:disabled { opacity: .5; cursor: default; } button.save { background: var(--text); color: var(--bg); margin-top: .75rem; }
.check { display: flex; align-items: start; gap: .5rem; } .check input { width: auto; margin-top: .2rem; } .reload { margin: 1rem 0; }
.attention { border-left: 3px solid var(--text-muted); padding: .7rem 1rem; overflow-wrap: anywhere; } .help { margin: 1.5rem 0; } summary { cursor: pointer; } code { overflow-wrap: anywhere; }
@media (max-width: 900px) { .lanes { grid-template-columns: 1fr; gap: 1rem; } }
</style>
@@ -0,0 +1,149 @@
import {
attemptKey,
makeResource,
parseView,
permissionKey,
policyDiff,
readAttempt,
readPermissions,
writePermissions,
} from "./permissions.ts";
import type { ScopedPermission } from "./permissions.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assert(condition: unknown): asserts condition {
if (!condition) throw new Error("Assertion failed");
}
const p: ScopedPermission = {
operation: "filesystem.read",
resource: {
kind: "workdir",
working_directory_id: "wd",
path: "src",
recursive: true,
},
};
Deno.test("permission differences preserve exact legacy resource and separate lanes", () => {
const legacy: ScopedPermission = {
operation: "worker.notify",
resource: {
kind: "worker",
runtime_id: "runtime",
worker_id: "only-worker",
},
};
const direct = [legacy, p];
assert(policyDiff([legacy], direct).added.length === 1);
assert(policyDiff([legacy], direct).removed.length === 0);
assert(
permissionKey(p) ===
permissionKey({
operation: p.operation,
resource: {
recursive: true,
path: "src",
working_directory_id: "wd",
kind: "workdir",
},
}),
);
assert(policyDiff([legacy], []).removed[0] === legacy);
});
Deno.test("typed resource input binds workspace, preserves narrow paths and rejects missing targets", () => {
assert(
JSON.stringify(
makeResource("workdir", "w", "s", {
working_directory_id: "wd",
path: "src",
}, false),
) ===
JSON.stringify({
kind: "workdir",
working_directory_id: "wd",
path: "src",
recursive: false,
}),
);
assert(
JSON.stringify(makeResource("subject", "w", "s", {}, false)) ===
JSON.stringify({ kind: "subject", workspace_id: "w", subject_id: "s" }),
);
let rejected = false;
try {
makeResource("ticket", "w", "s", {}, false);
} catch {
rejected = true;
}
assert(rejected);
});
Deno.test("catalog parsing refuses unknown resource types and malformed management permission", () => {
for (
const value of [
{ permissions: null, can_manage: "yes", operations: [] },
{
permissions: null,
can_manage: true,
operations: [{
operation: "unknown",
label: "Unknown",
description: "",
resource_kinds: ["everything"],
availability: { kind: "available" },
}],
},
]
) {
let rejected = false;
try {
parseView(value);
} catch {
rejected = true;
}
assert(rejected);
}
});
Deno.test("save retry keeps exact complete input scoped to Workspace and Subject", async () => {
const request = {
operation_id: "stable-operation",
expected_change_id: "old",
execution: [p],
delegation: [],
};
const memory = new Map([[attemptKey("w", "s"), JSON.stringify(request)]]);
const storage = { getItem: (key: string) => memory.get(key) ?? null };
assert(
JSON.stringify(readAttempt(storage, "w", "s")) === JSON.stringify(request),
);
assert(readAttempt(storage, "other", "s") === null);
assert(readAttempt(storage, "w", "other") === null);
const calls: { url: string; body: string }[] = [];
const fake = ((input: string, init?: RequestInit) => {
calls.push({ url: input, body: init?.body as string });
return Promise.resolve(
Response.json({
permissions: { change_id: "new", execution: [p], delegation: [] },
interruptions: [],
}),
);
}) as typeof fetch;
await writePermissions(fake, "w", "s", request);
await writePermissions(fake, "w", "s", readAttempt(storage, "w", "s")!);
assert(calls[0].url === "/api/w/w/subjektiv/subjects/s/permissions");
assert(calls[0].body === calls[1].body);
});
Deno.test("read permission projection uses the existing scoped endpoint and no fallback grant", async () => {
let path = "";
const fake = ((input: string) => {
path = input;
return Promise.resolve(
Response.json({ permissions: null, can_manage: false, operations: [] }),
);
}) as typeof fetch;
const view = await readPermissions(fake, "space", "subject");
assert(path === "/api/w/space/subjektiv/subjects/subject/permissions");
assert(
view.permissions === null && !view.can_manage &&
view.operations.length === 0,
);
});
@@ -0,0 +1,295 @@
import type {
PermissionOperationDescriptor,
PermissionResource,
ScopedPermission,
SubjectPermissionInterruption,
SubjectPermissions,
SubjectPermissionsMutationResponse,
SubjectPermissionsResponse,
SubjectPermissionsUpdate,
} from "#lib/generated/memory-api.ts";
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
export type { PermissionResource, ScopedPermission };
export type Policy = SubjectPermissions;
export type PolicyUpdate = SubjectPermissionsUpdate;
export type Operation = Omit<PermissionOperationDescriptor, "availability"> & {
available: boolean;
unavailable_reason: string | null;
};
export type PermissionView = Omit<SubjectPermissionsResponse, "operations"> & {
operations: Operation[];
};
export type Interruption = SubjectPermissionInterruption;
export type Mutation = SubjectPermissionsMutationResponse;
export type Lane = "execution" | "delegation";
const MAX_BYTES = 1_048_576;
const fields = {
workspace: ["workspace_id"],
ticket: ["workspace_id", "ticket_id"],
objective: ["workspace_id", "objective_id"],
merge_request: ["workspace_id", "merge_request_id"],
subject: ["workspace_id", "subject_id"],
drive_entry: ["workspace_id", "node_id"],
drive_request: ["workspace_id", "request_id"],
repository: ["workspace_id", "repository_key"],
workdir_attachment: ["workspace_id", "connection_id"],
workspace_config: ["workspace_id", "path", "recursive"],
network: ["url"],
sub_worker: ["runtime_id", "worker_id", "name"],
feature: [],
workdir: ["working_directory_id", "path", "recursive"],
worker: ["runtime_id", "worker_id"],
job: ["workspace_id", "job_id"],
} satisfies Record<PermissionResource["kind"], string[]>;
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Invalid permission response");
}
return value as Record<string, unknown>;
}
function text(value: unknown): string {
if (typeof value !== "string" || value.length > 16_384) {
throw new Error("Invalid permission text");
}
return value;
}
function bool(value: unknown): boolean {
if (typeof value !== "boolean") throw new Error("Invalid permission flag");
return value;
}
function list<T>(value: unknown, parse: (item: unknown) => T): T[] {
if (!Array.isArray(value) || value.length > 256) {
throw new Error("Invalid permission list");
}
return value.map(parse);
}
export function parseResource(value: unknown): PermissionResource {
const r = object(value);
const kind = text(r.kind) as PermissionResource["kind"];
if (!Object.hasOwn(fields, kind)) throw new Error("Unknown resource kind");
const keys: string[] = fields[kind];
if (
Object.keys(r).some((key) => key !== "kind" && !keys.includes(key)) ||
keys.some((key) => !(key in r))
) throw new Error("Invalid resource fields");
for (const key of keys) key === "recursive" ? bool(r[key]) : text(r[key]);
return r as PermissionResource;
}
export function parsePermission(value: unknown): ScopedPermission {
const p = object(value);
return { operation: text(p.operation), resource: parseResource(p.resource) };
}
export function parsePolicy(value: unknown): Policy {
const p = object(value);
return {
change_id: text(p.change_id),
execution: list(p.execution, parsePermission),
delegation: list(p.delegation, parsePermission),
};
}
export function parseView(value: unknown): PermissionView {
const v = object(value);
return {
permissions: v.permissions === null ? null : parsePolicy(v.permissions),
can_manage: bool(v.can_manage),
operations: list(v.operations, (value) => {
const o = object(value);
const availability = object(o.availability);
if (
availability.kind !== "available" && availability.kind !== "unavailable"
) throw new Error("Unknown operation availability");
return {
operation: text(o.operation),
label: text(o.label),
description: text(o.description),
resource_kinds: list(o.resource_kinds, (value) => {
const kind = text(value) as PermissionResource["kind"];
if (!Object.hasOwn(fields, kind)) {
throw new Error("Unknown resource kind");
}
return kind;
}),
available: availability.kind === "available",
unavailable_reason: availability.kind === "unavailable"
? text(availability.reason)
: null,
};
}),
};
}
export class PermissionHttpError extends Error {
constructor(public status: number) {
super(`Permission request rejected (HTTP ${status}).`);
}
}
const path = (workspaceId: string, subjectId: string) =>
workspaceApiPath(
workspaceId,
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/permissions`,
);
export async function readPermissions(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
): Promise<PermissionView> {
const response = await fetchFn(path(workspaceId, subjectId));
if (!response.ok) {
await response.body?.cancel();
throw new PermissionHttpError(response.status);
}
return parseView(await readBoundedJson(response, MAX_BYTES));
}
export async function writePermissions(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: PolicyUpdate,
): Promise<Mutation> {
const response = await fetchFn(path(workspaceId, subjectId), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
if (!response.ok) {
await response.body?.cancel();
throw new PermissionHttpError(response.status);
}
const v = object(await readBoundedJson(response, MAX_BYTES));
return {
permissions: parsePolicy(v.permissions),
interruptions: list(v.interruptions, (value) => {
const i = object(value);
return {
runtime_id: text(i.runtime_id),
worker_id: text(i.worker_id),
cancellation_accepted: bool(i.cancellation_accepted),
backend_command_sessions_closed: bool(
i.backend_command_sessions_closed,
),
diagnostics: list(i.diagnostics, text),
};
}),
};
}
export function permissionKey(permission: ScopedPermission): string {
return JSON.stringify([
permission.operation,
Object.entries(permission.resource).sort(([a], [b]) => a.localeCompare(b)),
]);
}
export function policyDiff(
before: ScopedPermission[],
after: ScopedPermission[],
) {
return {
added: after.filter((p) =>
!before.some((b) => permissionKey(b) === permissionKey(p))
),
removed: before.filter((p) =>
!after.some((a) => permissionKey(a) === permissionKey(p))
),
};
}
export function resourceLabel(resource: PermissionResource): string {
const labels: Record<string, string> = {
workspace: "Current workspace",
subject: "Subject",
ticket: "Ticket",
objective: "Objective",
merge_request: "Merge request",
workdir: "Workdir",
workspace_config: "Workspace configuration",
network: "URL",
drive_entry: "Drive entry",
drive_request: "Drive request",
repository: "Repository",
workdir_attachment: "Workdir connection",
feature: "MCP activation / legacy feature",
worker: "Legacy Worker target",
sub_worker: "Internal SubWorker",
job: "Job",
};
const identifiers = Object.entries(resource).filter(([key]) =>
!["kind", "workspace_id", "recursive"].includes(key)
).map(([_, value]) => String(value));
return `${labels[resource.kind]}${
identifiers.length ? ` · ${identifiers.join(" / ")}` : ""
}${
"recursive" in resource && resource.recursive
? " (including descendants)"
: ""
}`;
}
// 保存値の正本ではなく、同じ操作の完全一致再送に必要な入力だけをtab内に保持する。
export function attemptKey(workspaceId: string, subjectId: string): string {
return `yoi.subject-permissions:${JSON.stringify([workspaceId, subjectId])}`;
}
export function readAttempt(
storage: Pick<Storage, "getItem">,
workspaceId: string,
subjectId: string,
): PolicyUpdate | null {
const raw = storage.getItem(attemptKey(workspaceId, subjectId));
if (raw === null) return null;
if (raw.length > MAX_BYTES) {
throw new Error(
"Saved permission request is unreadable. Do not start another save.",
);
}
const v = object(JSON.parse(raw));
return {
operation_id: text(v.operation_id),
expected_change_id: v.expected_change_id === null
? null
: text(v.expected_change_id),
execution: list(v.execution, parsePermission),
delegation: list(v.delegation, parsePermission),
};
}
export function makeResource(
kind: PermissionResource["kind"],
workspaceId: string,
subjectId: string,
inputs: Record<string, string>,
recursive: boolean,
): PermissionResource {
const r: Record<string, unknown> = { kind };
for (const key of fields[kind]) {
r[key] = key === "workspace_id"
? workspaceId
: key === "recursive"
? recursive
: key === "subject_id"
? (inputs[key]?.trim() || subjectId)
: inputs[key]?.trim() ?? "";
if (
typeof r[key] === "string" && !(r[key] as string).length && key !== "path"
) throw new Error("Enter the required target resource.");
}
if (kind === "workdir" && !r.path) r.path = ".";
return parseResource(r);
}
export function resourceFields(kind: PermissionResource["kind"]): string[] {
return fields[kind].filter((key) =>
!["workspace_id", "recursive"].includes(key)
);
}
export const fieldLabel: Record<string, string> = {
ticket_id: "Ticket identifier",
objective_id: "Objective identifier",
merge_request_id: "Merge request identifier",
subject_id: "Subject identifier (empty means this Subject)",
node_id: "Drive entry number",
request_id: "Drive request identifier",
repository_key: "Repository key",
connection_id: "Workdir connection identifier",
path: "Relative path",
url: "Exact HTTP(S) URL",
working_directory_id: "Workdir identifier",
job_id: "Job identifier",
runtime_id: "Runtime identifier",
worker_id: "Worker identifier",
name: "Internal SubWorker name",
};
@@ -180,7 +180,7 @@
{/if}
{#if !deletionStarted}
<nav aria-label="Subjectの記録"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a></nav>
<nav aria-label="Subjectの記録と管理"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a> · <a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/permissions`)}>Operation permissions · 直接操作と委譲上限 →</a></nav>
{#if subject}
<section class="subject-overview" aria-label="Subject status">
@@ -0,0 +1,9 @@
<script lang="ts">
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<DeferredPage {data} title="Subject permissions">
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
</DeferredPage>
@@ -0,0 +1,24 @@
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import {
MEMORY_API_LOAD_POLICY,
parseSubjektivSubjectResponse,
} from "#lib/workspace/memory/api.ts";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
export const load = deferPage(async (
{ fetch, params }: Parameters<PageLoad>[0],
) => ({
workspaceId: params.workspaceId,
subjectId: params.subjectId,
subject: await loadJson(
fetch,
workspaceApiPath(
params.workspaceId,
`/subjektiv/subjects/${encodeURIComponent(params.subjectId)}`,
),
undefined,
(value) => parseSubjektivSubjectResponse(value, params.subjectId),
MEMORY_API_LOAD_POLICY,
),
}));
@@ -0,0 +1,16 @@
<script lang="ts">
import SubjectPermissions from '#lib/workspace/memory/SubjectPermissions.svelte';
import { workspaceRoute } from '#lib/workspace/api/http.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData } from './$types';
let { data }: { data: ResolvedPageData<PageData> } = $props();
</script>
<svelte:head><title>Permissions · {data.subject.data?.role ?? 'Subject'} · Yoi Workspace</title></svelte:head>
<section class="permission-page">
<a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`)}>← Subject details</a>
<h1>{data.subject.data?.role ?? 'Subject'} permissions</h1>
{#if data.subject.data}
{#key JSON.stringify([data.workspaceId, data.subjectId])}<SubjectPermissions workspaceId={data.workspaceId} subjectId={data.subjectId} />{/key}
{:else}<p role="alert">Target unavailable. {data.subject.error} An old page cannot recreate a deleted Subject.</p>{/if}
</section>
<style>.permission-page { padding: 1.5rem; min-width: 0; } h1 { font-size: var(--font-size-title); margin: 1rem 0 1.5rem; overflow-wrap: anywhere; } @media (max-width: 600px) { .permission-page { padding: 1rem; } }</style>
@@ -78,6 +78,7 @@ function subject() {
function subjectIndexData(items = [subject()]) {
return {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
cursor: null,
subjects: result({
@@ -91,6 +92,7 @@ function subjectIndexData(items = [subject()]) {
test("renders the subject index as the Memory product entry", () => {
render(SubjectIndexPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
cursor: "subject-current",
subjects: result({
@@ -273,6 +275,7 @@ test("warns about an unknown create outcome without replaying the POST", async (
test("renders a ready resident surface and committed Memory lifecycle states", async () => {
render(SubjectPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",
@@ -368,6 +371,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
vi.stubGlobal("fetch", fetchMock);
render(SubjectPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",
@@ -440,6 +444,7 @@ test.each([false, true])(
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
return {
workspace: { permissions: { delete_subjects: false } },
workspaceId,
workspace: memoryWorkspace(workspaceId),
subjectId: record.id,
@@ -773,6 +778,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
// the same empty committed list as a new Subject; candidates are not a page input.
render(SubjectPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",
@@ -857,6 +863,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
const base = {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",
@@ -943,6 +950,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
test("renders committed Memory detail, provenance, derivation, and immutable changes", async () => {
render(MemoryDetailPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",