fix: enforce ServerApi as the wire authority

This commit is contained in:
2026-09-23 17:27:27 +09:00
parent f25c8d8b6d
commit 2766e5b41b
15 changed files with 2880 additions and 6444 deletions
+214 -118
View File
@@ -537,8 +537,13 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext,
#[path] operation_id: String,
) -> Result<WorkspaceDeletionOperationResponse, RepositoryApiError>;
#[get("/api/workspace", status = 200, error_status = 400)]
#[get(
"/api/workspace",
status = 200,
error_status = 400,
bearer_auth = true,
browser_auth = true
)]
async fn workspace_current(
&self,
#[extension] context: ServerRequestContext,
@@ -833,12 +838,13 @@ pub trait ServerApi {
&self,
#[path] workspace_id: String,
) -> Result<WorkspaceConfigTreeResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/config/projections/prompts",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500]
additional_error_statuses = [401, 403, 404, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn workspace_prompt_projection(
&self,
@@ -899,47 +905,51 @@ pub trait ServerApi {
&self,
#[path] workspace_id: String,
) -> Result<ProfileSettingsResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/flows",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500]
additional_error_statuses = [401, 403, 404, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn flow_list(
&self,
#[path] workspace_id: String,
) -> Result<FlowSourceListResponse, RepositoryApiError>;
#[put(
"/api/w/{workspace_id}/flows",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500]
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn flow_put(
&self,
#[path] workspace_id: String,
#[body] request: PutFlowRequest,
) -> Result<FlowSourceRecord, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/flows/resolve",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500]
additional_error_statuses = [401, 403, 404, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn flow_resolve(
&self,
#[path] workspace_id: String,
#[body] request: FlowSourceResolveRequest,
) -> Result<ResolvedFlowSource, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/flows/{flow_id}",
status = 200,
error_status = 404,
additional_error_statuses = [400, 401, 403, 500]
additional_error_statuses = [400, 401, 403, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn flow_get(
&self,
@@ -973,24 +983,26 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[query] query: MemoryStagingQuery,
) -> Result<MemoryStagingListResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/memory/backend",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500]
additional_error_statuses = [401, 403, 404, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn memory_backend(
&self,
#[path] workspace_id: String,
#[body] request: MemoryBackendRequest,
) -> Result<MemoryBackendResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/memory/consolidation",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503]
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
bearer_auth = true,
browser_auth = true,
)]
async fn memory_consolidation(
&self,
@@ -1037,12 +1049,13 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[path] name: String,
) -> Result<SkillDetailResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/skills/{name}/activate",
status = 200,
error_status = 404,
additional_error_statuses = [400, 401, 403, 409, 500]
additional_error_statuses = [400, 401, 403, 409, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn skill_activate(
&self,
@@ -1112,24 +1125,24 @@ pub trait ServerApi {
&self,
#[path] workspace_id: String,
) -> Result<BrowserWorkspaceOrchestratorResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/worker-control/workers",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500]
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn worker_control_list(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<WorkerControlListResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/worker-control/workers",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503]
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
openapi = false,
)]
async fn worker_control_spawn(
&self,
@@ -1137,12 +1150,12 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: CreateWorkspaceWorkerRequest,
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/input",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503]
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
openapi = false,
)]
async fn worker_control_input(
&self,
@@ -1152,12 +1165,12 @@ pub trait ServerApi {
#[path] worker_id: String,
#[body] request: RuntimeWorkerInputRequest,
) -> Result<RuntimeWorkerInputResult, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/cancel",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503]
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
openapi = false,
)]
async fn worker_control_cancel(
&self,
@@ -1167,12 +1180,12 @@ pub trait ServerApi {
#[path] worker_id: String,
#[body] request: RuntimeWorkerLifecycleRequest,
) -> Result<RuntimeWorkerLifecycleResult, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/stop",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504]
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504],
openapi = false,
)]
async fn worker_control_stop(
&self,
@@ -1182,12 +1195,12 @@ pub trait ServerApi {
#[path] worker_id: String,
#[body] request: RuntimeWorkerLifecycleRequest,
) -> Result<RuntimeWorkerLifecycleResult, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/restore",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504]
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504],
openapi = false,
)]
async fn worker_control_restore(
&self,
@@ -1196,26 +1209,24 @@ pub trait ServerApi {
#[path] runtime_id: String,
#[path] worker_id: String,
) -> Result<WorkerRestoreResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/worker-observation/sessions",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
openapi = false
openapi = false,
)]
async fn worker_observation_sessions(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<WorkerObservationSessionsResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/worker-observation/session",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500, 502, 504],
openapi = false
openapi = false,
)]
async fn worker_observation_capture(
&self,
@@ -1223,12 +1234,12 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: WorkerObservationSubjectRef,
) -> Result<WorkerObservationCaptureResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/worker-discovery/workers",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500]
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn workspace_worker_discovery(
&self,
@@ -1303,12 +1314,12 @@ pub trait ServerApi {
&self,
#[path] workspace_id: String,
) -> Result<WorkerLaunchOptionsResponse, RepositoryApiError>;
#[post(
"/api/runtime/v1/workspaces/{workspace_id}/resources/fetch",
status = 200,
error_status = 400,
additional_error_statuses = [401, 404, 410, 502, 504]
additional_error_statuses = [401, 404, 410, 502, 504],
openapi = false,
)]
async fn runtime_resource_fetch(
&self,
@@ -1420,12 +1431,12 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: CompanionCancelRequest,
) -> Result<CompanionMessageResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/remove",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 503]
additional_error_statuses = [401, 403, 404, 409, 500, 503],
openapi = false,
)]
async fn workspace_worker_remove(
&self,
@@ -2214,13 +2225,12 @@ pub trait ServerApi {
#[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest,
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/self/workdir-attachments",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
bearer_auth = true
openapi = false,
)]
async fn current_worker_workdir_attach(
&self,
@@ -2228,13 +2238,12 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: CurrentWorkerWorkdirAttachRequest,
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
#[delete(
"/api/w/{workspace_id}/workers/self/workdir-attachments/{alias}",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
bearer_auth = true
openapi = false,
)]
async fn current_worker_workdir_detach(
&self,
@@ -2242,13 +2251,12 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[path] alias: String,
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
bearer_auth = true
openapi = false,
)]
async fn current_worker_workdir_operation(
&self,
@@ -2359,8 +2367,7 @@ pub trait ServerApi {
#[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest,
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[get("/api/tickets", status = 200, error_status = 400, additional_error_statuses = [500])]
#[get("/api/tickets", status = 200, error_status = 400, additional_error_statuses = [500], bearer_auth = true, browser_auth = true)]
async fn ticket_list_alias(
&self,
#[query] query: TicketListHttpQuery,
@@ -2387,61 +2394,53 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: TicketQueryRequest,
) -> Result<TicketQueryResponse, RepositoryApiError>;
#[get("/api/tickets/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 500])]
#[get("/api/tickets/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_get_alias(
&self,
#[path] id: String,
) -> Result<TicketDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/default-intake-ready-body", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[post("/api/w/{workspace_id}/tickets/default-intake-ready-body", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_default_intake_ready_body(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: DefaultIntakeReadyBodyRequest,
) -> Result<TextResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[get("/api/w/{workspace_id}/tickets/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_summary_search(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] query: TicketSummarySearchQuery,
) -> Result<TicketRecordSummaryList, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/doctor", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[get("/api/w/{workspace_id}/tickets/doctor", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_doctor(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<TicketDoctorResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/relations/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[post("/api/w/{workspace_id}/tickets/relations/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_relation_query(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: TicketRelationSearchRequest,
) -> Result<TicketRelationRecordList, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/orchestration-plans/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[post("/api/w/{workspace_id}/tickets/orchestration-plans/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_orchestration_plan_query(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: TicketOrchestrationPlanSearchRequest,
) -> Result<TicketOrchestrationPlanRecordList, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/record", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500])]
#[get("/api/w/{workspace_id}/tickets/{id}/record", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_record_get(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
) -> Result<TicketRecord, RepositoryApiError>;
#[patch("/api/w/{workspace_id}/tickets/{id}/item", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[patch("/api/w/{workspace_id}/tickets/{id}/item", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_record_item_edit(
&self,
#[extension] context: ServerRequestContext,
@@ -2449,16 +2448,14 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: EditTicketRecordItemRequest,
) -> Result<TicketRecord, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/dependency-check", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[get("/api/w/{workspace_id}/tickets/{id}/dependency-check", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_dependency_check(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
) -> Result<TicketDependencyCheckResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/thread-events", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/thread-events", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_thread_event_add(
&self,
#[extension] context: ServerRequestContext,
@@ -2466,8 +2463,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketThreadEventRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_change_add(
&self,
#[extension] context: ServerRequestContext,
@@ -2475,8 +2471,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/intake-summaries", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/intake-summaries", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_intake_summary_add(
&self,
#[extension] context: ServerRequestContext,
@@ -2484,8 +2479,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketIntakeSummaryRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_field_set(
&self,
#[extension] context: ServerRequestContext,
@@ -2494,8 +2488,7 @@ pub trait ServerApi {
#[path] field: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_workflow_state_set(
&self,
#[extension] context: ServerRequestContext,
@@ -2503,8 +2496,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_mark_ready_record(
&self,
#[extension] context: ServerRequestContext,
@@ -2512,8 +2504,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketMarkReadyRequest,
) -> Result<TicketRecord, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/queue", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/queue", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_queue_record(
&self,
#[extension] context: ServerRequestContext,
@@ -2535,8 +2526,7 @@ pub trait ServerApi {
#[path] merge_request_id: String,
#[query] query: MergeRequestThreadQuery,
) -> Result<MergeRequestDetailResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn merge_request_open(
&self,
#[extension] context: ServerRequestContext,
@@ -2544,23 +2534,20 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: OpenMergeRequestRequest,
) -> Result<PublicMergeRequest, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/merge-request/readiness", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[get("/api/w/{workspace_id}/tickets/{id}/merge-request/readiness", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn merge_request_readiness(
&self,
#[path] workspace_id: String,
#[path] id: String,
) -> Result<MergeRequestReadinessResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/merge-request/thread", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[get("/api/w/{workspace_id}/tickets/{id}/merge-request/thread", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn merge_request_thread(
&self,
#[path] workspace_id: String,
#[path] id: String,
#[query] query: MergeRequestThreadQuery,
) -> Result<MergeRequestThreadResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], browser_auth = true)]
async fn merge_request_selector_repair(
&self,
#[extension] context: ServerRequestContext,
@@ -2568,16 +2555,14 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: RepairMergeRequestSelectorRequest,
) -> Result<PublicMergeRequest, RepositoryApiError>;
#[post("/api/w/{workspace_id}/internal/reviewer-child-sessions", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/internal/reviewer-child-sessions", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn merge_request_reviewer_child_register(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: RegisterReviewerChildSessionRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/review-capabilities", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/review-capabilities", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn merge_request_review_capability_register(
&self,
#[extension] context: ServerRequestContext,
@@ -2585,16 +2570,14 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: RegisterMergeRequestReviewCapabilityRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn merge_request_review_submit(
&self,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: SubmitMergeRequestReviewRequest,
) -> Result<ReviewEvent, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews/revoke", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews/revoke", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn merge_request_review_revoke(
&self,
#[extension] context: ServerRequestContext,
@@ -2602,8 +2585,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: RevokeMergeRequestReviewRequest,
) -> Result<ReviewRevokedEvent, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn merge_request_complete(
&self,
#[extension] context: ServerRequestContext,
@@ -2611,8 +2593,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: CompleteMergeRequestRequest,
) -> Result<MergeEvent, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_close_record(
&self,
#[extension] context: ServerRequestContext,
@@ -2620,16 +2601,14 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketCloseRecordRequest,
) -> Result<(), RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_relation_view(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
) -> Result<TicketRelationRecordView, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/relations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/relations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_relation_record(
&self,
#[extension] context: ServerRequestContext,
@@ -2637,8 +2616,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: CreateTicketRelationRequest,
) -> Result<TicketRelationRecord, RepositoryApiError>;
#[delete("/api/w/{workspace_id}/tickets/{id}/relations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[delete("/api/w/{workspace_id}/tickets/{id}/relations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_relation_remove(
&self,
#[extension] context: ServerRequestContext,
@@ -2646,8 +2624,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketRelationRemoveRequest,
) -> Result<TicketRelationRecord, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/orchestration-plans", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/orchestration-plans", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_orchestration_plan_record(
&self,
#[extension] context: ServerRequestContext,
@@ -2670,8 +2647,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: BrowserEditTicketRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[post("/api/w/{workspace_id}/tickets/{id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_show(
&self,
#[path] workspace_id: String,
@@ -2751,8 +2727,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: BrowserCloseTicketRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[get("/api/objectives", status = 200, error_status = 400, additional_error_statuses = [500])]
#[get("/api/objectives", status = 200, error_status = 400, additional_error_statuses = [500], bearer_auth = true, browser_auth = true)]
async fn objective_list_alias(
&self,
#[query] query: ObjectiveListQuery,
@@ -2771,15 +2746,13 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: ObjectiveCreateRequest,
) -> Result<ObjectiveDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/objectives/query", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[post("/api/w/{workspace_id}/objectives/query", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn objective_query(
&self,
#[path] workspace_id: String,
#[body] request: ObjectiveQueryRequest,
) -> Result<ObjectiveQueryResponse, RepositoryApiError>;
#[get("/api/objectives/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 500])]
#[get("/api/objectives/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
async fn objective_get_alias(
&self,
#[path] id: String,
@@ -2799,8 +2772,7 @@ pub trait ServerApi {
#[path] objective_id: String,
#[body] request: ObjectiveEditRequest,
) -> Result<ObjectiveDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/objectives/{objective_id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500])]
#[post("/api/w/{workspace_id}/objectives/{objective_id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn objective_show(
&self,
#[path] workspace_id: String,
@@ -10555,6 +10527,130 @@ mod openapi_artifact_tests {
);
}
#[test]
fn server_api_openapi_security_matches_workspace_server_auth_semantics() {
const PUBLIC_UNAUTHENTICATED: &[&str] = &[
"health",
"auth_config",
"auth_bootstrap_user",
"auth_passkey_registration_options",
"auth_passkey_login_options",
"auth_device_login_start",
"auth_device_login_poll",
"auth_whoami",
];
// These operations are authenticated by Runtime request-source proof, Worker source
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
const SIGNED_INTERNAL: &[&str] = &[
"current_worker_workdir_attach",
"current_worker_workdir_detach",
"current_worker_workdir_operation",
"merge_request_complete",
"merge_request_open",
"merge_request_review_capability_register",
"merge_request_review_revoke",
"merge_request_review_submit",
"merge_request_reviewer_child_register",
"runtime_resource_fetch",
"worker_control_cancel",
"worker_control_input",
"worker_control_list",
"worker_control_restore",
"worker_control_spawn",
"worker_control_stop",
"worker_observation_capture",
"worker_observation_sessions",
"workspace_worker_discovery",
"workspace_worker_remove",
];
// Session observation retains its existing explicit non-OpenAPI boundary independently of
// the Runtime/Worker-source signed operation inventory above.
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"];
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
let value: serde_json::Value =
serde_json::from_str(&document.to_json().expect("document must serialize"))
.expect("document must be JSON");
let paths = value["paths"]
.as_object()
.expect("OpenAPI paths must exist");
let mut documented = std::collections::BTreeMap::new();
for path in paths.values() {
for operation in path
.as_object()
.expect("OpenAPI path item must be an object")
.values()
{
let operation_id = operation["operationId"]
.as_str()
.expect("OpenAPI operationId must be a string");
assert!(
documented.insert(operation_id, operation).is_none(),
"duplicate OpenAPI operationId {operation_id}"
);
}
}
let operations = ServerApiMetadata::OPERATIONS;
assert_eq!(
documented.len() + SIGNED_INTERNAL.len() + OTHER_OPENAPI_EXCLUDED.len(),
operations.len(),
"every ServerApi operation must have one bounded OpenAPI auth disposition"
);
for operation in operations {
let operation_id = operation.operation_id;
if SIGNED_INTERNAL.contains(&operation_id)
|| OTHER_OPENAPI_EXCLUDED.contains(&operation_id)
{
assert!(
!documented.contains_key(operation_id),
"internal/excluded operation {operation_id} must not appear public in OpenAPI"
);
continue;
}
let documented_operation = documented
.get(operation_id)
.unwrap_or_else(|| panic!("missing public OpenAPI operation {operation_id}"));
let expected = if PUBLIC_UNAUTHENTICATED.contains(&operation_id) {
&[][..]
} else if BROWSER_ONLY.contains(&operation_id) {
&["browserSession"][..]
} else {
&["bearerAuth", "browserSession"][..]
};
let security = documented_operation.get("security");
if expected.is_empty() {
assert!(
security.is_none(),
"public unauthenticated operation {operation_id} must not require security"
);
continue;
}
let security = security
.and_then(serde_json::Value::as_array)
.unwrap_or_else(|| panic!("authenticated operation {operation_id} lacks security"));
let mut actual = security
.iter()
.map(|requirement| {
let requirement = requirement.as_object().unwrap_or_else(|| {
panic!("security requirement for {operation_id} must be an object")
});
assert_eq!(
requirement.len(),
1,
"security alternatives for {operation_id} must remain independent OR choices"
);
requirement.keys().next().expect("one security scheme").as_str()
})
.collect::<Vec<_>>();
actual.sort_unstable();
assert_eq!(actual, expected, "security mismatch for {operation_id}");
}
}
#[test]
fn auth_and_workspace_catalog_contract_is_strict_and_excludes_cookie_routes() {
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
@@ -662,27 +662,6 @@ fn validate_workdir_operations(
request: Some("WorkingDirectoryRemovalRequest"),
successes: &[("200", "WorkingDirectoryRemovalResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/workers/self/workdir-attachments",
method: "post",
operation_id: "current_worker_workdir_attach",
request: Some("CurrentWorkerWorkdirAttachRequest"),
successes: &[("200", "CurrentWorkerWorkdirAttachmentResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/workers/self/workdir-attachments/{alias}",
method: "delete",
operation_id: "current_worker_workdir_detach",
request: None,
successes: &[("200", "CurrentWorkerWorkdirAttachmentResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/workers/self/workdir-session/operations",
method: "post",
operation_id: "current_worker_workdir_operation",
request: Some("CurrentWorkerWorkdirOperationRequest"),
successes: &[("200", "WorkdirSessionOperationResult")],
},
OperationContract {
path: "/api/w/{workspace_id}/external-workdir-grants",
method: "post",
@@ -1140,13 +1119,10 @@ fn validate_operation_contracts(
&format!("{} response {status}", operation.operation_id),
)?;
let schema = json_content_schema(response, operation.operation_id)?;
let expected = successes.get(status.as_str()).copied().unwrap_or_else(|| {
if operation.operation_id == "current_worker_workdir_operation" {
"WorkdirOperationApiError"
} else {
"RepositoryApiError"
}
});
let expected = successes
.get(status.as_str())
.copied()
.unwrap_or("RepositoryApiError");
require_schema_ref(schema, expected, operation.operation_id)?;
roots.insert(expected.to_owned());
}
+17 -17
View File
@@ -19,10 +19,10 @@ use crate::records::{
ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary,
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketActionEligibility,
TicketAssignmentPrincipalSummary, TicketAssignmentSummary, TicketDetail, TicketEventDetail,
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
TicketEvidenceEvent, TicketEvidenceSummary, TicketListProjectionRequest,
TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse,
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
summarize_body, truncate_body,
summarize_body, ticket_relation_view_from_domain, truncate_body,
};
use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
@@ -48,7 +48,7 @@ impl<T> WorkspaceAuthority for T where T: ObjectiveAuthority + TicketAuthority +
pub trait TicketAuthority {
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>>;
fn list_ticket_page(&self, request: TicketListPageRequest) -> Result<TicketSummaryPage>;
fn list_ticket_page(&self, request: TicketListProjectionRequest) -> Result<TicketSummaryPage>;
fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse>;
fn ticket(&self, id: &str) -> Result<TicketDetail>;
fn show_ticket(&self, id: &str, query: TicketShowRequest) -> Result<TicketDetail>;
@@ -86,14 +86,14 @@ pub trait MemoryAuthority {
fn ensure_memory_document(&self) -> Result<MemoryDocument>;
fn memory_document(&self) -> Result<MemoryDocument>;
fn update_memory_document(&self, body_md: &str) -> Result<MemoryDocument>;
fn list_memory_staging_records(&self, limit: usize) -> Result<Vec<MemoryStagingEntry>>;
fn memory_staging_record(&self, candidate_id: &str) -> Result<MemoryStagingEntry>;
fn list_memory_staging_records(&self, limit: usize) -> Result<Vec<InternalMemoryStagingEntry>>;
fn memory_staging_record(&self, candidate_id: &str) -> Result<InternalMemoryStagingEntry>;
fn upsert_memory_staging_record(
&self,
candidate_id: &str,
raw_json: &str,
source_path: Option<&str>,
) -> Result<MemoryStagingEntry>;
) -> Result<InternalMemoryStagingEntry>;
fn close_memory_staging_record(
&self,
candidate_id: &str,
@@ -114,7 +114,7 @@ pub struct MemoryDocument {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryStagingEntry {
pub struct InternalMemoryStagingEntry {
pub candidate_id: String,
pub raw_json: String,
pub source_path: Option<String>,
@@ -966,7 +966,7 @@ impl SqliteWorkspaceAuthority {
&ticket.meta.id,
)?)
.ok_or_else(|| Error::Store(format!("missing resource key for {}", ticket.meta.id)))?;
let mut relations: TicketRelationView = ticket.relations.into();
let mut relations = ticket_relation_view_from_domain(ticket.relations);
for relation in &mut relations.outgoing {
relation.target_resource_key = self.store.resource_key(
&self.workspace_id,
@@ -1073,7 +1073,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
})
}
fn list_ticket_page(&self, request: TicketListPageRequest) -> Result<TicketSummaryPage> {
fn list_ticket_page(&self, request: TicketListProjectionRequest) -> Result<TicketSummaryPage> {
let limit = request.limit.unwrap_or(30).clamp(1, 100);
let mut states = request.states;
states.sort();
@@ -1587,7 +1587,7 @@ impl MemoryAuthority for SqliteWorkspaceAuthority {
Ok(memory_document_from_record(record))
}
fn list_memory_staging_records(&self, limit: usize) -> Result<Vec<MemoryStagingEntry>> {
fn list_memory_staging_records(&self, limit: usize) -> Result<Vec<InternalMemoryStagingEntry>> {
self.store
.list_memory_staging_records(&self.workspace_id, limit)
.map(|records| {
@@ -1598,7 +1598,7 @@ impl MemoryAuthority for SqliteWorkspaceAuthority {
})
}
fn memory_staging_record(&self, candidate_id: &str) -> Result<MemoryStagingEntry> {
fn memory_staging_record(&self, candidate_id: &str) -> Result<InternalMemoryStagingEntry> {
validate_memory_candidate_id(candidate_id)?;
self.store
.get_memory_staging_record(&self.workspace_id, candidate_id)?
@@ -1613,7 +1613,7 @@ impl MemoryAuthority for SqliteWorkspaceAuthority {
candidate_id: &str,
raw_json: &str,
source_path: Option<&str>,
) -> Result<MemoryStagingEntry> {
) -> Result<InternalMemoryStagingEntry> {
validate_memory_candidate_id(candidate_id)?;
validate_json_object(raw_json, "raw_json")?;
let imported_at = now_rfc3339();
@@ -2634,8 +2634,8 @@ fn memory_document_from_record(record: MemoryDocumentRecord) -> MemoryDocument {
}
}
fn memory_staging_from_record(record: MemoryStagingRecord) -> MemoryStagingEntry {
MemoryStagingEntry {
fn memory_staging_from_record(record: MemoryStagingRecord) -> InternalMemoryStagingEntry {
InternalMemoryStagingEntry {
candidate_id: record.candidate_id,
raw_json: record.raw_json,
source_path: record.source_path,
@@ -3330,7 +3330,7 @@ VALUES ('workspace-test', 'ticket', 4);
assert_eq!(incoming_relation.items.len(), 1);
assert_eq!(incoming_relation.items[0].id, "00000000001J5");
let summary_page = authority
.list_ticket_page(TicketListPageRequest {
.list_ticket_page(TicketListProjectionRequest {
states: vec!["planning".to_string(), "ready".to_string()],
limit: Some(1),
cursor: None,
@@ -3338,7 +3338,7 @@ VALUES ('workspace-test', 'ticket', 4);
.unwrap();
assert_eq!(summary_page.items.len(), 1);
assert!(summary_page.page.has_more);
let mismatched_summary_cursor = authority.list_ticket_page(TicketListPageRequest {
let mismatched_summary_cursor = authority.list_ticket_page(TicketListProjectionRequest {
states: vec!["done".to_string()],
limit: Some(1),
cursor: summary_page.page.next_cursor,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -36,7 +36,7 @@ pub mod workspace_signing_identity;
mod workspace_subscription;
pub use authority::{
MemoryAuthority, MemoryDocument, MemoryStagingEntry, MemoryStagingResolution,
InternalMemoryStagingEntry, MemoryAuthority, MemoryDocument, MemoryStagingResolution,
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
WorkspaceAuthority,
};
+68 -409
View File
@@ -1,19 +1,29 @@
use serde::{Deserialize, Serialize};
pub use server_api::{
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary,
ObjectiveSummary, QueryPage,
InvalidProjectRecord, MergeRequestListItem, MergeRequestListResponse,
MergeRequestRefDiagnostic, ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary,
ObjectiveLinkedTicketSummary, ObjectiveQueryItem, ObjectiveQueryRequest,
ObjectiveQueryResponse, ObjectiveResourceSummary, ObjectiveShowRequest, ObjectiveSummary,
QueryPage, TicketActionEligibility,
TicketAssignmentPrincipal as TicketAssignmentPrincipalSummary, TicketAssignmentSummary,
TicketDetail, TicketDetailDerivedRelation as DerivedTicketRelation,
TicketDetailRelation as TicketRelation, TicketDetailRelationBlocker as TicketRelationBlocker,
TicketDetailRelationNotice as TicketRelationNotice,
TicketDetailRelationView as TicketRelationView, TicketEventDetail, TicketEvidenceEvent,
TicketEvidenceSummary, TicketListItemSummary as TicketSummary, TicketListResponse,
TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse,
TicketRoleAssignmentSummary, TicketShowRequest,
};
const SUMMARY_BODY_LIMIT: usize = 240;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectRecordList<T> {
pub items: Vec<T>,
pub invalid_records: Vec<InvalidProjectRecord>,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketSummaryPage {
pub items: Vec<TicketSummary>,
pub page: QueryPage,
@@ -21,418 +31,67 @@ pub struct TicketSummaryPage {
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InvalidProjectRecord {
pub label: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketSummary {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub priority: String,
pub updated_at: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub workspace_action_priority: String,
pub record_source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TicketListPageRequest {
#[serde(default)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TicketListProjectionRequest {
pub states: Vec<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketListResponse {
pub workspace_id: String,
pub limit: usize,
pub items: Vec<TicketSummary>,
pub page: QueryPage,
pub invalid_records: Vec<InvalidProjectRecord>,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketDetail {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub readiness: Option<String>,
pub priority: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub item_revision: String,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub repository_key: Option<String>,
pub ref_selector: Option<String>,
pub risk_flags: Vec<String>,
pub body: String,
pub body_truncated: bool,
pub event_count: usize,
pub events: Vec<TicketEventDetail>,
pub event_page: QueryPage,
pub artifact_count: usize,
pub artifacts: Vec<String>,
pub relations: TicketRelationView,
pub linked_objectives: Vec<ObjectiveLinkSummary>,
pub implementation_reports: Vec<TicketEvidenceEvent>,
pub assignments: Vec<TicketRoleAssignmentSummary>,
pub current_coder: Option<TicketAssignmentSummary>,
pub assignment_diagnostics: Vec<String>,
pub action_eligibility: TicketActionEligibility,
pub merge_request: Option<TicketMergeRequestSummary>,
pub evidence: TicketEvidenceSummary,
pub resolution: Option<String>,
pub record_source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketEventDetail {
pub sequence: usize,
pub event_ref: String,
pub kind: String,
pub author: Option<String>,
pub at: Option<String>,
pub status: Option<String>,
pub from: Option<String>,
pub to: Option<String>,
pub reason: Option<String>,
pub state_field: Option<String>,
pub heading: Option<String>,
pub body: Option<String>,
pub attributes: std::collections::BTreeMap<String, String>,
pub references: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketRelation {
pub ticket_id: String,
pub kind: String,
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_resource_key: Option<String>,
pub note: Option<String>,
pub author: String,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DerivedTicketRelation {
pub source_ticket: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_resource_key: Option<String>,
pub inverse_kind: String,
pub forward_kind: String,
pub note: Option<String>,
pub author: String,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketRelationBlocker {
pub blocking_ticket: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocking_resource_key: Option<String>,
pub reason_kind: String,
pub relation_kind: String,
pub note: Option<String>,
pub blocking_state: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketRelationNotice {
pub related_ticket: String,
pub kind: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketRelationView {
pub outgoing: Vec<TicketRelation>,
pub incoming: Vec<DerivedTicketRelation>,
pub blockers: Vec<TicketRelationBlocker>,
pub notices: Vec<TicketRelationNotice>,
}
impl From<ticket::TicketRelationView> for TicketRelationView {
fn from(value: ticket::TicketRelationView) -> Self {
Self {
outgoing: value
.outgoing
.into_iter()
.map(|relation| TicketRelation {
ticket_id: relation.ticket_id,
kind: relation.kind.as_str().to_string(),
target: relation.target,
target_resource_key: None,
note: relation.note,
author: relation.author,
at: relation.at,
})
.collect(),
incoming: value
.incoming
.into_iter()
.map(|relation| DerivedTicketRelation {
source_ticket: relation.source_ticket,
source_resource_key: None,
inverse_kind: relation.inverse_kind,
forward_kind: relation.forward_kind.as_str().to_string(),
note: relation.note,
author: relation.author,
at: relation.at,
})
.collect(),
blockers: value
.blockers
.into_iter()
.map(|blocker| TicketRelationBlocker {
blocking_ticket: blocker.blocking_ticket,
blocking_resource_key: None,
reason_kind: blocker.reason_kind,
relation_kind: blocker.relation_kind.as_str().to_string(),
note: blocker.note,
blocking_state: blocker.blocking_state.as_str().to_string(),
})
.collect(),
notices: value
.notices
.into_iter()
.map(|notice| TicketRelationNotice {
related_ticket: notice.related_ticket,
kind: notice.kind.as_str().to_string(),
message: notice.message,
})
.collect(),
}
pub(crate) fn ticket_relation_view_from_domain(
value: ticket::TicketRelationView,
) -> TicketRelationView {
TicketRelationView {
outgoing: value
.outgoing
.into_iter()
.map(|relation| TicketRelation {
ticket_id: relation.ticket_id,
kind: relation.kind.as_str().to_string(),
target: relation.target,
target_resource_key: None,
note: relation.note,
author: relation.author,
at: relation.at,
})
.collect(),
incoming: value
.incoming
.into_iter()
.map(|relation| DerivedTicketRelation {
source_ticket: relation.source_ticket,
source_resource_key: None,
inverse_kind: relation.inverse_kind,
forward_kind: relation.forward_kind.as_str().to_string(),
note: relation.note,
author: relation.author,
at: relation.at,
})
.collect(),
blockers: value
.blockers
.into_iter()
.map(|blocker| TicketRelationBlocker {
blocking_ticket: blocker.blocking_ticket,
blocking_resource_key: None,
reason_kind: blocker.reason_kind,
relation_kind: blocker.relation_kind.as_str().to_string(),
note: blocker.note,
blocking_state: blocker.blocking_state.as_str().to_string(),
})
.collect(),
notices: value
.notices
.into_iter()
.map(|notice| TicketRelationNotice {
related_ticket: notice.related_ticket,
kind: notice.kind.as_str().to_string(),
message: notice.message,
})
.collect(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveLinkSummary {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketEvidenceEvent {
pub event_ref: String,
pub sequence: usize,
pub kind: String,
pub at: Option<String>,
pub author: Option<String>,
pub excerpt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketAssignmentSummary {
pub assignment_id: String,
pub runtime_id: String,
pub worker_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_resource_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketRoleAssignmentSummary {
pub assignment_id: String,
pub role: String,
pub principal: TicketAssignmentPrincipalSummary,
pub assigned_by: String,
pub assigned_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TicketAssignmentPrincipalSummary {
User {
account_id: String,
},
Worker {
runtime_id: String,
worker_id: String,
},
WorkspaceAgent {
agent_key: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketActionEligibility {
pub can_assign_orchestrator: bool,
pub can_unassign_orchestrator: bool,
pub can_queue: bool,
pub can_start_manual_coder: bool,
pub queue_tickets: Vec<String>,
pub blockers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketMergeRequestSummary {
pub merge_request_id: String,
pub repository_key: String,
pub state: String,
pub review_status: String,
pub selector_from: Option<String>,
pub selector_to: String,
pub updated_at: String,
pub current_subject_ref: Option<String>,
pub review_subject_ref: Option<String>,
pub review_requested_at: Option<String>,
pub review_submitted_at: Option<String>,
pub review_excerpt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MergeRequestRefDiagnostic {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MergeRequestListItem {
pub summary: TicketMergeRequestSummary,
pub ticket_ids: Vec<String>,
pub thread_event_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ref_diagnostics: Vec<MergeRequestRefDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MergeRequestListResponse {
pub items: Vec<MergeRequestListItem>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TicketEvidenceSummary {
pub has_merge_request: bool,
pub has_current_subject_ref: bool,
pub has_review_request: bool,
pub has_commit: bool,
pub review_status: Option<String>,
pub approved_current_subject: bool,
pub review_after_rescope: bool,
pub unresolved_request_changes: bool,
pub complete_for_integration: bool,
pub missing: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TicketQueryRequest {
pub query: Option<String>,
#[serde(default)]
pub states: Vec<String>,
#[serde(default)]
pub event_kinds: Vec<String>,
#[serde(default)]
pub evidence: Vec<String>,
pub review_status: Option<String>,
#[serde(default)]
pub attention: Vec<String>,
pub related_ticket_id: Option<String>,
pub relation_kind: Option<String>,
pub linked_objective_id: Option<String>,
pub updated_after: Option<String>,
pub updated_before: Option<String>,
pub sort: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketQueryItem {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub readiness: Option<String>,
pub priority: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub item_revision: String,
pub workspace_action_priority: String,
pub matched_fields: Vec<String>,
pub snippet: Option<String>,
pub matching_event: Option<TicketEvidenceEvent>,
pub linked_objective_ids: Vec<String>,
pub linked_objective_keys: Vec<String>,
pub relation_count: usize,
pub blocker_count: usize,
pub unresolved_blocker_count: usize,
pub unresolved_review_count: usize,
pub evidence: TicketEvidenceSummary,
pub merge_request: Option<TicketMergeRequestSummary>,
pub current_coder: Option<TicketAssignmentSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketQueryResponse {
pub items: Vec<TicketQueryItem>,
pub page: QueryPage,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TicketShowRequest {
pub event_limit: Option<usize>,
pub event_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ObjectiveQueryRequest {
pub query: Option<String>,
#[serde(default)]
pub states: Vec<String>,
pub linked_ticket_id: Option<String>,
pub updated_after: Option<String>,
pub updated_before: Option<String>,
pub sort: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveQueryItem {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub matched_fields: Vec<String>,
pub snippet: Option<String>,
pub linked_ticket_count: usize,
pub linked_tickets: Vec<String>,
pub linked_ticket_keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveQueryResponse {
pub items: Vec<ObjectiveQueryItem>,
pub page: QueryPage,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ObjectiveShowRequest {
pub event_limit: Option<usize>,
pub event_cursor: Option<String>,
}
pub(crate) fn summarize_body(body: &str) -> String {
let summary = body
.lines()
File diff suppressed because it is too large Load Diff
+240 -71
View File
@@ -7,7 +7,9 @@ use std::time::{Duration, Instant};
use chrono::Utc;
use client::BackendApiClient;
use futures::{SinkExt, StreamExt};
use server_api::{ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse};
use server_api::{
ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse, ServerApiClient,
};
use tokio_tungstenite::connect_async_with_config;
use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig};
use workdir::external::{
@@ -95,40 +97,57 @@ fn pin_selected_root_from(
ExternalWorkdirRoot::pin(absolute).map_err(|error| error.to_string())
}
fn authenticated_server_api_client(
backend_client: &BackendApiClient,
backend_url: &str,
) -> Result<ServerApiClient, String> {
// BackendApiClient remains the stored-credential authority used by the
// manual provider WebSocket. Reuse that transport's Authorization header
// for the generated JSON client instead of loading or interpreting the
// credential a second time here.
let authenticated_request = backend_client
.websocket_request("/")
.map_err(|error| error.to_string())?;
let authorization = authenticated_request
.headers()
.get(reqwest::header::AUTHORIZATION)
.cloned()
.ok_or_else(|| "stored Backend credential did not provide Authorization".to_string())?;
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(reqwest::header::AUTHORIZATION, authorization);
let http_client = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(10))
.build()
.map_err(|error| format!("failed to configure Backend client: {error}"))?;
let base_url = backend_url
.try_into()
.map_err(|error| format!("invalid Backend URL: {error}"))?;
Ok(ServerApiClient::with_client(base_url, http_client))
}
pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
// Pin before creating remote authority: the Backend grant can never outlive
// a failed local approval/open race, and subsequent path replacement cannot
// redirect the provider.
let pinned_root = pin_selected_root(&options.path)?;
let client = BackendApiClient::from_stored_token(&options.backend_url)
let backend_client = BackendApiClient::from_stored_token(&options.backend_url)
.map_err(|error| error.to_string())?;
let server_api_client = authenticated_server_api_client(&backend_client, &options.backend_url)?;
let provider_instance_id = format!("cli-{}", uuid::Uuid::now_v7());
let create_path = format!(
"/api/w/{}/external-workdir-grants",
encode_path_segment(&options.workspace_id)
);
let response = client
.request(reqwest::Method::POST, &create_path)
.map_err(|error| error.to_string())?
.timeout(Duration::from_secs(10))
.json(&ExternalWorkdirGrantCreateRequest {
provider_instance_id: provider_instance_id.clone(),
display_name: options.display_name.clone(),
ttl_seconds: options.ttl.as_secs(),
read_only: true,
})
.send()
let grant = server_api_client
.external_workdir_grant_create(
options.workspace_id.clone(),
ExternalWorkdirGrantCreateRequest {
provider_instance_id: provider_instance_id.clone(),
display_name: options.display_name.clone(),
ttl_seconds: options.ttl.as_secs(),
read_only: true,
},
)
.await
.map_err(|error| format!("External Workdir grant request failed: {error}"))?;
let response = client
.require_success(response)
.await
.map_err(|error| error.to_string())?;
let grant = response
.json::<ExternalWorkdirGrantResponse>()
.await
.map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}"))?;
let limits = BoundedReadLimits::EXTERNAL_DEFAULT;
let session = LocalWorkdirSession::external_read_only_pinned(
@@ -150,7 +169,8 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
let mut generation = grant.generation;
loop {
let outcome = serve_provider_connection(
&client,
&backend_client,
&server_api_client,
&options,
&grant,
&provider_instance_id,
@@ -166,11 +186,12 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
"External Workdir grant expired after provider connection failure: {connection_error}"
));
}
let refresh = fetch_grant(&client, &options.workspace_id, &grant.grant_id);
let refresh =
fetch_grant(&server_api_client, &options.workspace_id, &grant.grant_id);
let refreshed = tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(&client, &options.workspace_id, &grant.grant_id).await?;
revoke(&server_api_client, &options.workspace_id, &grant.grant_id).await?;
println!("External Workdir grant revoked.");
return Ok(());
}
@@ -182,8 +203,12 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
eprintln!(
"Connection refresh failed; retrying: {connection_error}; {refresh_error}"
);
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id)
.await?
if wait_to_reconnect(
&server_api_client,
&options.workspace_id,
&grant.grant_id,
)
.await?
{
println!("External Workdir grant revoked.");
return Ok(());
@@ -213,7 +238,9 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
}
}
println!("Connection: reconnecting");
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? {
if wait_to_reconnect(&server_api_client, &options.workspace_id, &grant.grant_id)
.await?
{
println!("External Workdir grant revoked.");
return Ok(());
}
@@ -237,7 +264,9 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
.checked_add(1)
.ok_or_else(|| "External Workdir provider generation overflow".to_string())?;
println!("Connection: reconnecting");
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? {
if wait_to_reconnect(&server_api_client, &options.workspace_id, &grant.grant_id)
.await?
{
println!("External Workdir grant revoked.");
return Ok(());
}
@@ -247,7 +276,8 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
}
async fn serve_provider_connection(
client: &BackendApiClient,
backend_client: &BackendApiClient,
server_api_client: &ServerApiClient,
options: &WorkdirShareOptions,
grant: &ExternalWorkdirGrantResponse,
provider_instance_id: &str,
@@ -259,7 +289,7 @@ async fn serve_provider_connection(
encode_path_segment(&options.workspace_id),
encode_path_segment(&grant.grant_id)
);
let request = client
let request = backend_client
.websocket_request(&provider_path)
.map_err(|error| error.to_string())?;
let websocket_config = WebSocketConfig::default()
@@ -269,7 +299,7 @@ async fn serve_provider_connection(
let (mut socket, _) = tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(client, &options.workspace_id, &grant.grant_id).await?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted);
}
connection = connection => connection
@@ -292,7 +322,7 @@ async fn serve_provider_connection(
let registered = tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(client, &options.workspace_id, &grant.grant_id).await?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted);
}
registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => {
@@ -324,7 +354,7 @@ async fn serve_provider_connection(
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
stop_operations(&mut operations, &mut completions).await?;
revoke(client, &options.workspace_id, &grant.grant_id).await?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted);
}
completion = completions.recv() => {
@@ -558,7 +588,7 @@ where
}
async fn wait_to_reconnect(
client: &BackendApiClient,
client: &ServerApiClient,
workspace_id: &str,
grant_id: &str,
) -> Result<bool, String> {
@@ -573,51 +603,27 @@ async fn wait_to_reconnect(
}
async fn fetch_grant(
client: &BackendApiClient,
client: &ServerApiClient,
workspace_id: &str,
grant_id: &str,
) -> Result<ExternalWorkdirGrantResponse, String> {
let path = format!(
"/api/w/{}/external-workdir-grants/{}",
encode_path_segment(workspace_id),
encode_path_segment(grant_id)
);
let response = client
.request(reqwest::Method::GET, &path)
.map_err(|error| error.to_string())?
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|error| format!("External Workdir grant refresh failed: {error}"))?;
client
.require_success(response)
.external_workdir_grant_get(workspace_id.to_string(), grant_id.to_string())
.await
.map_err(|error| error.to_string())?
.json::<ExternalWorkdirGrantResponse>()
.await
.map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}"))
.map_err(|error| format!("External Workdir grant refresh failed: {error}"))
}
async fn revoke(
client: &BackendApiClient,
client: &ServerApiClient,
workspace_id: &str,
grant_id: &str,
) -> Result<(), String> {
let path = format!(
"/api/w/{}/external-workdir-grants/{}",
encode_path_segment(workspace_id),
encode_path_segment(grant_id)
);
let response = client
.request(reqwest::Method::DELETE, &path)
.map_err(|error| error.to_string())?
.timeout(Duration::from_secs(10))
.send()
client
.external_workdir_grant_revoke(workspace_id.to_string(), grant_id.to_string())
.await
.map_err(|error| format!("External Workdir revoke request failed: {error}"))?;
client.require_success(response).await.map_err(|error| {
format!("External Workdir revoke was not confirmed by the Backend: {error}")
})?;
.map_err(|error| {
format!("External Workdir revoke was not confirmed by the Backend: {error}")
})?;
Ok(())
}
@@ -636,6 +642,169 @@ fn encode_path_segment(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc;
fn test_server_api_client(base_url: &str) -> ServerApiClient {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_static("Bearer test-token"),
);
let http_client = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(10))
.build()
.unwrap();
ServerApiClient::with_client(base_url.try_into().unwrap(), http_client)
}
fn read_http_request(stream: &mut TcpStream) -> String {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
let mut expected_length = None;
loop {
let read = stream.read(&mut buffer).unwrap();
assert!(read > 0, "HTTP client closed before completing request");
request.extend_from_slice(&buffer[..read]);
if expected_length.is_none()
&& let Some(header_end) =
request.windows(4).position(|window| window == b"\r\n\r\n")
{
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
});
expected_length = Some(header_end + 4 + content_length.unwrap_or(0));
}
if expected_length.is_some_and(|length| request.len() >= length) {
break;
}
}
String::from_utf8(request).unwrap()
}
fn spawn_json_server(
response_body: String,
response_count: usize,
) -> (String, mpsc::Receiver<String>, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (sender, receiver) = mpsc::channel();
let thread = std::thread::spawn(move || {
for _ in 0..response_count {
let (mut stream, _) = listener.accept().unwrap();
let request = read_http_request(&mut stream);
let status = if request.starts_with("POST ") {
"201 Created"
} else {
"200 OK"
};
sender.send(request).unwrap();
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
}
});
(format!("http://{address}"), receiver, thread)
}
fn grant_response_json() -> String {
serde_json::json!({
"grant_id": "grant/a b",
"workspace_id": "workspace/a b",
"working_directory_id": "workdir-a",
"provider_instance_id": "provider-a",
"display_name": "Shared files",
"permissions": "read_only",
"expires_at": "2026-09-23T03:00:00Z",
"generation": 7,
"status": "pending"
})
.to_string()
}
#[tokio::test]
async fn generated_grant_operations_preserve_auth_and_encode_contract_paths() {
let (base_url, requests, server) = spawn_json_server(grant_response_json(), 3);
let client = test_server_api_client(&base_url);
let created = client
.external_workdir_grant_create(
"workspace/a b".to_string(),
ExternalWorkdirGrantCreateRequest {
provider_instance_id: "provider-a".to_string(),
display_name: "Shared files".to_string(),
ttl_seconds: 600,
read_only: true,
},
)
.await
.unwrap();
let fetched = fetch_grant(&client, "workspace/a b", "grant/a b")
.await
.unwrap();
revoke(&client, "workspace/a b", "grant/a b").await.unwrap();
assert_eq!(created.grant_id, "grant/a b");
assert_eq!(fetched.generation, 7);
let requests = (0..3)
.map(|_| requests.recv_timeout(Duration::from_secs(5)).unwrap())
.collect::<Vec<_>>();
server.join().unwrap();
assert!(
requests[0]
.starts_with("POST /api/w/workspace%2Fa%20b/external-workdir-grants HTTP/1.1\r\n")
);
assert!(requests[1].starts_with(
"GET /api/w/workspace%2Fa%20b/external-workdir-grants/grant%2Fa%20b HTTP/1.1\r\n"
));
assert!(requests[2].starts_with(
"DELETE /api/w/workspace%2Fa%20b/external-workdir-grants/grant%2Fa%20b HTTP/1.1\r\n"
));
for request in &requests {
assert!(
request
.to_ascii_lowercase()
.contains("\r\nauthorization: bearer test-token\r\n")
);
}
assert!(requests[0].contains("\"provider_instance_id\":\"provider-a\""));
assert!(requests[0].contains("\"ttl_seconds\":600"));
}
#[tokio::test]
async fn generated_grant_response_is_bounded() {
let oversized_body = format!("{{\"padding\":\"{}\"}}", "x".repeat(1_048_576));
let (base_url, requests, server) = spawn_json_server(oversized_body, 1);
let client = test_server_api_client(&base_url);
let error = fetch_grant(&client, "workspace-a", "grant-a")
.await
.unwrap_err();
let request = requests.recv_timeout(Duration::from_secs(5)).unwrap();
server.join().unwrap();
assert!(
request
.starts_with("GET /api/w/workspace-a/external-workdir-grants/grant-a HTTP/1.1\r\n")
);
assert!(error.contains("External Workdir grant refresh failed"));
assert!(
error.contains("response body exceeded 1048576 bytes"),
"{error}"
);
}
#[test]
fn relative_share_path_is_resolved_and_pinned_before_backend_access() {
+335 -4173
View File
File diff suppressed because it is too large Load Diff
@@ -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:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07)
// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe)
// 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
@@ -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:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07)
// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe)
// 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:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07)
// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe)
// 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:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07)
// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe)
// 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 -69
View File
@@ -1,84 +1,20 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07)
// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe)
// 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
export type CommandOutput = { content: string; exit_code?: number | null; next_cursor?: number | null; output_path?: string | null; status: CommandStatus; timed_out: boolean; truncated: boolean; };
export type CommandOutputRequest = { cursor: number; handle: string; limit: number; wait: boolean; };
export type CommandRequest = { command: string; cwd?: FsPath | null; output_limit: number; spill_dir?: string | null; timeout_secs: number; tool_call_id?: string | null; };
export type CommandStatus = "running" | "completed" | "failed" | "timed_out" | "cancelled";
export type CurrentWorkerWorkdirAttachRequest = { alias: string; working_directory_id: string; };
export type CurrentWorkerWorkdirAttachmentResponse = { alias: string; attached: boolean; capabilities: WorkdirSessionCapabilities; working_directory_id: string; workspace_id: string; };
export type CurrentWorkerWorkdirOperationRequest = { operation: WorkdirSessionOperation; target_workdir: string; };
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
export type DiagnosticSeverity = "info" | "warning" | "error";
export type EditRequest = { expected_hash: [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number]; new_string: string; old_string: string; path: FsPath; replace_all: boolean; };
export type EditResult = { bytes_written: number; content_hash: [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number]; replacements: number; };
export type EntryKind = "file" | "directory" | "symlink" | "other";
export type ExternalWorkdirGrantCreateRequest = { display_name: string; provider_instance_id: string; read_only: boolean; ttl_seconds: number; };
export type ExternalWorkdirGrantResponse = { display_name: string; expires_at: string; generation: number; grant_id: string; permissions: string; provider_instance_id: string; status: string; working_directory_id: string; workspace_id: string; };
export type FsPath = string;
export type GlobRequest = { limit: number; path: FsPath; pattern: string; };
export type GlobResult = { paths: Array<FsPath>; truncated: boolean; };
export type GrepOutputMode = "content" | "files_with_matches" | "count";
export type GrepRequest = { after_context: number; before_context: number; case_insensitive: boolean; file_type?: string | null; glob?: string | null; limit: number; multiline: boolean; offset: number; output_mode: GrepOutputMode; path: FsPath; pattern: string; };
export type GrepResult = { match_count: number; matched_files: number; output: string; truncated: boolean; };
export type ListEntry = { kind: EntryKind; path: FsPath; size: number; };
export type ListRequest = { limit: number; path: FsPath; };
export type ListResult = { entries: Array<ListEntry>; total_bytes: number; total_entries: number; truncated: boolean; };
export type ReadRequest = { limit: number; max_bytes: number; offset: number; path: FsPath; };
export type ReadResult = { bytes: Array<number>; content_hash: [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number]; path: FsPath; start_line: number; total_lines: number; truncated: boolean; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type StatRequest = { path: FsPath; };
export type StatResult = { kind: EntryKind; path: FsPath; size: number; };
export type WorkdirOperationApiError = { code?: WorkdirTransportErrorCode | null; diagnostics?: Array<Diagnostic> | null; error?: string | null; message: string; };
export type WorkdirScopeAuthorizationRequest = { path: FsPath; permission: WorkdirToolScopePermission; rules: Array<WorkdirToolScopeRule>; };
export type WorkdirScopeOverlapRequest = { left: WorkdirToolScopeRule; right: WorkdirToolScopeRule; };
export type WorkdirSessionCapabilities = { bits: number; };
export type WorkdirSessionOperation = { operation: "authorize_scope"; request: WorkdirScopeAuthorizationRequest; } | { operation: "scope_rules_overlap"; request: WorkdirScopeOverlapRequest; } | { operation: "stat"; request: StatRequest; } | { operation: "read"; request: ReadRequest; } | { operation: "write"; request: WriteRequest; } | { operation: "edit"; request: EditRequest; } | { operation: "list"; request: ListRequest; } | { operation: "glob"; request: GlobRequest; } | { operation: "grep"; request: GrepRequest; } | { operation: "command_start"; request: CommandRequest; } | { operation: "command_status"; request: string; } | { operation: "command_output"; request: CommandOutputRequest; } | { operation: "command_cancel"; request: string; };
export type WorkdirSessionOperationResult = { operation: "authorize_scope"; } | { operation: "scope_rules_overlap"; result: { overlaps: boolean; }; } | { operation: "stat"; result: StatResult; } | { operation: "read"; result: ReadResult; } | { operation: "write"; result: WriteResult; } | { operation: "edit"; result: EditResult; } | { operation: "list"; result: ListResult; } | { operation: "glob"; result: GlobResult; } | { operation: "grep"; result: GrepResult; } | { operation: "command_start"; result: string; } | { operation: "command_status"; result: CommandStatus; } | { operation: "command_output"; result: CommandOutput; } | { operation: "command_cancel"; };
export type WorkdirToolScopePermission = "read" | "write";
export type WorkdirToolScopeRule = { permission: WorkdirToolScopePermission; recursive: boolean; symlink_policy?: string; target: FsPath; };
export type WorkdirTransportErrorCode = "not_found" | "conflict" | "unsupported" | "invalid_request" | "denied" | "out_of_scope" | "symlink_out_of_scope" | "broken_symlink" | "symlink_target_is_directory" | "read_only" | "is_directory" | "symlink_directory_not_traversed" | "unknown_command" | "unavailable" | "io" | "transport" | "internal";
export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; };
export type WorkingDirectoryCreateRequest = { display_name?: string | null; operation_id?: string | null; repository_key: string; runtime_id?: string | null; selector?: string | null; };
@@ -104,7 +40,3 @@ export type WorkingDirectorySource = { kind: "repository"; repository_key: strin
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type WriteRequest = { content: Array<number>; expected_hash?: [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number] | null; path: FsPath; };
export type WriteResult = { bytes_written: number; created: boolean; };
@@ -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:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07)
// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe)
// 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